3. 中间表示(IR)概述:IR的定义、分类与设计原则
聊到深度学习编译器,有个东西绕不开——中间表示,也就是IR。我刚开始接触编译器那会儿,觉得IR就是个过渡产物,没啥大不了的。后来踩了不少坑才明白,IR选得好不好,直接决定了编译器的上限。
说白了,IR就是编译器内部用来表示程序的一种数据结构。它既不是源代码,也不是目标机器码,而是夹在中间的一个抽象层。你想想看,编译器要处理那么多前端框架(PyTorch、TensorFlow、ONNX),又要生成不同硬件(CPU、GPU、NPU)的代码,没有IR做桥梁,那得多乱?
3.1 IR的定义
IR的全称是Intermediate Representation,翻译过来就是中间表示。它的核心作用就两个:承载语义和方便变换。
我个人的理解是,IR就像翻译过程中的"笔记"。你把中文翻译成英文,不可能直接一句一句硬翻,得先理解意思,记下要点,再组织成英文。编译器也是这个道理——先把深度学习模型"理解"成IR,再基于IR做各种优化,最后生成目标代码。
IR的三个核心特征:
- 语义完整:能完整表达原始模型的计算逻辑
- 结构规整:有固定的语法和结构,方便程序处理
- 可变换:支持各种优化pass的改写和转换
嗯,这里要注意一点:IR不是唯一的。不同编译器、不同阶段,用的IR可能完全不同。这就引出了IR的分类问题。
3.2 IR的分类:HIR、MIR、LIR
根据抽象层级,IR通常分为三类:高层IR(HIR)、中层IR(MIR)和低层IR(LIR)。这个分类不是绝对的,但大部分编译器都遵循这个思路。
| 类型 | 抽象层级 | 典型代表 | 主要用途 |
|---|---|---|---|
| HIR | 接近源语言 | Graph IR、TVM Relay | 图优化、算子融合 |
| MIR | 中间层级 | MLIR、XLA HLO | 内存优化、数据布局变换 |
| LIR | 接近机器码 | LLVM IR、TIR | 寄存器分配、指令调度 |
3.2.1 高层IR(HIR)
HIR离原始模型最近。它保留了完整的计算图结构,包括张量形状、数据类型、算子语义等信息。我在做TVM Relay优化时,就经常在HIR层面做算子融合——把连续的conv+bias+relu合并成一个算子,减少内存访问。
HIR的特点是:语义丰富,但优化空间有限。因为它太"高"了,很多底层细节看不到,比如内存布局、寄存器分配这些,在HIR层面根本没法做。
3.2.2 中层IR(MIR)
MIR是HIR和LIR之间的桥梁。它把高层语义拆解成更细粒度的操作,同时保留足够的语义信息供优化使用。
举个例子,HIR里的一个"矩阵乘法"节点,到了MIR层面可能被拆成"循环+乘加操作"。这样,编译器就能做循环展开、向量化等优化了。
我曾经在MLIR里做过一个数据布局变换的pass,就是在MIR层面把NHWC格式转成NCHW格式。这种优化在HIR层面做太粗糙,在LIR层面做又太复杂,MIR刚刚好。
3.2.3 低层IR(LIR)
LIR离机器码只有一步之遥。它通常采用静态单赋值(SSA)形式,有明确的指令集语义。LLVM IR就是典型的LIR。
LIR层面的优化非常"硬核":寄存器分配、指令选择、指令调度……这些优化直接决定了最终代码的执行效率。我记得有一次调试一个性能问题,折腾了半天,最后发现是LIR层面的寄存器溢出导致的——嗯,这种问题在HIR和MIR层面根本看不出来。
3.3 IR的设计原则
设计一个好的IR,比想象中难得多。我见过不少编译器,因为IR设计得不好,后面优化根本推不动。这里分享几个我总结的设计原则。
3.3.1 原则一:语义清晰,无歧义
IR的语义必须明确。同一个操作,不能有多种解释。比如"加法",你得说清楚是整数加法还是浮点加法,有没有溢出处理。
我曾经接手过一个项目,它的IR里有个"通用计算节点",什么操作都能往里塞。结果呢?优化pass根本不敢动这个节点,因为不知道里面到底是什么。这就是语义不清晰的典型反面教材。
3.3.2 原则二:层次分明,职责单一
不同层级的IR各管各的事。HIR只管图结构和算子语义,别去操心寄存器分配;LIR只管指令选择和调度,别去管算子融合。
我建议在设计IR时,先想清楚"这个IR要服务哪些优化pass",然后只保留必要的语义信息。多余的语义就是负担。
3.3.3 原则三:可扩展,不僵化
深度学习领域发展太快,今天出来个新算子,明天出来个新硬件。IR如果设计得太死,后面加东西就痛苦了。
MLIR在这方面做得不错。它用"Dialect"机制来扩展IR——每个Dialect定义一组操作和类型,互不干扰。你想加一个新硬件支持?写个新Dialect就行,不用动已有的IR结构。
避坑指南: 我曾经在一个项目里把IR设计得过于"完美",把所有可能的操作都预定义好了。结果新算子一来,发现预定义的操作根本不够用,还得打补丁。后来我学乖了——IR设计要留有余地,别想着一次性搞定所有事情。
3.3.4 原则四:便于分析和变换
IR最终是要被分析和变换的。如果IR结构太复杂,分析pass写起来就痛苦;如果IR结构太简单,又丢失了太多语义信息。
这里有个平衡点。我个人习惯是:先保证分析pass能高效实现,再考虑语义完整性。因为分析pass是编译器里最常写的代码,如果每次写pass都像在解谜题,那开发效率就太低了。
3.4 知识体系总览
说了这么多,我画了一张图来总结IR的核心知识体系。你看一眼,应该就能对IR有个整体把握。
这张图把IR的分类和设计原则串在了一起。你看,HIR、MIR、LIR各有各的优化重点,而四个设计原则贯穿始终。说白了,IR设计就是一场"抽象与具体"的博弈——太抽象了优化做不了,太具体了又失去了通用性。
注意: 不要试图设计一个"万能IR"。我见过有人想把所有优化都在一个IR里做完,结果IR变得臃肿不堪,每个pass都要处理大量无关信息。记住:不同阶段用不同的IR,各司其职。
好了,关于IR的概述就聊到这里。下一节我们会深入具体的IR表示形式,看看图IR、SSA IR这些到底是怎么工作的。
公众号:蓝海资料掘金营,微信deep3321