设想一家大型连锁餐饮集团要推出一道新菜:主厨在总店厨房里现场演示,品控员拿着速记本盯住每个动作,把“切丁、焯水、爆炒”记成一张不依赖具体食材的菜谱;这张菜谱随后被翻译成所有分店都认识的标准工序单;每家分店再按照自家炉灶、烤箱甚至流水线设备的特点,把工序单拆成可执行的小操作,最后同时出餐。JAX 后端 MLIR 做的正是这件事:主厨是 Python 函数,品控员是 JAX 的追踪器,菜谱是 JAXPR,标准工序单是 StableHLO,分店厨房是 GPU 和 TPU,而 MLIR 就是一套用来描述“从大菜谱到小工序”的通用语言系统。
理解 JAX 后端 MLIR,你将从“框架调用者”变成“编译器流水线设计者”。这是做大模型训练优化、AI 基础设施和算子开发的硬核内功,也是理解现代深度学习框架底层如何炼成的一把钥匙。
目录
可视化演示
展示JAX MLIR后端三维度:编译流水线(Python函数经tracing追踪生成jaxpr IR再映射StableHLO经MHLO LMHLO降级到LLVM IR最终生成设备代码共8阶段)、方言层级(StableHLO可移植高层运算方言定义Add/Dot/Convolution等标准算子逐层Lowering StableHLO→MHLO→LMHLO→LLVM抽象级别递降每层增加硬件细节)、多设备部署(统一StableHLO IR经不同后端Lowering部署CPU/GPU/TPU GPU走CUDA后端TPU专用XLA编译器CPU走LLVM后端PjRt运行时协同),三模式切换理解JAX MLIR编译机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
1. 追踪机制:把掌勺动作写成菜谱
总店主厨研发新菜时,JAX 的追踪器就像一个拿着速记本的品控员。你调用 jax.jit(f) 后,Python 函数并不会立刻算出数值,而是由 tracer 对象代替真实输入进入函数。品控员看到的不是数字,而是“加法”“乘法”“reshape”这些动作。于是,一次 Python 执行被冻结成一张可复用的菜谱——JAXPR。
这张菜谱不依赖 Python 解释器,也不关心数据具体是什么,只关心操作之间的依赖关系。它把 x + y 记录成 add(x, y),把矩阵乘法记录成 dot_general。这种“先建图、后执行”的方式,是 JAX 能对接编译器的基石。它让 JAX 拥有自动微分和并行化改造的能力,因为编译器终于可以提前看清楚整道菜的所有步骤。
JAXPR 是 JAX 的内部表示,它还不是 MLIR 方言。它的作用是“原汁原味地保存主厨的意图”,至于怎么切菜、用什么锅,留给后面的标准化工序员去决定。从技术上讲,追踪过程可以看作把一个 Python 函数变成一张计算图:
回到餐厅:菜谱写好后,品控员不会亲自去炒菜,而是把它交给标准化工序员。这位工序员就是下一节的主角——StableHLO 方言。
Trace 把函数 f 在抽象输入 x 上跑一遍,得到 JAXPR 计算图 J。- 追踪符号
\mathrm{Trace}表示 JAX 的追踪器,它会在函数执行时记录每个算子。 - 目标函数
f表示被转换的 Python 函数,也就是主厨的掌勺动作。 - 输入占位
\boldsymbol{x}表示 tracer 输入,它不是真实数值,而是替代食材的占位牌。 - 输出菜谱
\mathcal{J}表示生成的 JAXPR 计算图,它是一张与硬件无关的菜谱。
2. HLO方言:通用厨房的标准化菜单
标准化工序员读完整本菜谱后,发现各分店对“糖色”“断生”的理解都不一样。于是他把菜谱翻译成一份所有厨房都公认的工序单:StableHLO。它是一组稳定、可移植的高层算子,比如 stablehlo.add、stablehlo.dot_general,只描述语义,不规定具体实现。
StableHLO 的全称是 Stable High-Level Operations。它从 XLA 内部 HLO 中抽取稳定子集,避免 HLO 频繁变动导致上层框架碎片化。在 MLIR 的世界里,这种“一门特定领域的语言”叫作 dialect。StableHLO 就是一个 dialect,它让 JAX 不用直接关心 GPU、TPU 各自的指令,而是统一表达为“加”“乘”“卷积”“归一化”等标准动作。
这种标准化对框架集成至关重要。传统后端需要为每个硬件各自写一套算子库,而有了 StableHLO,前端只需要把 JAXPR 映射到 StableHLO,后端再从 StableHLO 出发做优化。从数学上看,这一映射是一个方言翻译过程:
厨房有了统一工序单还不够,备菜间还得知道每份食材多大、什么规格。这就是类型推导要解决的问题。
Phi 从 JAXPR 节点映射来的。- 映射函数
\Phi_{\mathrm{stablehlo}}表示从 JAXPR 到 StableHLO 的方言转换。 - 输入图
\mathcal{J}是上一节生成的 JAXPR 计算图。 - 输出图
\mathcal{S}是 StableHLO 计算图,语义和 JAXPR 等价,但表示方式标准化了。
3. 类型推导:给每份食材标注规格
标准工序单上写着“加入适量料酒”,但分店备菜时不知道“适量”是多少。类型推导要做的,就是给计算图中的每个张量标注明确的形状和数据类型。它像一位严格的备菜组长,逐一检查每份食材的尺寸、重量和单位,确保后面的厨师不会把“500克盐”当成“500克糖”。
在 JAX 后端流水线中,类型推导会在 StableHLO 层面检查张量的 shape 和 dtype。比如矩阵乘法要求两个输入维度匹配,卷积要求输入通道数与卷积核一致。如果发现 shape 不匹配,编译器会直接报错,而不是等到设备上运行到一半才崩溃。这种早期检查让问题暴露在编译期,而不是昂贵的训练期。
类型推导也为后续内存分配、算子选择和 LLVM 代码生成提供了依据。对于动态维度,比如 NLP 中不定长的句子,类型系统会用 ? 标记不确定维度,并生成对应的运行时检查逻辑。类型学中的经典表达是:
规格标注完毕,接下来工序员要把“红烧肉”这一道大工序,拆成“切肉、焯水、炒糖色、炖煮”等一系列小工序。这就是 Lowering 流程。
Gamma 下,表达式 e 的类型可以被推导为 tau。- 类型环境
\Gamma表示一组已知的类型约束,相当于备菜组长的规格表。 - 推导符
\vdash读作“推导出”,表示左边环境能推出右边结论。 - 表达式
e表示计算图中的某个算子或张量。 - 结果类型
\tau表示推导出来的 shape 和 dtype,比如f32[128, 256]。
4. 下降流程:从大菜拆成小工序
StableHLO 还是“做一道菜”级别的高层描述,设备没法直接执行。MLIR 的设计哲学是逐级下降:先把 StableHLO 变成 Linalg 方言,再变成 Arith、MemRef 等底层方言,最后落到 LLVM IR。每一步都像工序员把一个大步骤拆成更细的小步骤,直到每个小步骤连普通帮厨都能照做。
方言转换过程中,最重要的事情是保持 legality。所谓合法,是指转换前后计算语义完全一致。MLIR 提供了 Dialect Conversion 框架,用 pattern 描述“大算子如何拆成小算子”。JAX 后端通过注册大量转换规则,把 StableHLO 的每个算子逐步映射到更低层抽象。
这种多级下降的价值在于复用。GPU 和 TPU 后端不需要各自从 StableHLO 直接生成机器码,而是共享同一套中间转换规则,只在最后一步选择不同的硬件代码生成。整个下降流程可以看作一串转换函数的复合:
工序拆完后,主厨开始排班:哪个灶先热、哪道菜可以合并到同一个锅里。这就是 XLA 代码生成阶段。
- 转换函数
\ell_1到\ell_k表示每一级 Lowering 的方言转换规则。 - 复合运算
\circ表示函数复合,先做l1,再做l2,依此类推。 - 整体流水线
\mathcal{L}表示完整的 Lowering 流水线。
5. 代码生成:主厨排班与火候优化
当 IR 下降到足够底层,XLA 就像总店的主厨,开始规划整个厨房的排班表:哪些算子可以合并到一个 kernel 里执行,哪些中间结果应该留在寄存器或高速缓存中,哪些计算应该并行。最经典的优化叫算子融合,它能把“A 加 B”和“A 加 B 后乘 C”合并进同一个 kernel,减少一次读写显存的开销,就像把“焯水”和“爆炒”合并到同一个锅里,省去了来回端锅的等待。
调度优化会为每个算子选择最优的执行顺序和并行策略。XLA 会构建一个 cost model,评估不同调度方案的执行时间,然后选择消耗最小的一种。对训练来说,这直接决定了 step 时间;对推理来说,这决定了延迟和吞吐量。
代码生成的本质是在巨大的搜索空间中寻找最优调度。可以写成最小化问题:
排班表做好后,菜终于要下锅了。不同的分店厨房设备差异很大,这就要看 GPU 和 TPU 如何接招。
S 中,选出使计算图 G 执行时间 T 最短的那个最优调度 S*。- 执行时间函数
T(G, S)表示在调度方案S下执行计算图G的耗时。 - 合法调度集合
\mathcal{S}_{\mathrm{exec}}表示所有满足硬件约束的调度方案集合。 - 最优调度
S^{*}表示最终选择的执行计划。 - 最小化操作
\arg\min表示“找到使目标函数最小的那个候选值”。
6. 硬件加速:专用炉灶与并行炒锅
GPU 像一家有几千个小炒锅的大排档,每个锅都能独立翻炒,适合“同一道菜同时做很多份”。TPU 则更像一条专门做固定菜品的流水线烤盘,用脉动阵列把矩阵乘法按固定节奏推过去,单位功耗下吞吐更高。JAX 后端通过 XLA,把同一份 StableHLO 翻译成不同设备的机器码:GPU 拿到 CUDA 内核,TPU 拿到定制的 HLO 优化指令。
硬件加速不只是换一个执行引擎,还包括内存布局优化。GPU 需要把数据排布成适合 coalesced access 的格式,TPU 则需要考虑片上存储和主存之间的搬运。MLIR 的作用在这里体现为 expose 显式的 layout 信息,让每一级转换都能针对硬件特性做调整。
并行带来的加速并不是免费的。多个计算单元协同工作时,会引入通信和同步开销。理想加速比可以用一个简单的模型表示:
一道菜可以同时做多份,但如果是千人宴,单靠多个厨师炒同一道菜还不够,还得把整道菜拆成不同工作站。这就来到大规模训练。
T_ovh。- 串行时间
T_{\mathrm{serial}}表示单设备执行完整任务的时间。 - 并行时间
T_{\mathrm{parallel}}表示多设备执行的总时间,包含计算时间和通信时间。 - 设备数量
n表示并行设备个数。 - 额外开销
T_{\mathrm{ovh}}表示通信、同步和内存搬运等额外开销。
7. 大规模训练:千人宴的流水线
千人宴不能让一个厨师从头做到尾,更不能让每桌都单独开火。大规模训练用的是同样的思路:数据并行让每个 GPU 各拿一份 batch 同时前向和反向,模型并行把一个大模型切成几段放不同设备上,异步流水让设备不必每步都停下来等全局同步。
数据并行最直观:每个设备算出自己的梯度,然后把所有梯度汇总,更新一份共享参数。模型并行则用于大模型无法塞进单卡显存的情况,比如把 Transformer 的 Layer 切到不同设备上。MLIR 和 XLA 会插入集合通信算子,比如 all-reduce,来完成梯度汇总。
在大规模训练中,通信常常成为瓶颈。梯度压缩、梯度累积和异步流水都是为了减少等待。最终所有局部梯度需要汇总成全局梯度,用公式表示:
从追踪到菜谱,从标准工序单到逐级拆解,再到设备上的并行炒菜,JAX 后端 MLIR 就像一条完整的餐饮流水线:把灵活性留在前端,把性能释放到后端。
g 等于所有设备局部梯度 g_i 的和。- 局部梯度
\boldsymbol{g}_i表示第i个设备算出的梯度。 - 设备数量
P表示参与训练的并行设备总数。 - 求和符号
\sum_{i=1}^{P}表示把所有设备的梯度逐项累加。 - 全局梯度
\boldsymbol{g}表示聚合后用于更新模型参数的最终梯度。
AI 应用场景
场景1:大模型云端训练
阶段:编译器代码生成与硬件加速
解决的问题:传统手写 CUDA 内核几乎不可能覆盖大模型中的海量算子组合。手写内核不仅开发周期长,而且难以在不同 GPU 型号间迁移。JAX 后端通过 MLIR 把模型统一编译成 GPU/TPU 设备代码,自动完成算子融合和内存优化。
具体案例:一家 AI 公司要在 TPU 集群上训练千亿参数语言模型。工程师用 JAX 定义模型,jax.jit 自动追踪生成 JAXPR,再经 StableHLO 和 MLIR 下降为 TPU 可执行代码。模型并行和数据并行由 XLA 编译器统一调度,省去大量手写分布式代码。
场景2:同一模型多硬件部署
阶段:中间表示与后端移植
解决的问题:传统框架为 CPU、GPU、NPU 分别维护算子库,导致新模型接入每种硬件都要重复适配。StableHLO 作为设备无关的中间表示,让模型只写一次,就能通过不同后端编译到多种硬件。
具体案例:一家自动驾驶公司需要把视觉模型同时部署到车端英伟达 GPU 和自研 NPU。他们选择把模型导出为 StableHLO,再由各硬件团队基于 MLIR 写后端转换。前端模型变更时,后端无需改动。
场景3:推理延迟优化
阶段:XLA 代码生成与调度优化
解决的问题:在线推理服务对延迟极其敏感,未融合的算子会频繁读写显存,成为主要瓶颈。XLA 的算子融合把多个算子合成一个 kernel,减少内核启动和访存次数。
具体案例:一个语音识别服务原先用 TensorRT 手写融合,后来改用 JAX 导出 StableHLO,再利用 XLA 自动融合和调度,P99 延迟降低了约 30%,同时减少了显存占用。
Python 演示
# mini_jax_pipeline.py# A tiny demo of JAX backend pipeline: trace -> types -> HLO -> codegen. class Node: def __init__(self, op, args, dtype='f32'): self.op = op self.args = args self.dtype = dtype class MiniJAX: def __init__(self): self.tape = [] self.counter = 0 def new_tracer(self, name, val): node = Node('param', [name], 'f32') self.tape.append(node) return node def add(self, a, b): node = Node('add', [a, b]) self.tape.append(node) return node def mul(self, a, b): node = Node('mul', [a, b]) self.tape.append(node) return node def trace(self, fn, x): self.tape = [] x_t = self.new_tracer('x', x) y = fn(x_t) return y, list(self.tape) def infer_types(self): for node in self.tape: if node.op in ('add', 'mul'): node.dtype = node.args[0].dtype return self.tape def lower_to_hlo(self): ops = [] for node in self.tape: op_map = {'add': 'stablehlo.add', 'mul': 'stablehlo.dot'} name = op_map.get(node.op, node.op) arg_names = [a.op for a in node.args] ops.append('%s(%s)' % (name, ', '.join(arg_names))) return ops def codegen(self): lines = [] for node in self.tape: if node.op == 'add': a = id(node.args[0]) b = id(node.args[1]) lines.append('x.%d = x.%d + x.%d' % (id(node), a, b)) elif node.op == 'mul': a = id(node.args[0]) b = id(node.args[1]) lines.append('x.%d = x.%d * x.%d' % (id(node), a, b)) else: lines.append('x.%d = input %s' % (id(node), node.args[0])) return lines if __name__ == '__main__': jax = MiniJAX() def model(x): h = jax.mul(x, x) y = jax.add(h, x) return y y, tape = jax.trace(model, 3.0) print('JAXPR:') for n in tape: print(n.op, ':', [a.op for a in n.args]) jax.infer_types() print('HLO:') for line in jax.lower_to_hlo(): print(line) print('Codegen:') for line in jax.codegen(): print(line)
class MiniJAX:定义迷你框架类,相当于一个简易 JAX 后端。self.tape = []磁带tape记录所有被追踪的算子,相当于 JAXPR 菜谱。def trace(self, fn, x):追踪函数把 Python 函数放进记录环境执行。x_t = self.new_tracer('x', x)用 tracer 占位符代替真实输入。h = jax.mul(x, x)在追踪环境里调用乘法,会被记录为mul算子。y = jax.add(h, x)加法同样被记录到 tape 上。jax.infer_types()模拟类型推导,为每个中间张量标记 dtype。jax.lower_to_hlo()把内部算子映射成 StableHLO 风格的名字。ops.append('%s(%s)' % ...)生成字符串形式的 HLO 表示,方便后续 Lowering。jax.codegen()模拟最终代码生成,把计算图变成逐行赋值代码。
常见误区
误区我以为 `jax.jit` 就是把函数包了一层缓存,第二次调用直接返回上一次的结果,和 `functools.lru_cache` 差不多。
正解不是缓存结果,而是追踪计算过程生成 JAXPR 并编译成设备代码。菜谱不是剩菜,而是“做法记录”。相同输入形状下编译产物会被复用,但核心价值在于生成计算图,而不是缓存数值。
为何常见:因为 jit 的名字里带“即时编译”,人们容易把它类比成运行时缓存工具。
误区我以为 StableHLO 像 Java 字节码一样,由设备解释执行,是一种运行时文件格式。
正解StableHLO 是编译期中间表示,不是运行时指令集。它像标准化工序单,最终会被 MLIR 和 XLA 编译成 GPU/TPU 的机器码。Python 用户接触到的都是编译前产物,所以看不到它直接执行。
为何常见:因为 JIT、字节码、中间表示这些词都带“中间”,很容易混淆编译期和运行期。
误区我以为 MLIR Lowering 的最终结果是一段 C++ 代码,然后交给普通编译器编译。
正解Lowering 是多级方言转换,路线通常是 StableHLO -> Linalg -> Arith -> LLVM IR -> 机器码,并不会生成 C++ 源码。MLIR 和 LLVM 共用基础设施,但 MLIR 是更高层的多级 IR 框架。
为何常见:看到 LLVM 就想到 C++ 工具链,忽略了 MLIR 是面向编译器的“模型描述语言”。
误区Python 里写代码从不标注类型,JAX 也能运行,所以类型推导只是锦上添花。
正解类型推导决定张量形状和 dtype,是编译器分配内存、选择内核、执行布局转换和检测动态维度的基础。菜谱上不标食材分量,分店根本没法备菜。
为何常见:因为运行时 JAX 会自动处理 shape 检查,用户感知不到类型推导的存在,误以为没有它也能工作。
误区只要把训练任务塞满所有 GPU/TPU,速度一定线性提升,甚至不用管通信。
正解通信开销、同步屏障和内存瓶颈会让加速比远低于设备数。数据并行需要 all-reduce 汇总梯度,模型并行需要频繁传输中间激活。工程上要用梯度压缩、异步流水等方式降低等待。
为何常见:直观上“人多力量大”,却忽略了协作成本,也就是公式里的 T_ovh。
面试题
Q1. JAX 中 jax.jit 对 Python 函数做的第一件事是什么?
- A.直接编译成机器码
- B.追踪执行并生成计算图← 正确答案
- C.缓存函数的返回值
- D.调用 C++ 运行时
答案:B。jax.jit 先用 tracer 代替真实输入执行 Python 函数,记录算子依赖并生成 JAXPR。它不像 lru_cache 那样缓存结果,而是先建图后编译。机器码要等后续 XLA 阶段才生成。
Q2. JAX 追踪后得到的中间表示通常称为?
- A.JAXPR 计算图← 正确答案
- B.LLVM IR 指令
- C.ONNX 模型文件
- D.Python 字节码
答案:A。JAX 的追踪器输出 JAXPR,它是 JAX 内部的计算图。LLVM IR 在更底层,ONNX 是模型交换格式,Python 字节码则是解释器产物。
Q3. StableHLO 在 JAX 后端流水线中的角色是?
- A.统一的设备无关 IR← 正确答案
- B.设备相关的汇编器
- C.Python 的语法解析器
- D.分布式通信协议
答案:A。StableHLO 是稳定、可移植的高层算子集,属于设备无关 IR。它让 JAX 前端和 GPU/TPU 后端解耦。汇编器、语法解析器、通信协议都不是 StableHLO 的职责。
Q4. 类型推导公式 Γ ⊢ e : τ 中的 Γ 表示?
- A.编译期类型环境← 正确答案
- B.设备内存地址
- C.算子执行日志
- D.训练超参数表
答案:A。Γ 是类型上下文,记录已知变量的类型约束。它像备菜组长的规格表,是推导张量 shape 和 dtype 的依据,与运行时日志或超参数无关。
Q5. MLIR Lowering 过程中“legality”指的是?
- A.转换前后语义保持一致← 正确答案
- B.转换后代码符合 PEP8
- C.转换结果通过安全审查
- D.转换过程不需要验证
答案:A。legality 是方言转换的核心约束,表示每一步下降后计算语义不变。它保证高层 StableHLO 拆成底层算子后,仍等于原来的数学含义。PEP8、安全审查都不属于编译器 legality。
Q6. 算子融合最主要的性能收益来自?
- A.减少内核启动和访存次数← 正确答案
- B.提高 Python 解释速度
- C.增大训练 batch 大小
- D.降低模型参数量
答案:A。算子融合把多个计算合并到一个 kernel,减少中间结果写回显存和重新读取的开销,也减少内核启动次数。它不会显著改变 Python 速度、batch 大小或参数总量。
Q7. 数据并行与模型并行的核心区别是?
- A.前者切数据后者切模型← 正确答案
- B.前者切模型后者切数据
- C.两者在原理上完全等价
- D.两者在实际执行中相同
答案:A。数据并行是每台设备拿不同 batch,算出梯度后汇总;模型并行是把模型不同层或参数切到不同设备上。二者解决不同瓶颈,常组合使用,并不等价。
Q8. 以下哪些是 JAX 后端 MLIR 流水线环节?
- A.追踪生成 JAXPR← 正确答案
- B.映射到 StableHLO← 正确答案
- C.手工执行梯度下降
- D.XLA 生成设备代码← 正确答案
答案:ABD。流水线包含 JAX 追踪生成 JAXPR、方言映射到 StableHLO、MLIR 多级下降,以及 XLA 最终生成设备代码。梯度下降是训练算法,不是编译流水线环节。
Q9. TPU 上常用的硬件加速手段包括?
- A.脉动阵列矩阵乘法← 正确答案
- B.高带宽片上存储← 正确答案
- C.删除所有类型推导
- D.调度与算子融合优化← 正确答案
答案:ABD。TPU 通过脉动阵列高效执行矩阵乘法,并利用高带宽片上存储减少搬运开销;XLA 还会做调度和算子融合。类型推导是编译基础,不能删除,否则无法生成正确代码。
Q10. 大规模训练中用于缓解通信瓶颈的手段有?
- A.梯度压缩降低通信量← 正确答案
- B.异步流水隐藏等待时间← 正确答案
- C.禁止任何模型并行
- D.每步强制全局同步
答案:AB。梯度压缩减少 all-reduce 的数据量,异步流水让通信和计算重叠,从而隐藏等待。禁止模型并行和强制全局同步只会让扩展更困难,不是缓解手段。