L2.7 深度学习编译器与 MLIR 多级下沉
三维坐标
layer: L2(数据·算子·编译)|level: Senior|pillar: 编程与编译本文是 L2「编程与编译」支柱的收官之作。前几篇我们手写过 CUDA / Triton 单个 kernel,本文则上升到编译器视角:一个完整模型如何被自动捕获成图、逐级优化、最终下沉成一个个设备 kernel。理解这条链路,是从「会写算子」迈向「懂编译系统」的分水岭。
学习目标
- 前置知识:读过 L0.5(编译三段式:前端/优化/后端,知道 IR 是什么);学过 L2 前序篇章(手写过 CUDA / Triton 单个 kernel,理解 kernel 启动开销与访存受限);会基础 Python 即可,无需写过编译器。
- 学完产出:① 能画出
torch.compile三驾马车流水线(Dynamo 捕获 → AOTAutograd 前反向追踪 → Inductor codegen),说清各自职责与 graph break 的代价;② 能解释「图级优化」中算子融合为何收益最大,并把它和「访存受限」一句话挂钩;③ 能复述 MLIR 多级 Dialect 渐进 lowering 的全链路(TOSA → Linalg → Vector → GPU → LLVM IR),说清「为什么是多级而非一步到位」;④ 能区分图级 IR(FX / StableHLO)与 LLVM IR 各自的抽象层级与适用优化,不再把两者混为一谈;⑤ 亲手跑一次 eager vstorch.compile对比,用TORCH_LOGS/dynamo.explain观测图捕获并量化加速比。 - 阅读姿势:盯住一条主线——「编译器的全部工作,是把一行动态 Python 逐级翻译成贴着硬件跑的静态 kernel,而每一级 IR 都是一个最合适做某类优化的窗口」。无论 torch.compile 的三驾马车,还是 MLIR 的多级 Dialect,本质都在回答同一个问题:在哪一层抽象上,做哪一类优化最自然。
背景与现状
深度学习编译器(DL Compiler) 要解决的核心矛盾是:上层算法工程师用动态、灵活的 Python 表达模型,而底层硬件只认静态、规整、已调优的 kernel。编译器的使命,就是在这两者之间架一座自动化的桥——把 model(x) 这样一行 Python,自动转化为一组在 GPU/CPU/NPU 上贴着硬件特性跑的高性能指令。
从产业演进看,这条路走过三个阶段:
- 手写 kernel 时代:算子靠人肉 CUDA/cuDNN 实现,新算子上线慢、组合爆 炸(算子数 × 数据类型 × 形状)。
- 图编译器时代(2018–2021):TVM、XLA、TorchScript 尝试把整图静态化后做融合与 codegen,但「动态 Python 难捕获」始终是痛点——TorchScript 要求改写代码,体验割裂。
- 逐图捕获 + 通用 IR 时代(2022 至今):torch.compile(PyTorch 2.0)用 TorchDynamo 在 Python 字节码层无侵入捕获图;同时 MLIR(Multi-Level IR)作为 LLVM 之父 Chris Lattner 主导的「可复用编译器基础设施」,用多级 Dialect 把碎片化的 DL 编译生态统一到一套框架下。
业界信号:
torch.compile已成为 PyTorch 2.x 的默认加速入口,一行torch.compile(model)即可获得 30%–2× 提速;而 MLIR 已被 IREE、Triton、OpenXLA、华为昇腾、甚至 NVIDIA 的新一代 Tile IR 采纳——这说明「统一多级 IR」正在成为跨硬件编译的事实标准,而非学术玩具。
原理与架构
理解 DL 编译器,最有效的方式是看两条主线:torch.compile 的「三驾马车」流水线(PyTorch 生态的工程落地),与 MLIR 的多级 Dialect 下沉(跨硬件编译的理论框架)。
2.1 torch.compile 三驾马车流水线
torch.compile 不是一个单体编译器,而是三个分工明确的组件串成的流水线:TorchDynamo(捕获)→ AOTAutograd(前反向追踪)→ TorchInductor(codegen)。
- TorchDynamo(捕获器):它的精髓是利用 CPython 的 Frame Evaluation API(PEP 523),在每个 Python 函数帧执行前插入 hook,动态地把字节码翻译成 FX Graph——无需用户改写代码。一旦遇到无法表达进图的操作(如打印、依赖数据的
if、未支持的 C 扩展),就触发 graph break:把图切成两段,中间回落到 eager 模式执行,再继续捕获后续。graph break 越多,编译收益越低,这是调优第一抓手。 - AOTAutograd(前反向追踪器):训练需要反向图,但反向是运行时才生成的。AOTAutograd 在编译期就提前(Ahead-Of-Time)追踪出前向 + 反向的联合图,并把高层 ATen 算子分解(decompose)成更原子的 Prim 算子,为后端融合提供更细的颗粒度。
- TorchInductor(codegen 后端):拿到分解后的图,做一系列图优化(见 2.2),最终为 GPU 生成 Triton kernel、为 CPU 生成 C++/OpenMP kernel。Triton 让 Inductor 能自动生成贴近手写性能的融合 kernel,这是 PyTorch 2.0 提速的关键。
2.2 图优化策略:编译器在图上做了什么
拿到 完整计算图后,编译器的核心价值在于一系列图重写(graph rewrite) 优化:
| 优化策略 | 做什么 | 收益本质 |
|---|---|---|
| 算子融合(Fusion) | 把 mul → add → relu 等多个逐元素算子合并成一个 kernel | 减少 kernel 启动开销 + 避免中间结果反复读写显存(带宽是瓶颈) |
| 常量折叠(Constant Folding) | 编译期就把 2 * 3.14 这类常量表达式算出结果 | 消除运行时冗余计算 |
| 死代码消除(DCE) | 删除输出不被任何节点使用的算子 | 缩小图规模,减少无用计算 |
| 显存复用规划(Memory Planning) | 分析张量生命周期,让不重叠的张量复用同一块显存 buffer | 降低峰值显存,等价于「编译期的内存分配器」 |
值得注意的是:这四类优化里,算子融合的收益最大,因为 GPU 上 DL 负载普遍是 访存受限(memory-bound) 而非算力受限——融合把多次「显存往返」压成一次,直接命中带宽墙。这也是 Inductor 选 Triton 作后端的根因:Triton 天然擅长生成融合 kernel。
2.3 MLIR 多级 Dialect 下沉机制
如果说 torch.compile 是 PyTorch 生态的工程方案,MLIR 则是更底层、更通用的编译器基础设施。它的核心创新是 Dialect(方言):在同一套 IR 框 架里,允许定义多个抽象层级的算子集合,然后通过 lowering(下沉)pass 逐级翻译,直到接近硬件。
逐级读这张图:高层 TOSA(Tensor Operator Set Architecture,硬件无关的标准算子集)描述「做什么」→ Linalg Dialect 把算子表达成可做 tiling(分块)与融合的结构化循环嵌套 → Vector Dialect 引入向量化抽象 → GPU/NVVM Dialect 落到具体的线程/block/共享内存模型 → 最终降到 LLVM IR 交给 LLVM 后端生成 PTX 或机器码。
为什么是「多级」而非「一步到位」:每一级 Dialect 都是一个优化机会窗口——在 Linalg 层做 tiling 与融合最自然,在 Vector 层做向量化最自然。多级下沉让每个优化 pass 在最合适的抽象层级工作,这就是 MLIR「Multi-Level」三个字的全部意义。
工程落地的两个代表:
- IREE(Intermediate Representation Execution Environment):Google 主导的端到端 MLIR 编译/运行时栈,把模型编译成可部署的 flatbuffer,覆盖从服务器 GPU 到移动端的全谱系硬件,是 MLIR 多级下沉最完整的工业实现。
- CUDA Tile IR:NVIDIA 新一代基于 MLIR 的 tile 级抽象,目标是让编译器在「线程块处理一个 tile」这个粒度上做调度与优化——本质是把 Triton 式的 tile 编程理念吸纳进 NVIDIA 自家的编译栈。
2.4 CUDA 编译链基础:PTX / cubin、JIT 与 AOT
无论上层是 Inductor 的 Triton 还是 MLIR 的 GPU Dialect,最终在 NVIDIA 平台都要落到 CUDA 编译链:
- PTX(Parallel Thread Execution):一种虚拟 ISA(中间汇编),与具体 GPU 架构解耦,类似 GPU 世界的「Java 字节码」。
- cubin(CUDA binary):针对特定架构(SM_80 / SM_90 等) 的真实机器码。
- AOT 编译:
nvcc在构建期就把 PTX 编译成多个架构的 cubin,打包进 fatbin。运行快、但只支持构建时指定的架构。 - JIT 编译:运行时由驱动把 PTX 即时编译成当前 GPU 的 cubin。兼容未来新架构(向前兼容靠的就是 PTX + JIT),首次启动有编译开销,结果可被 JIT cache 缓存。
要点在于:Triton / Inductor 走的正是 PTX + JIT 路线——首次运行某个形状会触发编译(所以测时延必须预热),这与 2.1 节「首次调用极慢」的现象同源。
动手实践:开/关 torch.compile 对比
实验目标:对同一个小模型分别在 eager 与 torch.compile 两种模式下运行,亲手观测图捕获日志(graph break / recompile)并对比端到端时延。产出物:一份 TORCH_LOGS 捕获日志摘录 + 一组 eager vs compiled 的时延数字。
3.1 环境准备
# 推荐 Python 3.11;用 uv 或 venv 隔离
python3 -m venv .venv && source .venv/bin/activate
# NVIDIA GPU 路径:自动选 CUDA 版(Triton 后端随 torch 附带)
pip install torch
# Mac / 无 GPU 的 CPU 路径(torch.compile 在 CPU 上同样可用,走 C++/OpenMP 后端):
# pip install torch --index-url https://download.pytorch.org/whl/cpu
3.2 代码:开/关 torch.compile 对比时延
import torch
import torch.nn as nn
import time
torch.manual_seed(0)
dev = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[device] {dev}")
# 一个故意制造融合机会的小模型:多个逐元素算子串联
class TinyMLP(nn.Module):
def __init__(self, d=2048):
super().__init__()
self.fc1 = nn.Linear(d, d)
self.fc2 = nn.Linear(d, d)
def forward(self, x):
x = self.fc1(x)
x = torch.relu(x) * torch.sigmoid(x) # 逐元素链,编译期可融合
x = self.fc2(x)
return x.sum()
model = TinyMLP().to(dev).eval()
x = torch.randn(512, 2048, device=dev)
def bench(fn, name, iters=50):
# 预热:触发 JIT / 编译 / cuBLAS 初始化
for _ in range(10):
fn(x)
if dev == "cuda":
torch.cuda.synchronize()
t0 = time.perf_counter()
for _ in range(iters):
fn(x)
if dev == "cuda":
torch.cuda.synchronize()
dt = (time.perf_counter() - t0) / iters * 1e3
print(f"[{name}] {dt:.3f} ms/iter")
return dt
with torch.no_grad():
eager = bench(model, "eager")
compiled_fn = torch.compile(model) # 三驾马车上线
comp = bench(compiled_fn, "compiled")
print(f"[speedup] {eager / comp:.2f}x")
3.3 用 torch._dynamo.explain 看图捕获
在计时前,先用官方工具直观看到 TorchDynamo 捕获了几张图、有没有 graph break:
import torch._dynamo as dynamo
with torch.no_grad():
explanation = dynamo.explain(model)(x)
print(explanation) # 打印 graph count / graph break count / 原因
3.4 运行与观察(看图捕获日志)
用 TORCH_LOGS 环境变量打开 Dynamo 的内部日志,是定位编译行为最直接的手段:
# 打印图捕获、graph break 与重编译信息
TORCH_LOGS="graph_breaks,recompiles" python compile_bench.py
# 想看 Inductor 生成的 Triton/C++ 代码,加 output_code:
# TORCH_LOGS="graph_breaks,recompiles,output_code" python compile_bench.py
- NVIDIA GPU 路径:
compiled通常比eager快 1.3×–2×,提速主要来自 Inductor 把relu(x) * sigmoid(x)这条逐元素链融合成一个 Triton kernel,省掉了中间张量的显存往返。TORCH_LOGS=output_code能直接看到生成的triton_poi_fused_*kernel 源码。 - Mac / CPU 替代方案:无 GPU 时同样能跑
torch.compile,Inductor 走 C++/OpenMP 后端生成融合 kernel,也能看到可观的加速(虽幅度小于 GPU)。output_code会显示生成的 C++ 源码。目的一致——观测「同一段代码经编译器融合后变快」这一编译收益的本质,与硬件无关。
踩坑预警 (Gotchas)
- 不预热就计时 = 测了编译开销:
torch.compile首次调用会触发完整的捕获 + codegen + JIT,耗时可达数秒。务必充分预热后再计时,否则测出的「compiled 更慢」是假象。 - graph break 偷走收益:模型里有
print、依赖数据的 Python 控制流、不支持的库调用,都会触发 graph break,把图切碎、收益骤降。用TORCH_LOGS=graph_breaks揪出断点,尽量改写成可捕获形式。 - 动态形状触发 recompile:每次输入形状变化,Dynamo 可能重新编译(recompile),抵消收益。固定 batch/seq 形状,或用
torch.compile(model, dynamic=True)显式声明动态维度。 - CUDA 不
synchronize计时无意义:与上一篇同源——kernel 异步下发,GPU 路径计时前后必须torch.cuda.synchronize()。 - 小模型可能不提速:模型太小则 kernel 启动开销占比低、融合收益有限,甚至被编译额外开销抵消。本实验用
2048维度正是为了制造足够的融合空间。
深入思考
下面三题每题先给题干,再用
<details>折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。
思考题 1:统一 IR 的代价与收益
MLIR 用「多级 Dialect」统一了碎片化的 DL 编译生态,但每多一级 Dialect 就多一层 lowering pass 与潜在的优化损耗。结合 2.3 节的多级下沉机制,论证:在什么场景下「一步到位的专用编译器(如直接 PyTorch→Triton)」反而比「通用多级 IR(MLIR)」更优?统一 IR 的真正护 城河,是性能还是生态复用与跨硬件可移植性?
展开参考答案(含「专用 vs 通用」编译路径权衡图 + 对比表)
结论:单一硬件、单一负载、追求极致性能时,一步到位的专用编译器路径更短、优化更直接;而当目标是「一套前端覆盖 N 种后端硬件」时,多级 IR 用一次性的抽象成本换来 N 份后端复用,护城河本质是生态复用与跨硬件可移植性,而非单点峰值性能。
专用一步到位更优的场景:目标只有一种硬件(如只跑 NVIDIA GPU)、负载形态固定(如纯 Transformer 推理)、且对峰值性能极度敏感。此时多级 Dialect 的每一层抽象都是「为通用性付的税」——你并不需要的可移植性,却要承担多一层 lowering 带来的信息丢失与调试复杂度。直接 PyTorch→Triton 路径短、优化能贴死单一架构,反而更快出活。
对比一张表看清取舍:
| 维度 | 专用编译器(PyTorch→Triton) | 通用多级 IR(MLIR) |
|---|---|---|
| lowering 层数 | 少,损耗低 | 多,每级是优化窗口也是潜在损耗点 |
| 单硬件峰值性能 | 高(优化贴死单一架构) | 视后端成熟度,可能略逊 |
| 覆盖 N 种硬件成本 | ≈ N 次重写后端 | 前端共享,每后端写一段 lowering |
| 生态复用 | 弱,绑定单一栈 | 强,Dialect/Pass 可被多项目复用 |
护城河结论:MLIR 的价值不在「比专用编译器更快」,而在它把「编译器基础设施」做成可复用的公共品——IREE、Triton、OpenXLA、昇腾都能各取所需。当硬件碎片化(GPU/NPU/TPU 并存)成为常态,一份前端 + 多份后端 lowering 的边际成本远低于「每种硬件养一套专用编译器」。所以护城河是生态复用与跨硬件可移植性。
思考题 2:跨硬件移植——哪一层重写、哪一层复用
你要把一个已用 torch.compile 在 NVIDIA GPU 上跑通的模型,迁移到一款国产 NPU。结合 2.1 节三驾马车与 2.3 节 MLIR 下沉两条主线,分析:捕获层(Dynamo)、前反向层(AOTAutograd)、codegen 层(Inductor / MLIR 后端)哪一层需要重写、哪一层可复用?为什么「编译器后端」是异构移植成本的集中点?
展开参考答案(含三驾马车「可复用/需重写」分层图 + 算一遍)
结论:捕获层与前反向层几乎与硬件无关、可整段复用;真正需要重写的是 codegen 后端——因为只有这一层才把抽象算子翻译成具体硬件指令,硬件差异在此集中爆发,这也是异构移植成本的主要承重点。
逐层分析:
- 捕获层(Dynamo):它处理的是 Python 字节码 → FX Graph,与目标硬件毫无关系。无论后端是 GPU 还是 NPU,捕获出的图是同一张——整段复用。
- 前反向层(AOTAutograd):负责把前向 + 反向追踪成联合图、把高层 ATen 算子分解成 Prim 算子。这一步产出的仍是硬件无关的算子图——整段复用。
- codegen 后端(Inductor / MLIR 后端):这里要为 Triton(NVIDIA)或 NPU 的指令集生成真实 kernel,涉及算子到硬件原语的映射、tiling 与调度策略、内存层级适配——必须重写。走 MLIR 路线时,对应的是「为 NPU 写一套从 Linalg/Vector 往下的 lowering 与 Dialect」。
用比例算一遍(数量级直觉):把一次完整移植的工程量记为 100%,则捕获 + 前反向两层(复用)几乎是 0 额外成本(接得上接口即可);而 codegen 后端——算子覆盖、性能调优、长尾算子手写——往往占去 80% 以上的迁移人力。这正是「编译器后端是异构移植成本集中点」的量化体感:硬件差异不在「图长什么样」,而在「图怎么落成指令」,所有差异都被推到了最后一层。
思考题 3:graph break 治理方法论
线上一个推理模型开了 torch.compile 却几乎没提速。你用 torch._dynamo.explain 发现它被切成了 30+ 张子图。结合 2.1 节 graph break 机制,给出一套系统化的 graph break 排查与消除方法论——从定位断点原因、到改写代码、到何时该「认命」接受部分 eager 回落,需要权衡哪些工程因素?
展开参考答案(含 graph break 排查闭环流程图 + 决策表)
结论:先用工具定位每个断点的具体原因,再按「能消则消、消不动则隔离」的优先级改写代码,最后在收益与改写成本之间画一条线——当消除某个断点的工程代价高于它偷走的那点收益时,就接受局部 eager 回落,把编译力气集中在热点子图上。
三步方法论:
- 定位:先
torch._dynamo.explain(model)(x)看 graph count / break count / 每个断点原因,再用TORCH_LOGS=graph_breaks,recompiles把断点的源码行与触发原因打出来。不要盲改,先看清 30 个断点分别是什么。 - 分类消除(按代价从低到高):
print/ 日志 / 调试钩子 → 直接移出热路径;依赖数据的 Pythonif/for→ 改写成张量算子或torch.cond/torch.where;不支持的库调用或自定义 C 扩展 → 换可捕获实现,或用torch.compiler.allow_in_graph/ 把它隔离到编译区之外。每改一类都重测「子图数」与「加速比」,确认收益真实存在。 - 认命的判据:当某个断点来自第三方库、改写要动大量业务代码、或它本就在冷路径上时,强行消除的工程代价会超过它偷走的收益。此时正确做法是接受局部 eager 回落,让编译器专注优化占绝大多数耗时的热点子图。
何时该停手——决策表:
| 断点情形 | 消除代价 | 处理建议 |
|---|---|---|
print / 调试代码在热路径 | 低 | 立即移除,必消 |
| 依赖数据的控制流(热路径) | 中 | 改写为 torch.cond / 张量化,值得做 |
| 第三方库调用(冷路径,占比 < 5% 耗时) | 高 | 认命,接受 eager 回落 |
| 自定义算子未注册 | 中高 | 注册 custom op 或 allow_in_graph,看复用频次定 |
权衡的工程因素:改写成本(动多少业务代码、回归测试范围)、断点所在路径的耗时占比(热点优先)、可维护性(为消断点写出的代码是否反而更难读)、以及收益的可测性(每一步都要有 dynamo.explain 与时延数字佐证,避免「改了一通却没快」)。核心心法是:把有限的编译力气押在耗时热点上,而非追求 100% 整图捕获的洁癖。
延伸阅读
1. 核心 Paper / 技术文档
- MLIR: A Compiler Infrastructure for the End of Moore's Law(Lattner et al., 2020)— 理解多级 Dialect 设计哲学的奠基论文。
- PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation(2024)— TorchDynamo / Inductor 的官方系统论文。
- Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations(2019)— 理解 Inductor 为何选 Triton 作 GPU 后端。
2. 相关高 Star 仓库与源码必读路径
pytorch/pytorch— 看torch/_dynamo/(捕获)、torch/_functorch/aot_autograd.py(前反向追踪)、torch/_inductor/(codegen 与图优化 pass)。llvm/llvm-project下mlir/— 看Dialect/Linalg、Dialect/Vector、Dialect/GPU的算子定义与 lowering pass。iree-org/iree— MLIR 多级下沉的端到端工业实现,看其编译流水线如何串联各级 Dialect。
3. 优质博客 / 视频
- PyTorch 官方「torch.compile Tutorial」与「TorchDynamo Deep Dive」系列文档。
- MLIR 官方 Toy Tutorial(从零定义一个 Dialect 并 lowering 到 LLVM),动手理解多级下沉。
- LLVM Developers' Meeting 历年关于 MLIR / IREE 的技术报告视频。
下一篇 → L3.1 分布式训练范式与 3D 并行:从 L2「单机编译」跨入 L3「多机训练」,讲清数据并行 / 张量并行 / 流水线并行如何把模型铺到上千张卡上。