想象你是一家米其林后厨的总厨。每天订单像雪片一样飞来,每道菜(深度学习模型)都有几十道工序(计算算子)。你的厨房设备一流:有超大灶台(GPU)、保温柜(高速缓存),但后厨规矩却可能毁掉一切——如果每换一道工序都要把食材放回冷库再拿出来(读写显存),如果每道菜都独占一口锅(为每个张量单独分配显存),再好的灶台也白搭。XLA 就是替你统筹后厨的智能总管:它先把客人点单翻译成一张标准备餐清单(HLO 中间表示),再优化流程——能一锅出的绝不分开做(算子融合),能轮用的锅具绝不浪费(内存复用与缓冲区分配),最后把清单翻译成每位厨师的肌肉记忆(机器码)。本文我们就跟着这位总管,把 XLA 的后厨秘密一层层拆开看。
理解 XLA 是掌握 JAX 高性能引擎的关键:无论训练还是推理,你写的 Python 代码都要经它翻译与改造。学透本文,你能看懂性能瓶颈、写出对编译器友好的模型,也能为系统优化打下基础。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
XLA概览与定位
餐厅开张前,总厨得先明确自己的职权范围。XLA(Accelerated Linear Algebra,加速线性代数)就是深度学习框架请来的这位总厨:它不像普通厨师那样“来一道炒一道”,而是拿到一整桌订单后统一规划再开工。在 JAX 世界里,普通模式下每个算子(比如加法、正弦)都会立刻执行,就像厨师每切一片菜就去炒一次;而 jax.jit 会把这整桌菜一次性打包交给 XLA 总厨。
从技术上讲,XLA 是一个面向线性代数计算的领域专用编译器。它接收的是 JAX 或 TensorFlow 给出的“菜单”——张量计算程序——输出的是能在 GPU、CPU、TPU 上高效运行的机器码。与通用编译器不同,XLA 知道矩阵乘法、卷积这些算子的数学含义,因此可以大胆地在代数层面做文章:把连续的加法、乘法合并,把中间结果留在寄存器里。
XLA 的整个工作流程可以浓缩成一条流水线:
所以 XLA 的定位很清晰:它不是某个炒菜工位,而是整个后厨的流程总管。它把“菜单进、菜品出”之间的一切——清单翻译、流程合并、锅碗调度——都包了下来。理解了这层定位,接下来每个环节就都有了坐标。
- 追踪生成 HLO 图JAX 通过 tracer 把 Python 函数展开成一张由 HLO 指令组成的有向无环图,消除所有 Python 层面的动态分支与 Python 开销。
- 优化 Pass 反复改造算子融合、内存复用、缓冲区分配等 Pass 依次作用在 HLO 图上,让它变得更省内存、更少访存、更适合目标硬件。
- 下降与代码生成优化后的 HLO 先降为平台无关的 LLVM IR,再由 LLVM 的代码生成器翻译成 GPU、CPU 或 TPU 的机器码。
HLO中间表示
总厨拿到订单后第一件事,是把口语化的点单整理成标准备餐清单。这份清单上的每一条都不能有歧义:食材是什么、怎么切、放哪个盘。HLO(High Level Optimizer)就是 XLA 的备餐清单——一种面向张量计算的中间表示(IR),整个编译过程都以它为工作台。
HLO 组织成模块、子计算、指令三层结构。一个模块就像整本菜单,里面包含多个子计算;一个子计算像一道菜的具体步骤,由若干指令按依赖关系排成一张有向无环图;一条指令则像“把土豆切成块”这样的一步。指令之间采用 SSA(静态单赋值)形式:每个值只被定义一次,后面的指令直接引用前面的值,天然不存在复杂的别名纠缠。
一条 HLO 指令可以看作一个四元组:
有了这份无歧义的备餐清单,总厨才敢开始动刀。HLO 的价值正在于此:在它之前是 Python 的千变万化,在它之后是硬件的千差万别,而 HLO 提供了一个稳定的中间锚点。无论是 JAX 还是 TensorFlow 程序,最终都会汇聚到这个共性表示上。
- opcode 操作码标记指令类型,如加法 kAdd、乘法 kMultiply、矩阵乘 kDot、卷积 kConvolution,决定了这条指令的数学语义。
- operands 操作数输入张量的引用列表,指向图中其他指令的输出,构成数据依赖边。
- shape 输出形状描述输出张量的维度大小与元素类型,比如一个 3×256 的 float32 矩阵。
- layout 内存布局指定多维张量在内存中的维度排列顺序,例如行主序还是列主序,直接决定访存是否连续。
算子融合策略
清单拿在手里,总厨开始找茬:“洗菜、切菜、焯水”三步本来可以在同一个案板一口气完成,可如果每步都把菜放回冷库,下一位再取出来,时间全耗在路上。深度学习里也有同样的“冷库”——显存:每个张量算子都要从显存读输入、把结果写回显存。
这类逐元素算子(加法、乘法、激活函数等)的计算量很小,瓶颈几乎全在访存上,属于访存密集型算子。XLA 的策略是算子融合:把这些首尾相连的小算子合并成一个大的融合算子(fused kernel),中间结果不再回写显存,而是留在寄存器或片上缓存里。整个融合过程由 HLO 层面的 fusion Pass 自动完成,它会在图上找出形状一致、无副作用、满足融合条件的算子链,把它们包进一条 kFusion 指令。
用一个小模型算笔账:两个逐元素算子 A 和 B 串行执行,A 读输入 x、写中间量 m;B 读 m、写输出 y。
融合就像“一锅出”:菜省去了反复入冷库的奔波,锅也少洗几遍。在实际模型里,像 GELU 激活、LayerNorm 这类由许多逐元素小算子拼成的结构,正是融合最大的受益者——它们往往被融合成单个内核,跑起来又快又省。
- 未融合的访存量A 读出输入、写入中间量,B 读出中间量、写入输出,所以中间量 m 被访问了两次(一写一读)。
- 融合后的访存量A 和 B 合并成一个内核,数据从输入读入后一直在寄存器里流动,直到输出才写回显存。
- 差值即收益节省的访存量等于中间张量的两倍大小,同时内核启动次数也从两次降为一次。
内存复用
融合省下了奔波,但后厨还有个老问题:锅不够用。如果每道菜都独占一口锅,灶台边早堆成山了。显存也一样:一块 20GB 的 GPU,动辄几 GB 的张量并行存在,很快就会爆。XLA 的办法是“错峰用锅”——记录每口锅被哪道菜占用到几点,只要两道菜不同时用,就能共用同一口锅。
技术上说,每个张量缓冲区都有自己的活跃区间:从被某个算子生产出来起,到最后一次被消费止。两个缓冲能否复用同一块显存,取决于它们的活跃区间是否重叠。这需要做活跃性分析,扫描整个计算图,为每个值标注定义点和最后使用点,得到一张完整的“用锅时间表”。
判断规则可以写成:
通过这种“错峰用锅”,模型的峰值显存从“所有张量大小之和”压缩到“同一时刻最多同时存活的张量大小之和”。对大模型来说,这个差距往往是数倍——正是复用让几十 GB 的模型能挤进有限显存,也让训练时的 batch size 可以开得更大。
- 活跃区间值 v 从定义指令到最后一个消费指令之间的指令区间,定义了它“必须活在显存里”的时间范围。
- 区间求交若两个活跃区间有交集,说明两个张量在某时刻同时存在,不能共用同一块内存。
- 复用判定交集为空则复用安全,分配器就会把 B 安排到 A 释放出来的那块内存上。
缓冲区分配
错峰方案定好了,接下来要把每道菜的用锅安排落实到具体位置——哪口锅放哪个架位。缓冲区分配就是显存里的“摆锅规则”:给定每个张量的生命周期和大小,决定每一段内存的起始偏移量,让它们在互不冲突的前提下尽量紧凑。
XLA 的缓冲区分配器拿到活跃性分析结果后,会把所有需要存储的张量看作若干带长度的区间,像拼乐高一样拼进一块大显存。核心目标是最小化峰值内存:
除了复用已释放的空间,分配器还要处理临时缓冲——某些算子在执行时需要额外的暂存区,比如卷积的 im2col 预处理、归约的中间累加。分配器会给这些临时缓冲也安排好位置;别名分析则进一步发现:某些值是另一些值的“切片”或“转置视图”,可以直接共享底层存储,不必复制。布局优化还会结合目标硬件的访存偏好,选择行主序还是列主序等内存布局,让连续访问命中率更高。
这套“摆放规则”的效果立竿见影:内存布局合理、访问连续,缓存命中率高,实际带宽利用率也上去了。总厨看着井井有条的架位,终于可以把精力投给下个环节——真正开火炒菜。
- 存活集合给定一个执行时刻 t,收集所有活跃区间覆盖 t 的缓冲区,得到此刻必须占着显存的张量集合。
- 时刻内存用量把存活集合里每个缓冲区的大小累加,就是 t 时刻的瞬时内存占用。
- 峰值与最小化对所有时刻取最大值得到峰值内存,分配器以最小化该值为目标来安排偏移和复用。
编译流水线
到这一步,总厨的准备工作基本就绪。但一家餐厅要稳定出餐,靠的是固定流程:点单、备餐、炒制、装盘、上桌。XLA 的编译流水线同样分阶段,每一阶段有明确的输入和输出,任何一阶段出问题都能快速定位。
简化来看,XLA 编译可以分成四大段:前端追踪、HLO 优化、下降、代码生成。前端由 JAX 的 tracer 完成,把 Python 函数变成 HLO;HLO 优化阶段就是前面讲的主角们——融合、内存复用、缓冲区分配等 Pass 在这里依次登场;下降阶段把 HLO 翻译成 LLVM IR;最后 LLVM 生成机器码。
补充一点:不是所有算子都走通用代码生成路径。对于矩阵乘法、卷积这类重量级算子,XLA 会直接调用厂商提供的高性能库(如 cuBLAS、cuDNN),就像总厨把“佛跳墙”直接交给专门的大师傅,而不是让普通厨师从头摸索。这样既保证通用性,又保证顶级性能。
流水线是后厨的骨架:每个岗位各司其职、产物边界清晰。理解流水线,遇到“编译慢”的问题时,你就能大致判断卡在哪一段——是前端反复追踪,还是优化 Pass 太多,抑或是 LLVM 后端代码生成耗时,进而针对性排查和调优。
- 融合 Pass先把可合并的算子链合成 kFusion 指令,从源头减少中间张量的产生。
- 复用与分配 Pass在融合后的图上做活跃性分析、内存复用和缓冲区分配,决定每个张量住在哪块显存。
- 下降为 LLVM IR把 HLO 指令逐条翻译成 LLVM 中间指令,涉及循环生成、向量化接口等低层细节。
- 代码生成LLVM 接手优化并生成目标平台的机器码,必要时调用厂商高性能库。
LLVM后端
总厨的清单最终要变成厨师手上的动作。但每个餐厅的灶具不同:有的用明火、有的用电磁炉、有的是燃气灶。总厨不可能为每家餐厅单独写一套动作手册。聪明的做法是:先写一套“通用动作”,再让一套工具自动翻译成各家设备的具体动作。LLVM 就是这套“通用动作库”。
LLVM IR 是一种低层次、但平台无关的中间指令:它贴近机器(有内存搬运、算术、跳转指令),又不绑定任何具体 CPU 或 GPU。XLA 把 HLO 下降为 LLVM IR 后,LLVM 编译器接手,执行自己的优化流水线——循环展开、向量化、内联、指令合并等——再通过指令选择把每条 IR 指令映射为目标平台的真实指令,最后做寄存器分配。
值得强调的是,XLA 的 GPU 后端并不是纯靠 LLVM 从零生成所有代码:它对逐元素融合算子是生成 LLVM IR 编译成 PTX/CUDA 内核;对大的 GEMM、卷积则调用 cuBLAS/cuDNN 等手工调优库。这种“通用模板加专家库”的组合,兼顾了灵活性和性能上限。
到了这里,一张订单终于变成了端上桌的菜:从点单(高层的 Python API)到备餐清单(HLO),从流程合并(融合)到锅碗调度(内存复用与缓冲区分配),再到最后的肌肉动作(机器码)。XLA 这位总厨的全套后厨魔法,已经尽收眼底。
- 指令选择把 LLVM IR 指令与目标硬件的指令模式做匹配,生成一棵选择 DAG,选出每条 IR 对应的真实机器指令。
- 指令调度按数据依赖关系排列指令顺序,兼顾硬件流水线与缓存局部性,减少停顿。
- 寄存器分配把数量无限的虚拟寄存器映射到有限的物理寄存器,放不下时把变量溢出到内存。
AI 应用场景
JAX 训练循环的 JIT 编译加速
阶段:模型训练
解决的问题:eager 模式下每个算子独立启动一个 GPU 内核,中间张量反复写回显存,训练 step 中大量逐元素算子(激活、损失计算、梯度裁剪、优化器更新)都在空耗带宽。XLA 通过 JIT 编译把前向、反向与优化器更新融合成少数内核。
具体案例:训练 BERT 类模型时,将 train_step 函数用 @jax.jit 装饰。XLA 把损失计算链上的几十个逐元素算子融合为单个内核,同时复用反向传播中的中间缓冲,训练吞吐显著提升,显存占用下降约三成。
大模型推理中的注意力融合
阶段:模型推理部署
解决的问题:注意力机制中 QK^T、softmax、PV 之间会产生巨大的中间矩阵,HBM 带宽成为推理延迟的瓶颈,逐算子执行还会多次启动内核。
具体案例:推理引擎工程师利用 XLA 的融合能力,把注意力子图编译成单内核,QK^T 的结果直接留在片上寄存器参与 softmax,不再写回显存。在 KV cache 场景下,首 token 延迟明显下降,长序列吞吐提升。
跨后端统一部署到 TPU
阶段:模型部署到专用硬件
解决的问题:同一模型要跑在 CPU、GPU、TPU 等多种后端上,手写每套后端的优化代码几乎不可维护。HLO 作为统一中间表示,让“一份模型、多端部署”成为可能。
具体案例:Google 内部将 JAX 训练的模型经 XLA 编译到 TPU:前端统一生成 HLO,后端针对 TPU 做专门的布局与流水线优化。编译器工程师只需关注 HLO 到 TPU 指令的映射,而无需关心前端框架差异。
Python 演示
import jaximport jax.numpy as jnp def element_wise_chain(x): # 三个逐元素算子串成一条链, 这是融合的典型候选 t = jnp.multiply(x, 2.0) t = jnp.sin(t) t = jnp.add(t, 1.0) return jnp.sum(t) def main(): # 构造输入并触发一次完整的 XLA 编译 x = jnp.arange(8, dtype=jnp.float32) compiled = jax.jit(element_wise_chain) out = compiled(x) print("result:", out) # 导出 HLO 中间表示, 可以看到内部的融合算子 hlo = compiled.lower(x).compiler_ir(dialect="stablehlo") print(hlo) if __name__ == "__main__": main()
def element_wise_chain(x):定义一条乘、正弦、加、归约组成的计算链,结构上正是融合与内存复用的重点优化对象。t = jnp.multiply(x, 2.0)逐个元素相乘,属于访存密集型轻量算子,若不用 XLA 编译,它会单独启动内核并写回中间量。compiled = jax.jit(element_wise_chain)把函数标记为“交给 XLA 总厨统筹”,返回一个可调用的编译版本。out = compiled(x)首次调用触发完整编译流水线:追踪成 HLO、融合优化、下降生成机器码,之后同一 shape 的调用直接复用。compiled.lower(x)对示例输入 x 做 lower 操作,生成可导出的中间表示对象。compiler_ir(dialect="stablehlo")把中间表示序列化为文本,打印出的模块里可以看到融合后的算子结构。
常见误区
误区我只要在函数前面加上 `@jax.jit`,XLA 就会自动把性能拉满,模型一定比 eager 模式快很多。
正解jax.jit 带来的是编译优化,但编译本身有开销:首次调用要花时间追踪和优化,shape 或结构变化还会触发重新编译。模型只有几行简单运算时,编译开销可能超过收益,反而更慢。正确做法是用 benchmark 对比 eager 与 jit 的端到端耗时,再决定是否启用。
为何常见:因为网上教程几乎都以“jit 加速”为例,且 JAX 官方文档强调编译后的性能优势,初学者容易把“可能加速”误读成“必然加速”。
误区融合最大的好处就是把好几个内核合并成一个,减少 CPU 启动 GPU 内核的次数,仅此而已。
正解减少内核启动只是表面收益。更深层的收益是消除中间张量在显存里的写回与重读:融合后中间量留在寄存器或共享内存里,访存量从“输入+2×中间+输出”降为“输入+输出”。对于访存密集型的逐元素链,这种内存带宽节省往往比内核启动开销的减少更关键。
为何常见:因为“少启动内核”最容易从字面理解,而访存带宽的定量分析需要理解硬件层级,普通开发者很少关注 profiling 数据。
误区缓冲区复用只是省点显存而已,属于优化选项,即使完全不复用,模型最多慢一点,不会出错。
正解对大模型来说,不复用往往直接导致显存溢出(OOM)。设想一个计算图的中间张量共 20 个、每个 1GB,不复用时峰值占用 20GB,复用后可能只要 5GB。在常见的 16GB、24GB 显卡上,这两者的差别就是“能训”与“不能训”。所以内存复用和缓冲区分配不是优化,而是可行性的前提。
为何常见:小模型运行时显存富余,让人误以为分配策略无关紧要;只有把模型 scale 上去,才会撞到显存墙。
误区HLO 既然是编译器的中间表示,应该和汇编差不多,一条 HLO 指令就对应一条 CPU 或 GPU 指令。
正解HLO 仍然是非常高层的代数表示,一条 kDot 可能对应一个矩阵乘,一条 kFusion 对应一个复杂的循环内核,和机器指令相差好几个抽象层级。中间还要经过 LLVM IR、指令选择、寄存器分配等步骤才会变成机器码。HLO 的“低”是相对 Python 高层 API 而言,离硬件还远得很。
为何常见:初学者通常只接触过“源代码到字节码”这类简单编译模型,容易把“中间表示”一律想象成贴近机器的汇编形态。
面试题
Q1. JAX 中触发 XLA 编译的推荐方式是哪种?
- A.用 jax.numpy 直接计算
- B.用 jax.jit 修饰函数← 正确答案
- C.用 jax.random 生成随机数
- D.用 jax.tree 拼接数据
答案:B。jax.jit 会把整个函数追踪成中间表示并交给 XLA 编译,生成可执行程序;jax.numpy 直接运算是 eager 路径,不经过编译;jax.random 只负责随机数生成;jax.tree 是纯数据结构工具。用厨房比喻说,只有 jit 才会把整桌菜交给总厨统筹规划。
Q2. HLO 指令四元组中,哪个字段负责描述输出张量的维度?
- A.opcode 算子类型
- B.operands 输入列表
- C.shape 输出形状← 正确答案
- D.layout 布局配置
答案:C。HLO 指令由 opcode、operands、shape、layout 组成。opcode 决定做什么运算,operands 给出输入引用,shape 描述输出张量的维度大小与元素类型,layout 决定内存排列顺序。就像备餐清单上“切成 2 厘米块”这句话,输出长什么样由 shape 说了算。
Q3. 算子融合可以带来哪些收益?
- A.减少内核启动次数← 正确答案
- B.消除中间张量访存← 正确答案
- C.增加模型参数数量
- D.提升缓存命中概率← 正确答案
答案:ABD。融合把多个内核合成一个,启动次数减少;中间结果留在寄存器或共享内存,不再写回显存,访存大幅下降;数据在片上连续流动,缓存命中率提升。融合不改变网络结构,与参数数量无关,所以 C 错误。这正是“一锅出”的三重好处。
Q4. 判断两个张量缓冲能否共用同一块显存的依据是什么?
- A.两者形状必须完全一致
- B.两者活跃区间互不重叠← 正确答案
- C.两者数值大小必须相等
- D.两者位于同一张计算图
答案:B。内存复用只看生命周期:从定义到最后一次使用之间的活跃区间。只要 A 和 B 的活跃区间没有交集,即使形状、数值完全不同,也能共用一块显存。就像两道菜即使分量不同,只要不同时占用同一口锅,就可以轮流使用。
Q5. XLA 缓冲区分配中“临时缓冲”通常指什么?
- A.编译期复用的一段暂存空间← 正确答案
- B.模型权重专用的常驻缓存
- C.框架前向传播的输出缓存
- D.操作系统拿到的虚拟内存
答案:A。临时缓冲是某些算子在执行时需要额外 scratch 空间(如卷积 im2col、归约中间累加),由分配器在编译期统一规划,生命周期结束后可被其他算子复用。它不属于模型权重,也跟操作系统虚拟内存无关。就像厨房里临时用来沥水的空碗,用完就腾出来给别的菜。
Q6. XLA 编译流水线的正确顺序是哪一项?
- A.先从 HLO 再到机器码再到程序
- B.JAX 程序经 HLO 再到机器码← 正确答案
- C.先机器码再到 HLO 再到程序
- D.LLVM IR 经 HLO 再到机器码
答案:B。流水线顺序是:JAX 程序(Python 层)先被追踪成 HLO 图,经过优化 Pass,再下降为 LLVM IR,最后生成机器码。其他选项都颠倒了因果:机器码是终点不是起点,LLVM IR 在 HLO 之后而非之前。
Q7. 下列哪些属于 XLA 的 HLO 优化 Pass?
- A.算子融合优化 Pass← 正确答案
- B.缓冲区分配 Pass← 正确答案
- C.Python 解释器 Pass
- D.常量折叠化简 Pass← 正确答案
答案:ABD。算子融合、缓冲区分配、常量折叠都属于 XLA 编译流水线中的 Pass,它们在 HLO 图上工作,减少访存、降低峰值内存、消除冗余计算。Python 解释发生在 XLA 之前,由 JAX 运行时负责,不参与 HLO 层面的优化,所以 C 错误。
Q8. 两个逐元素算子融合后,张量数据的访存量大约如何变化?
- A.增加为原来的三倍
- B.和融合前完全相等
- C.降为一次输入加输出访存← 正确答案
- D.只保留读输入不写输出
答案:C。未融合时每个算子都要把中间量写回显存再由下一个算子读出,访存量约为“输入 + 2×中间 + 输出”;融合后中间量留在寄存器里,变成“输入 + 输出”一次读写。输出仍然要写回显存,所以 D 错误。就像菜不再放回冷库来回折腾,省下的正是中间那两趟路。
Q9. XLA 中的英文缩写对应哪一组单词?
- A.Extra Linear Accelerator
- B.Accelerated Linear Algebra← 正确答案
- C.eXotic Language Analyzer
- D.Accelerated Language Agent
答案:B。XLA 全称是 Accelerated Linear Algebra,即“加速线性代数”,是 Google 为深度学习张量计算打造的领域专用编译器。其余选项都是干扰项,XLA 并不负责语言分析,也并非“额外”的加速器,而是对线性代数运算做整体编译优化。
Q10. 关于 XLA 的 LLVM 后端,下列说法正确的是?
- A.将 HLO 直接翻译成机器码
- B.负责指令选择与寄存器分配← 正确答案
- C.可针对不同 CPU 架构生成代码← 正确答案
- D.不参与任何形式的优化
答案:BC。LLVM 后端接收的是 LLVM IR 而非 HLO,HLO 要先下降为 LLVM IR 才能进入后端,所以 A 错误。后端做指令选择、调度、寄存器分配,并能针对多种 CPU 架构生成代码,同时 LLVM 自身也有循环展开、向量化等优化,所以 B、C 正确,D 错误。这正是“通用动作库”的价值所在。