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的关系。
从这张图你可以看到,整个流程是:前端模型 -> 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是推理引擎的“骨架”,设计得好,后面的工作就顺风顺水;设计得不好,后面全是坑。