第四章:中间表示(IR)——编译器的“通用语言”

好,咱们进入正题。今天聊中间表示,也就是IR。

IR这东西,说白了就是编译器内部用来描述程序的一种“中间语言”。它既不像源代码那样贴近人类,也不像机器码那样贴近硬件。它卡在中间,起到承上启下的作用。我个人习惯把IR比作翻译过程中的“笔记”——你先把中文记成一种结构化的符号,再把这些符号转成英文,这样效率最高。

4.1 IR的设计原则:为什么不能随便搞?

设计IR不是拍脑袋的事。我见过不少团队,上来就自己定义一套IR,结果后面改得想哭。这里有几个核心原则,是我在实际项目中踩坑总结出来的。

  • 层次化:IR不能只有一层。高层IR贴近语义,低层IR贴近硬件。你想想看,如果所有优化都在同一层做,那要么丢失信息,要么过于琐碎。
  • SSA形式:静态单赋值,这是现代IR的标配。每个变量只赋值一次,数据流分析变得极其简单。嗯,这里要注意,SSA不是银弹,但它确实让很多优化变得优雅。
  • 类型系统:IR必须携带类型信息。我在项目中遇到过,有人为了省事把类型全丢了,结果后面做内存优化时根本不知道这个张量是int8还是float16,只能靠猜。
  • 可扩展性:你永远不知道明天会接入什么新算子。IR的设计要留好扩展点,别把自己写死。

核心观点:好的IR设计,应该让“添加一个新优化”这件事,像在乐高上插一块积木一样自然。

4.2 常见的IR:TVM Relay/Relax、MLIR、XLA HLO

现在业界主流的IR,我挑三个最有代表性的聊聊。

4.2.1 TVM Relay / Relax

TVM早期用的是Relay IR。它是个函数式的、基于图的IR。每个节点代表一个算子,边代表数据依赖。Relay的设计思路很清晰:把神经网络的计算图表达成一种可嵌套的、带类型的表达式。

后来TVM团队推出了Relax。为什么?因为Relay在处理动态形状、控制流时有点吃力。Relax引入了更灵活的变量绑定机制,说白了就是让IR能更好地描述“循环”和“条件分支”。

# Relay 示例:一个简单的卷积+ReLU
def @main(%x: Tensor[(1, 3, 224, 224), float32]) {
  %w = param("weight")
  %conv = nn.conv2d(%x, %w, padding=[1,1])
  %relu = nn.relu(%conv)
  %relu
}

个人经验:我曾经在部署一个动态形状的模型时,被Relay的静态形状假设坑了一把。后来切到Relax,问题迎刃而解。所以,如果你的模型有动态维度,优先考虑Relax。

4.2.2 MLIR

MLIR是Google搞的,它的野心更大——它不只是一个IR,而是一个IR框架。你可以用MLIR定义自己的IR方言(Dialect)。比如TensorFlow有自己的TFRT方言,PyTorch有自己的Torch-MLIR方言。

MLIR的核心思想是“多级IR”。从高层的Tensor操作,到低层的循环和向量化,每一层都有对应的方言。方言之间通过转换(Pass)来降级。

// MLIR 示例:一个简单的linalg方言操作
func.func @matmul(%A: tensor<4x4xf32>, %B: tensor<4x4xf32>) -> tensor<4x4xf32> {
  %C = linalg.matmul ins(%A, %B: tensor<4x4xf32>, tensor<4x4xf32>)
                     outs(%init: tensor<4x4xf32>) -> tensor<4x4xf32>
  return %C
}

避坑指南:我曾经试图在一个小团队里全面推广MLIR,结果发现学习曲线太陡了。MLIR的抽象层次太高,如果你只是做简单的模型编译,用TVM Relay可能更直接。MLIR更适合那些需要自定义IR、做深度硬件适配的场景。

4.2.3 XLA HLO

XLA HLO是Google XLA编译器的IR。它是个高度优化的、面向线性代数的IR。HLO的设计非常简洁,每个操作都是纯函数式的,没有副作用。这使得HLO的并行化和内存优化非常容易做。

HLO后来演变成了StableHLO,主要是为了标准化和跨平台兼容。StableHLO的规范写得很清楚,我建议做硬件后端的同学仔细读一读。

// HLO 示例:一个简单的加法
HloModule add_module
ENTRY %add (x: f32[2,2], y: f32[2,2]) -> f32[2,2] {
  %x = f32[2,2] parameter(0)
  %y = f32[2,2] parameter(1)
  ROOT %add = f32[2,2] add(%x, %y)
}

4.3 IR的层次结构:从抽象到具体

IR不是一层不变的。一个好的编译器,IR应该是分层的。我画了一张图,帮你理解这个层次结构。

IR层次结构图 高层IR(HIR) 贴近语义,保留控制流、数据流、类型信息 示例:TVM Relay, MLIR TOSA, XLA HLO 降级(Lowering) 中层IR(MIR) 展开循环、内存规划、算子融合 示例:TVM TE, MLIR Linalg, XLA LMHLO 降级(Lowering) 低层IR(LIR) 贴近硬件,处理寄存器、指令选择、调度 示例:MLIR Vector/LLVM, TVM TIR, XLA LLVM 代码生成 机器码 / 目标代码

你看这张图,从上到下,IR越来越具体。高层IR关心“做什么”,低层IR关心“怎么做”。每一层降级,都会丢失一些语义信息,但换来的是更精确的硬件映射。

举个例子。在高层IR里,一个卷积操作就是一个节点。到了中层IR,这个卷积可能被展开成多层循环和矩阵乘法。到了低层IR,这些循环会被向量化、并行化,最终映射到具体的指令。

我的建议:做模型编译器时,不要试图在一个IR里解决所有问题。分层的IR设计,虽然增加了转换的复杂度,但让每一层的优化目标变得清晰。你想想看,如果所有优化都混在一起,调试起来得多痛苦?

4.4 小结

IR是模型编译器的核心。设计IR时,要记住层次化、SSA、类型系统和可扩展性这几个原则。TVM Relay/Relax适合做神经网络编译,MLIR适合做自定义IR框架,XLA HLO适合做高性能线性代数计算。而IR的分层结构,则是从抽象到具体的必经之路。

嗯,这一章就到这里。下一章我们会聊算子融合,那是模型编译器中最重要的优化之一。到时候我会分享一些实际项目中的调优经验。

可可精品资料库