4、中间表示(IR):多层IR设计哲学,Graph IR与Linear IR,TVM的Relay IR与TIR
好,咱们今天聊聊中间表示。IR,Intermediate Representation,说白了就是编译器内部用来表示程序的一种“中间语言”。你写的是Python,机器要的是二进制,IR就是中间的桥梁。
我个人习惯把IR比作“翻译草稿”。你想想看,直接把人话翻译成机器语言,太容易出错了。先翻译成一种结构清晰、便于分析的中间形式,再逐步优化、最终生成目标代码,这才是正经做法。
4.1 多层IR设计哲学
为什么需要多层IR?一个IR不够用吗?
嗯,这里要注意。单一IR往往顾此失彼。高层IR离源语言近,便于做高级优化,比如算子融合、内存布局变换。低层IR离硬件近,便于做寄存器分配、指令调度。你不可能用一个IR同时搞定这两件事。
我在项目中遇到过这种情况:一开始只用了一层IR,结果做高级优化时发现底层细节太多,干扰判断;做底层优化时又发现信息不够,还得反向推导。折腾了几个月,最后还是拆成了两层。
TVM的做法就很典型。它用了两层IR:
- Relay IR:高层IR,表达计算图,关注算子级别的优化
- TIR:底层IR,表达循环和内存访问,关注硬件映射
这种分层设计,说白了就是“各司其职”。高层IR只管“做什么”,底层IR只管“怎么做”。中间通过一个Pass(Lowering)来衔接。
核心哲学:每一层IR只关注自己那一层的抽象。不要试图用一个IR解决所有问题。
4.2 Graph IR与Linear IR
IR从结构上可以分为两大类:Graph IR和Linear IR。
Graph IR,也叫图IR,用节点表示操作,用边表示数据依赖。典型的代表有TVM的Relay、TensorFlow的GraphDef、ONNX。它的优势是全局视野,便于做算子融合、死代码消除、内存规划。
Linear IR,也叫线性IR,用指令序列表示程序。典型的代表有LLVM IR、TVM的TIR。它的优势是贴近硬件,便于做指令调度、寄存器分配。
我举个例子你就明白了。假设有一个简单的计算:a = x + y; b = a * 2。
Graph IR看起来像这样:
x y
\ /
+ (add)
|
a 2
| /
* (mul)
|
b
Linear IR看起来像这样:
%a = add %x, %y
%b = mul %a, 2
你看,Graph IR更直观地展示了数据流动,而Linear IR更接近汇编。两者各有用途,不能互相替代。
我的建议:做模型编译器时,先用Graph IR做高层优化,再Lower到Linear IR做底层优化。不要试图跳过任何一步。
4.3 TVM的Relay IR与TIR
TVM的Relay IR,全称是Relay Intermediate Representation。它本质上是一个函数式IR,支持控制流、递归、高阶函数。但最常用的场景还是表达计算图。
Relay IR的一个关键特性是类型系统。每个节点都有明确的类型信息,比如Tensor形状、数据类型。这为后续的自动微分、算子选择提供了基础。
举个例子,一个简单的卷积+ReLU在Relay中是这样表示的:
def @main(%x: Tensor[(1, 3, 224, 224), float32]) {
%w = param("weight", Tensor[(64, 3, 3, 3), float32])
%conv = nn.conv2d(%x, %w, padding=[1, 1], strides=[2, 2])
%relu = nn.relu(%conv)
%relu
}
你看,Relay IR保留了算子级别的语义。nn.conv2d、nn.relu这些高级操作一目了然。优化Pass可以基于这些语义做融合、布局变换。
而TIR,全称是Tensor Intermediate Representation。它表达的是循环嵌套和内存访问。同一个卷积在TIR中长这样:
for i in range(64):
for j in range(112):
for k in range(112):
for m in range(3):
for n in range(3):
for c in range(3):
C[i][j][k] += A[i][c][j*2+m][k*2+n] * W[i][c][m][n]
嗯,这里要注意。TIR暴露了循环、内存地址、数据复用模式。优化Pass可以基于这些信息做循环分块、向量化、内存对齐。
我曾经踩过一个坑:在Relay层做内存布局变换,结果发现TIR层又给改回去了。后来才明白,布局变换这种跟硬件强相关的优化,应该在TIR层做,而不是Relay层。
避坑指南:我曾经在Relay层做了大量内存布局优化,结果Lower到TIR后全部失效。原因是TIR有自己的内存规划Pass。正确的做法是:Relay层只做算子融合、常量折叠等高级优化,内存布局、循环变换留给TIR。
4.4 从Relay到TIR的Lowering过程
Lowering,说白了就是把高层IR翻译成低层IR。这个过程不是一步到位的,而是分多个Pass逐步完成。
TVM的Lowering流程大致如下:
- Relay IR:计算图表示,算子级别
- Relay-to-TIR Lowering:将每个Relay算子展开为TIR的循环嵌套
- TIR:循环表示,内存访问级别
- TIR优化Pass:循环分块、向量化、内存对齐
- 代码生成:生成目标硬件代码(CUDA、LLVM、OpenCL等)
我画了一张图,帮你理解这个过程:
你看,整个过程就像剥洋葱。每一层只做自己该做的事。Relay层不关心循环怎么展开,TIR层不关心算子是什么语义。这种解耦设计,让每一层的优化Pass都能专注、高效。
4.5 小结
多层IR设计,说白了就是“分而治之”。Graph IR给你全局视野,Linear IR给你硬件细节。TVM的Relay和TIR就是这种思想的典型实践。
我个人建议,如果你要自己写模型编译器,至少分两层IR。高层做算子优化,低层做硬件映射。中间通过Lowering Pass衔接。别想着一步到位,那只会让你陷入无尽的调试地狱。
嗯,今天就聊到这里。IR这块内容很多,但核心思想就一个:抽象层次要清晰,每一层只关注自己的事。
一句话总结:Relay IR告诉你“算什么”,TIR告诉你“怎么算”。两者结合,才能高效地把模型跑在硬件上。