跳到主要内容

L2.7 深度学习编译器与 MLIR 多级下沉

三维坐标 layer: L2(数据·算子·编译)level: Seniorpillar: 编程与编译

本文是 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 vs torch.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)TVMXLATorchScript 尝试把整图静态化后做融合与 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 已被 IREETritonOpenXLA、华为昇腾、甚至 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 通常比 eager1.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 回落,把编译力气集中在热点子图上。

三步方法论

  1. 定位:先 torch._dynamo.explain(model)(x) 看 graph count / break count / 每个断点原因,再用 TORCH_LOGS=graph_breaks,recompiles 把断点的源码行与触发原因打出来。不要盲改,先看清 30 个断点分别是什么。
  2. 分类消除(按代价从低到高):print / 日志 / 调试钩子 → 直接移出热路径;依赖数据的 Python if/for → 改写成张量算子或 torch.cond / torch.where;不支持的库调用或自定义 C 扩展 → 换可捕获实现,或用 torch.compiler.allow_in_graph / 把它隔离到编译区之外。每改一类都重测「子图数」与「加速比」,确认收益真实存在。
  3. 认命的判据:当某个断点来自第三方库、改写要动大量业务代码、或它本就在冷路径上时,强行消除的工程代价会超过它偷走的收益。此时正确做法是接受局部 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-projectmlir/ — 看 Dialect/LinalgDialect/VectorDialect/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「多机训练」,讲清数据并行 / 张量并行 / 流水线并行如何把模型铺到上千张卡上。