土法炼钢兴趣小组的算法知识备份

【编译器与 MLIR】编译器的挑战与 IR 的裂变

文章导航

分类入口
compilerarchitecture
标签入口
#mlir#llvm#compiler#ir#halide#tvm#xla#codegen#domain-specific-architecture

目录

编译器的挑战与 IR 的裂变

这是「编译器与 MLIR」系列的第一篇。MLIR 是什么、怎么用,后面的篇章会用代码和实验讲清楚。这一篇只回答一个前置问题:为什么我们需要 MLIR 这个新东西?LLVM IR 不已经是”编译器基础设施的终结者”了吗?

要回答这个问题,需要先看过去二十年编译器工程发生了什么——尤其是 AI 编译器和领域专用架构(DSA)的崛起如何把传统中间表示(IR)推到了极限。

一、三阶段编译器的黄金年代

编译器架构的教科书模型——前端、中端(优化器)、后端——统治了半个世纪。这个模型的威力在于解耦:前端只管把源语言翻译成中间表示(IR),优化器在 IR 上做与语言和目标无关的变换,后端把 IR 翻译成目标机器的指令。

LLVM 是这个模型的巅峰。它的核心洞见写在 2004 年 CGO 论文里:

  1. IR 是编译器的心脏,所有语言前端和所有目标后端共享同一个 IR;
  2. IR 以 SSA 形式表达,分析精度和变换正确性都有坚实的形式化基础;
  3. Pass 管线是可组合的,优化策略通过串联独立的 Pass 来配置 [Lattner & Adve, 2004]。

这套设计让 LLVM 在通用编译器市场取得了压倒性成功。Clang(C/C++)、Rust、Swift、Julia、Zig 等语言的编译器都基于 LLVM IR,x86、ARM、RISC-V、WebAssembly 等目标都有成熟的后端。一次写成、到处运行。它很纯粹——一个 IR 承载了从高级语言到机器码的完整降阶路径。

但它有前提条件。

三阶段模型的隐含假设是:IR 的抽象层级是固定的。一条降阶路径走到底:前端把语言特性全部打散到 IR 的操作粒度,然后优化器在同一个抽象层级上做变换,最后后端把 IR 指令逐一映射到机器指令。

当编译目标是一颗通用 CPU(x86 或 ARM)时,这个假设基本成立。整数运算就是整数运算,跳转就是跳转,IR 的粒度刚好匹配 CPU 指令集的粒度。

但当编译目标是张量计算、图像处理流水线、硬件加速器指令集时,这个假设就塌了。

二、Halide 的第一次裂变:算法与调度不是一回事

2013 年,MIT CSAIL 的 Ragan-Kelley 等人在 PLDI 上发表了 Halide [Ragan-Kelley et al., 2013]。Halide 解决的问题很具体:怎么写图像处理代码既能让人看懂,又能跑得快?

在此之前,高性能图像处理的代码是两种东西的混合物:

// 伪代码——图像处理的典型写法
for (int y = 0; y < height; y++)
  for (int x = 0; x < width; x++)
    output[y][x] = blur_filter(input, y, x, kernel);

算法逻辑(“对每个像素做模糊”)和调度策略(“先 y 后 x、分块、向量化”)搅在一起,改一下 tiling 策略就得重写整个循环。

Halide 的核心贡献是算法与调度的彻底分离

一个极简的 Halide 程序长这样:

// Halide 语言
Func blur("blur");
Var x, y, c;
blur(x, y, c) = (input(x-1, y, c) + input(x, y, c) + input(x+1, y, c)) / 3;
// 算法定义结束

// 独立的调度策略
Var xi, yi;
blur.tile(x, y, xi, yi, 256, 32)
    .vectorize(xi, 8)
    .parallel(y);

同一个算法可以绑定几十种不同的调度——只需修改 Schedule 部分,不用动算法。反过来,换一个算法就用同一套 Schedule 也无妨。

但从编译器的角度看,Halide 暴露了一个根本问题:传统 IR 的抽象层级是单层的,无法在”像素级纯函数”和”tiled-loops-with-vectors”之间切换。Halide 内部需要一套自己的 IR 来承载这个降阶过程——从函数式的算法定义,到循环体的中间表示,再到 LLVM IR。这套 IR 是专门为图像处理流水线设计的,跟通用 IR 完全不同。

Halide 的影响超出了图像处理。它证明了:当问题域足够特殊时,IR 需要专门设计。而调度分离的思想直接影响了后来的 TVM,也影响了 MLIR 的”渐进降阶”——让不同的抽象层各自有最合适的表示,而不是硬塞进同一个 IR。

三、AI 框架的 IR 爆炸:XLA、TVM 与计算图的困境

2015 年到 2020 年,深度学习框架经历了一场编译革命。核心驱动力是一样的:Python 代码太慢,需要编译到 GPU 或专用加速器上。每个框架都在做同一件事——设计一个 IR,把用户的计算图变成可优化的中间表示,然后生成设备代码。

XLA:Google 的加速线性代数

XLA(Accelerated Linear Algebra)的 HLO(High-Level Optimizer)IR 工作在”操作”级别,而不是”标量指令”级别。一个 dot 操作包含整个矩阵乘法,不是逐个加法乘法的序列。HLO 的优化是代数级别的——算子融合(fusion)、布局选择、常数折叠 [Google, XLA documentation]。

// HLO IR 示意(dot 融合 relu)
%dot = f32[256,256] dot(%lhs, %rhs), lhs_contracting_dims={1}, rhs_contracting_dims={0}
%result = f32[256,256] maximum(%dot, %zero)

问题在于:HLO 之后的降阶路径仍然需要进入 LLVM IR 或 GPU 指令集。一旦掉到 LLVM IR 层级,dot 的语义就丢失了——编译器看到的是几百条标量 load/store/fma,而不是”这是矩阵乘法”。一些只有知道高级语义才能做的优化(比如根据输入形状选择不同的矩阵乘 kernel)就无法在这一层完成。

XLA 的做法是在 HLO 和 LLVM IR 之间加了一个”emitter”层——它在降阶之前根据高级语义做了所有关键决策(kernel 选择、tiling、内存分配),然后机械地生成 LLVM IR。这个 emitter 本质上是把编译知识写死在 C++ 代码里,每支持一种新硬件(TPU、GPU、CPU),就要写一套新的 emitter。

TVM:Relay + TensorIR 的双层抽象

TVM 在 2018 年 OSDI 论文中明确提出了一种双层 IR 架构 [Chen et al., 2018]:

// Relay IR(计算图)
fn (%data: Tensor[(1, 3, 224, 224), float32]) {
  %0 = nn.conv2d(%data, %weight, padding=[1, 1], kernel_size=[3, 3])
  %1 = nn.batch_norm(%0, %gamma, %beta)
  %2 = nn.relu(%1)
  %3 = nn.max_pool2d(%2, pool_size=[2, 2])
}

// TensorIR(调度级)
@main = primfn(A: handle, B: handle, C: handle) {
  for io, jo, ko, ii, ji, ki in grid(64, 64, 64, 8, 8, 8) {
    // tiled, vectorized matrix multiply
  }
}

TVM 的路径是:Python DSL → Relay IR → TE/TensorIR(调度搜索) → 代码生成 → LLVM IR / CUDA / OpenCL / 等。

这个架构有效,但它在工程上暴露了几个硬伤:

  1. IR 之间的桥接是手工维护的。Relay 到 TensorIR 的降阶需要大量 C++ 胶水代码,每增加一个新算子或新目标都要改桥接层。
  2. 自定义硬件支持困难。如果一家公司自研了一颗 AI 加速器,想在 TVM 里支持,需要理解并修改从 Relay 到代码生成的整条链路。TVM 的 IR 和 Pass 不是设计成可扩展的——它们是”够用就好”的内部抽象。
  3. 没有解决”通用编译知识”的复用问题。循环分块、依赖分析、寄存器分配、指令调度——这些是任何编译器都要做的事情,但 TVM 需要自己重新实现一遍,跟 Halide 的实现不共享,跟 LLVM 的实现也不共享。

四、分裂的代价:每个框架都在重新发明编译器

如果只看一个系统,IR 的裂变是合理的工程权衡。Halide 为图像流水线设计 IR,XLA 为 TPU 和 GPU 设计 IR,TVM 为自动调度设计 IR——每个选择都有充分理由。

但从整个工业界看,这是一个系统性浪费:

系统 IR 设计 循环优化 内存管理 设备代码生成
Halide Halide IR 内置调度原语 内置 LLVM / CUDA
XLA HLO emitter 手写 手写缓冲区分配 LLVM / CUDA / TPU
TVM Relay + TensorIR AutoTVM / AutoScheduler 内置 LLVM / CUDA / OpenCL / 等
TensorFlow GraphDef + (XLA) HLO XLA 决定 手写 LLVM / CUDA / TPU
PyTorch 2.0 FX Graph + Aten IR Inductor scheduler Triton / 手写 Triton / CUDA
Triton Triton IR 内置 tiling 内置 PTX

每个系统都实现了循环分块、向量化、内存层次管理、依赖分析——但实现方式各不相同,不能互通。

如果硬件厂商想让自己的加速器被所有这些框架支持,它需要为每个框架写一套后端。框架开发者想让自己的编译器支持新硬件,也需要理解并修改每个框架内部的编译管线。这不是”百花齐放”——它是重复劳动。

MLIR 论文正是从这种局面出发的:不同抽象层级和领域各自在构建独立的编译基础设施,这些系统之间几乎没有代码复用,“软件碎片化”(software fragmentation)已经成为编译器工程的核心挑战 [Lattner et al., 2020]。

五、领域专用架构(DSA)推了一把

如果说 AI 框架的 IR 分裂是软件层的驱动力,那硬件的分化就是物理层面的推手。

通用 CPU 的性能提升在 2010 年代后明显放缓。与此同时,AI 训练和推理的算力需求每两年翻一倍。答案只能是专用硬件——GPU、TPU、NPU、FPGA、ASIC。这些硬件之间的差异不仅仅是”ARM 还是 x86”级别的 ISA 差异——它们连基本的数据移动模型都不一样。

传统编译器后端的工作方式是指令选择 → 指令调度 → 寄存器分配。这条流水线假设目标是”某种有寄存器文件、能逐条执行指令的处理器”。当目标是脉动阵列或 FPGA 逻辑时,这个模型直接失效。编译器需要知道的是”你的计算单元怎么连接、内存带宽是多少、缓存线大小是多少”——这些信息无法在普通的指令级 IR 中表达。

硬件厂商的应对是各自提供自己的编译器工具链——NVIDIA 有 CUDA + nvcc, AMD 有 ROCm, Google 有 TPU compiler, Intel 有 oneAPI。每个都是完整、独立、封闭的编译器栈,跟 LLVM 的关系最多是”最后一步生成机器码时调一下 LLVM”。

结论很清楚:单一 IR 不可能同时高效表示 tensor 级语义、循环级优化、GPU 内存层次和脉动阵列数据流。需要的不是一个更强大的 IR,而是一个能够承载多重 IR 的框架。

六、MLIR 的答案:不说”一个 IR 统治一切”

MLIR 的论文标题很直白:MLIR: A Compiler Infrastructure for the End of Moore’s Law(摩尔定律终结后的编译器基础设施)。论文的核心主张是:

  1. 不追求单一全能 IR——允许每个抽象层级、每个领域有自己的”方言”(Dialect),方言之间可以共存于同一个 Module 中。
  2. 方言之间通过”降阶”(Lowering)衔接——编译过程从高层的领域方言逐步降到通用方言再到 LLVM IR,每一步都是一个正式定义的”方言转换”(Dialect Conversion)。
  3. 基础设施共享——所有方言共用同一套 Type 系统、Pass 管理、模式重写引擎、TableGen 工具链、打印/解析/验证器。Pass 管理、打印/解析、验证与模式重写等通用设施可直接复用,方言作者主要投入语义与降阶设计(参见 MLIR Toy Tutorial)。

用一句话说:MLIR 不是”另一种 IR”,它是”构建 IR 的框架”。

这样前面表格里的六套系统,如果能收敛到 MLIR 上(事实上 Triton(经桥接)、Torch-MLIR、IREE、CIRCT 已经在这么做):

方言的可组合性是 MLIR 设计中最关键的一点。传统编译器的 Pass 管线是一条直线:源语言 → 中间表示 → 目标代码。MLIR 的管线可以分叉和收敛:

           tensor@数学 → linalg@结构化 → affine@循环分析 → scf@控制流 → memref@内存化 → llvm
          ↗                                                                    ↘
torch/onnx/tf                                                               gpu@SPIR-V
          ↘                                                                    ↗
           stablehlo@算子融合 → xla_runtime@设备选择

同一个 Module 的不同部分可以走不同的降阶路径。一个函数可以让 MLIR 自动 tile 和向量化,另一个函数可以保留手写优化不降阶。这在传统单一 IR 的编译器中是不可能的——要么全部降阶,要么全都不降。

七、不展开的话题与本篇边界

这篇是系列的第一篇,只讲了”为什么”。接下来的每一篇都会深入”是什么”和”怎么做”。有几件事本篇明确不展开:

如果读完这篇你只有一个印象,我希望是这个:编译器工程的下一代挑战不是写出更快更好的单一 IR——而是设计一个让多种 IR 能共存、能演进、能组合的框架。MLIR 是这个方向的第一个系统化尝试。

参考资料

论文(A 级)

官方文档(A 级)

社区项目(B 级)

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-09 · compiler / architecture

【编译器与 MLIR】操作、方言与 IR 的 C++ 表示

深入 Operation、Op、Value、Block、Region 的 C++ 内存布局与继承体系:CRTP 模板包装、SSA 值的两种来源、Use 链表的遍历方法。这是后续所有 Pass 写作的基础。

2026-06-09 · compiler / architecture

【编译器与 MLIR】MLIR 全景图与设计哲学

从 Module-Operation-Region-Block 四层结构出发,系统讲解 MLIR 的三条核心设计原则:渐进降阶、方言可组合性、基础设施复用,配合 IREE、CIRCT、Torch-MLIR 等实际案例建立心智模型。


By .