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流程大致如下:

  1. Relay IR:计算图表示,算子级别
  2. Relay-to-TIR Lowering:将每个Relay算子展开为TIR的循环嵌套
  3. TIR:循环表示,内存访问级别
  4. TIR优化Pass:循环分块、向量化、内存对齐
  5. 代码生成:生成目标硬件代码(CUDA、LLVM、OpenCL等)

我画了一张图,帮你理解这个过程:

TVM IR Lowering 流程 Relay IR 计算图表示 算子级别 Lowering TIR 循环嵌套表示 内存访问级别 优化Pass 优化后TIR 循环分块 向量化 代码生成 目标代码 CUDA / LLVM / OpenCL 图:TVM从Relay IR到目标代码的Lowering流程

你看,整个过程就像剥洋葱。每一层只做自己该做的事。Relay层不关心循环怎么展开,TIR层不关心算子是什么语义。这种解耦设计,让每一层的优化Pass都能专注、高效。

4.5 小结

多层IR设计,说白了就是“分而治之”。Graph IR给你全局视野,Linear IR给你硬件细节。TVM的Relay和TIR就是这种思想的典型实践。

我个人建议,如果你要自己写模型编译器,至少分两层IR。高层做算子优化,低层做硬件映射。中间通过Lowering Pass衔接。别想着一步到位,那只会让你陷入无尽的调试地狱。

嗯,今天就聊到这里。IR这块内容很多,但核心思想就一个:抽象层次要清晰,每一层只关注自己的事。

一句话总结:Relay IR告诉你“算什么”,TIR告诉你“怎么算”。两者结合,才能高效地把模型跑在硬件上。