4. IR中间表示

各位同学好,我是你们这堂课的主讲人。今天咱们聊聊IR,也就是中间表示。

说实话,IR这东西,刚入行的时候我觉得它就是个“中间商”,没啥技术含量。直到我在一个项目里,被一个糟糕的IR设计坑得加班到凌晨三点……嗯,从那以后,我再也不敢小看它了。

你想想看,推理引擎要支持那么多框架——PyTorch、TensorFlow、ONNX,每个框架的模型格式都不一样。如果没有一个统一的IR,你的引擎就得为每个框架写一套独立的优化和代码生成逻辑。那工作量,想想就头大。

1.1 为什么需要IR?

说白了,IR就是推理引擎的“通用语言”。

它的核心作用有三个:

  • 解耦前端和后端:前端负责把各种框架的模型转成IR,后端负责把IR转成目标硬件的代码。两者互不干扰。
  • 方便做优化:在IR这个层面,你可以做算子融合、内存规划、常量折叠等等。这些优化跟具体硬件无关,一次实现,到处受益。
  • 支持多硬件:同一个IR,你可以把它编译到CPU、GPU、NPU。只要后端实现得好,一套IR走天下。

核心观点:IR的设计质量,直接决定了推理引擎的可扩展性和优化上限。我见过一些引擎,IR设计得太“死”,后面想加一个新硬件支持,几乎要重写整个优化流程。

1.2 IR的设计原则

我在做推理引擎的时候,总结了几条IR设计的原则。不一定全对,但至少让我少踩了不少坑。

原则一:足够抽象,但不要过度抽象

IR的抽象层级要适中。太具体了,比如直接暴露硬件寄存器,那优化就绑死在硬件上;太抽象了,比如把整个模型表示成一个巨大的函数,那优化又无从下手。

我个人习惯,把IR设计成“算子级”的。每个算子对应一个计算操作,比如卷积、池化、全连接。这样既保留了语义信息,又给了优化足够的空间。

原则二:支持控制流

很多早期的推理引擎只支持静态图,也就是一个DAG(有向无环图)。但现在的模型越来越复杂,比如Transformer里有条件分支、循环。如果你的IR不支持控制流,那这些模型就没办法表示。

我曾经在一个项目里,为了支持一个带if-else的模型,不得不把IR推倒重来。那感觉,就像盖好了房子才发现地基打歪了。

原则三:可扩展性

新的算子、新的硬件、新的优化策略,这些都是会不断出现的。你的IR设计要能轻松地扩展,而不是每次加个新东西都要改核心数据结构。

我建议用“注册机制”来管理算子和优化pass。这样,新增一个算子就像插个U盘一样简单。

小技巧:在设计IR的数据结构时,尽量用“属性”而不是“子类”来表达算子的变体。比如,一个卷积算子,用属性来表示padding、stride,而不是为每个变体都创建一个子类。这样能避免类爆炸。

1.3 Graph IR vs. Linear IR

好,接下来咱们聊聊两种主流的IR形式:Graph IR 和 Linear IR。

这两种IR,说白了就是“图”和“序列”的区别。一个强调结构,一个强调顺序。

Graph IR

Graph IR把模型表示成一个有向图。节点是算子,边是数据依赖。典型的代表就是ONNX、TensorFlow的GraphDef。

它的优点很明显:

  • 直观:一眼就能看出算子的连接关系,方便做全局优化。
  • 支持并行:图中没有依赖关系的节点可以并行执行。
  • 便于做图变换:比如算子融合、常量折叠,这些操作在图上做非常自然。

但Graph IR也有缺点:

  • 内存开销大:要维护图的结构,节点、边、属性,这些都要占内存。
  • 执行效率低:运行时需要遍历图,调度节点,这比线性执行要慢。

Linear IR

Linear IR把模型表示成一个指令序列。每条指令就是一个算子,指令之间通过虚拟寄存器或栈来传递数据。典型的代表就是LLVM IR、TVM的Relay IR(某种程度上)。

它的优点:

  • 紧凑:没有图结构,就是一条条指令,内存占用小。
  • 执行高效:指令顺序执行,没有调度开销。
  • 便于做底层优化:比如寄存器分配、指令选择,这些在Linear IR上做非常合适。

缺点也很明显:

  • 丢失了结构信息:你很难一眼看出哪些算子可以融合。
  • 不利于做全局优化:因为数据依赖关系是隐式的,需要做数据流分析才能知道。

注意:不要以为Linear IR就比Graph IR“低级”。实际上,很多推理引擎是两者结合的。比如,前端用Graph IR做高层优化,然后降级成Linear IR做底层优化。我参与的一个项目,就是用Graph IR做算子融合,然后转成Linear IR做内存规划。

1.4 一个简单的对比表格

特性 Graph IR Linear IR
表示方式 有向图 指令序列
内存开销 高 低
执行效率 低(需调度) 高(顺序执行)
优化能力 适合全局优化 适合局部优化
典型代表 ONNX, TensorFlow GraphDef LLVM IR, TVM Relay

1.5 知识体系图

下面这张图,是我自己画的,展示了IR在整个推理引擎中的位置,以及Graph IR和Linear IR的关系。

前端(PyTorch/TF/ONNX) 解析 Graph IR 降级 Linear IR 代码生成 后端(CPU/GPU/NPU) 优化Pass 算子融合 常量折叠 内存规划 指令选择 寄存器分配 Graph IR Linear IR 优化Pass 作用方向

从这张图你可以看到,整个流程是:前端模型 -> Graph IR -> 优化 -> Linear IR -> 优化 -> 后端代码。Graph IR和Linear IR不是互斥的,而是互补的。一个负责“宏观”优化,一个负责“微观”优化。

1.6 实战中的选择建议

那么,在实际项目中,到底该选Graph IR还是Linear IR?

我的建议是:

  • 如果你的目标是快速原型验证,或者你的模型结构相对简单,Graph IR就够了。它直观,容易调试。
  • 如果你的目标是极致性能,或者你要支持多种硬件,那最好用两层IR。先用Graph IR做高层优化,再降级到Linear IR做底层优化。
  • 如果你在做编译器后端,比如你要生成汇编代码,那Linear IR是必须的。因为只有线性序列才能方便地做指令调度和寄存器分配。

避坑指南:我曾经在一个项目里,一开始只用了Graph IR,结果到了后期要做内存优化时,发现图结构太“散”了,根本没法做精细的内存规划。后来不得不加了一层Linear IR,才把问题解决。所以,如果你预见到未来要做底层优化,一开始就设计好两层IR,别等到后面再改。

好了,关于IR的设计原则和两种IR的区别,今天就聊到这里。记住,IR是推理引擎的“骨架”,设计得好,后面的工作就顺风顺水;设计得不好,后面全是坑。