想象你是一家中央厨房的总厨。客人点了一道由十几道工序组成的菜,按老规矩,每做完一道工序,半成品都要装盘、放回冰箱;下一道工序开始时,再把盘子从冰箱端出来。切完菜送回冰箱,焯完水又送回冰箱,炒的时候再拿出来……你很快就发现,大部分时间不是花在做饭上,而是花在一趟趟开冰箱、端盘子上。 AI 模型的执行也是这样:神经网络是一张计算图,每个节点是一个算子,每个算子独立地从显存读出数据、算完写回。中间结果在显存里来回搬运,往往比计算本身更耗时。XLA(加速线性代数)编译器扮演的角色,就是那位懂得统筹的总厨——它通过“算子融合”,把能合并的步骤合成一个整体,让半成品不再落地,一次就把菜出锅。 本文从算子融合的概念出发,沿着 XLA 的编译管线,讲清融合的分类、决策算法、性能收益与工程挑战,带你看懂 AI 编译器里这场“一锅出”的厨房革命。
理解算子融合,等于拿到了 AI 编译器优化的入场券:它能帮你读懂性能瓶颈、看懂 XLA/TVM 的优化日志,并亲手把模型训练和推理的时延降下来。
可视化演示
展示XLA算子融合:融合前后计算图对比(多内核HBM往返→单内核寄存器驻留)、逐元素归约生产者消费者三类融合与寄存器压力、内存读写与内核启动性能收益,交互切换核心维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
算子融合前后对比 未融合:3个独立内核(A→B→C),中间结果每次写回HBM再读取 融合后:1个内核完成全部计算,中间结果驻留寄存器无HBM访问 kernels: N → 1 | HBM R/W: O(N) → 0 | latency: N x launch → 1 x launch 算子融合是XLA减少内存瓶颈的核心优化
算子融合概念
回到中央厨房。如果每切一刀都要把菜放回冰箱、下一步再拿出来,客人等到天黑也吃不上饭。神经网络里的“切菜”就是一个个算子:矩阵加法、乘法、激活函数、卷积……默认情况下,每个算子是一个独立的内核(kernel),各自从显存读入数据、计算、写回结果。下一个算子再读这个结果,又得重新搬运一趟。当算子数量成千上万时,GPU 大部分能量都耗在了“端盘子”上,而不是真正干活。
这种“盘子在灶台与冰箱间往返”的成本,在计算机里叫访存(内存访问)。一个由 N 个算子串联的计算链,分开执行时每个算子都要写一次中间结果、再被下一个算子读一次;而融合后,整个链路只读一次输入、写一次输出:
所以算子融合的本质,就是让中间结果“不落地”。总厨把切、焯、炒合成一锅出,省下的正是那一趟趟端盘子的时间。
- Mem\_separate(分开执行的总访存量)N 个算子各自独立执行,每个算子的输出都要写回显存、供下一个算子读取。
- 2S\_i(每个算子贡献的两次访存)第 i 个算子的输出尺寸为 S\_i,写一次、下一个算子读一次,合计 2S\_i。
- 累加求和(总访存=每个算子访存之和)把 N 个算子的 2S\_i 全部加起来,就是分开执行在显存中搬运的数据总量。
- Mem\_fused(融合后只保留首尾两次)中间结果留在芯片内部的寄存器或共享内存里,不再写入显存,因此只统计输入和输出。
XLA编译管线
中央厨房接单后有一套完整流程:先由服务员把客人需求写成工单(前端捕获),再由总厨把工单整理成标准菜谱(HLO 图),接着总厨逐段审视菜谱、决定哪些步骤可以合并(优化 Pass),最后把定稿的菜谱交给炉头执行(后端代码生成)。XLA 编译器就是这座厨房的大脑。
XLA 的完整管线大致是:前端把 PyTorch、TensorFlow 等框架的计算图翻译成 HLO(高级优化中间表示);随后进入优化阶段,几十个 Pass 轮番对 HLO 图做变换,算子融合就发生在这里;优化后的 HLO 再次被翻译成 LLVM IR,最终由后端生成 GPU(PTX)或 TPU 的机器指令。
融合在图中就是一个“折叠”的操作:从一份 HLO 图里挑选 k 个节点,把它们替换成一个融合节点。
这条管线里,融合是唯一能“删边”的环节——总厨合并菜谱上的并列步骤,让整道菜的流程变短。
- G(原始计算图)编译前端产出的 HLO 图,节点是算子,边是数据依赖。
- \{v\_1, v\_2, …, v\_k\}(候选算子集合)被选中参与融合的一组算子节点,通常是数据流上相邻、形状匹配的算子。
- Fuse(融合变换)把 k 个节点折叠成一个融合节点,原节点间的中间边随之消失。
- G'(融合后的计算图)新图中融合节点内部是一个小型的子图,对外只暴露输入和输出,整体被编译成一个 kernel。
融合分类方式
厨房里的统筹有两种思路。第一种是横着批处理:十道菜都要用蒜末,那就一次剁一大碗,做菜时随手抓一把,不必每道菜重新剥蒜。第二种是纵着串流程:同一道菜,从洗、切到焯、炒一口气做完,中间不让半成品回冰箱。这两种思路,在编译器里分别叫横向融合和纵向融合。
- 横向融合:把同层、互不依赖的算子合并。比如多个形状相同的 elementwise 算子(加、乘、激活)并行执行,各自启动一个小 kernel 太浪费,合成一个大 kernel 后,启动开销从 N 次变 1 次。
- 纵向融合:把数据流上串联的算子合并。比如 Conv → BN → ReLU 是一条链,前一个算子的输出直接喂给后一个,合成一个核后中间结果不再访问显存。
- 输入融合:把多个上游算子的计算结果合并后一次性交付给下游消费者,常用于减少重复读取。
- 输出融合:把下游多个消费者的公共上游合并处理,同样是为了少搬几趟数据。
横向融合的特点是“并行批量”,纵向融合的特点是“串联到底”。实际模型中 Conv+BN+ReLU 是最经典的纵向融合案例:推理时 BN 的均值和方差已经固定,可以折叠成卷积的缩放系数,三段流水线瞬间变成一步到位。总厨心里那本账,就是看哪种统筹方式省得更多。
融合关键算法
中央厨房每天要面对成百上千道菜,总厨不可能把每两种食材的组合都试一遍。他会凭经验先找“相邻、且合并明显划算”的步骤下手:先并收益最大的,并完再看下一步。XLA 里的融合决策,用的正是这套思路——启发式搜索候选算子对,用代价模型评估收益,贪心迭代直到没有正收益为止。
具体的收益公式长这样:把两个算子 i 和 j 各自执行的代价相加,减去合并后一个核的代价,再归一化,得到融合带来的相对收益。
贪心策略会反复挑选 Gain 最大的相邻算子对执行融合,直到找不到正收益为止。同时,融合还要受峰值内存约束:如果融合核的中间结果需要在寄存器里同时存活,而数量超过了硬件寄存器上限,即使收益为正也会被否决。这就像总厨发现“这锅菜需要八个灶眼,但厨房只有四个”——再划算也得放弃。
- C\_i、C\_j(两个算子单独执行的代价)代价通常由计算时间和访存时间构成,访存受限的算子里,访存占大头。
- C\_{i⊕j}(合并后一个核的代价)融合核只需要一次访存、一次计算,但寄存器压力也可能增大。
- 分子(省下的代价)分开执行的总代价减去融合后的代价,数值越大说明越划算。
- 分母归一化(相对收益)除以分开执行的代价,得到“省了百分之多少”,方便在不同候选对之间横向比较。
性能优化特性
一锅出的好处不只是少洗几个碗。从执行的角度看,它至少带来三件好事:第一,访存次数骤降,半成品不再来回进出显存;第二,内核启动开销减少,GPU 不必为每个小算子付出调度和 launch 的固定成本;第三,融合核内部有机会做指令级并行编排,算子和算子之间的依赖变成寄存器间的直通数据,而不是等显存回包。
其中访存的收益最为关键。在访存受限(memory-bound)的场景下,算得快不快基本由“搬了多少数据”决定。假设 N 个算子的中间结果大小相近,都是 \bar{S},带宽为 B,那么:
灶台火力(带宽)越紧张,一锅出的优势就越明显。大模型训练和推理往往就是这种“火力不足”的厨房——显存带宽供不应求,算子融合因此成为最立竿见影的优化手段。
- 分子(分开执行的总访存时间)N 个算子各自写一次、读一次中间结果,总搬运量 2N\bar{S},除以带宽 B 得到时间。
- 分母(融合后的访存时间)融合核只读一次输入、写一次输出,搬运量只有 2\bar{S}。
- 加速比(两者相除)访存受限时,融合的加速倍数约等于被融合的算子数量 N。
- 适用条件这个公式假设计算时间可以忽略,中间结果尺寸相近;如果计算本身很重(compute-bound),收益会小于 N。
实践与挑战
一锅出也有翻车的时候。锅太大,灶眼放不下;客人临时改分量,备菜方案全部作废。算子融合在工程实践中同样会遇到三类典型挑战。
第一是调试难度。融合核一旦生成,内部就是一个黑盒,原本可以逐个算子检查的中间结果全被藏起来了。工程师排查数值问题时,经常不得不先关闭融合再逐层对比。第二是编译耗时。启发式搜索和代价评估要遍历大量候选对,融合 pass 有时会让编译时间成倍增加,训练脚本里等编译的时间比训练还长。第三是动态形状。当输入张量的形状在运行时才能确定时(比如 NLP 里变长序列),编译器没法预编译最优的融合核,XLA 往往会回退到保守路径,放弃融合或触发重新编译。
实战中,融合的收益也很可观:ResNet 推理时把 Conv+BN+ReLU 融合,端到端时延常能下降 30% 以上;Transformer 解码阶段把 KV Cache 读取与注意力计算融合,能显著降低显存带宽压力。但这些数字都建立在“代价模型判断值得”的前提上——优秀的总厨既懂得合并,也懂得什么时候不该合。
AI 应用场景
LLM 推理加速:解码阶段的注意力融合
阶段:推理部署优化
解决的问题:大语言模型逐 token 生成时,每个 token 都要执行注意力与 FFN 的多个 kernel。每个 kernel 独立启动,都要从 HBM 显存里读取 KV Cache 和中间激活。传统推理引擎在这条链路上搬运的数据远多于计算,显存带宽成为吞吐瓶颈。
具体案例:推理引擎把 KV Cache 的读取、QK 点积、Softmax、与 V 的加权累加融合成一个 kernel,分数矩阵不再写回显存;同时把 RMSNorm 融合进残差连接与线性层。优化后,单 token 生成的访存次数从十几次降到两三次,输出吞吐提升 30% 以上。
CNN 模型部署:Conv+BN+ReLU 折叠
阶段:模型转换与优化部署
解决的问题:ResNet 等 CNN 在 GPU 上推理时,卷积、批归一化、激活函数三段各自访存一次。部署场景对时延极其敏感,这类重复搬运是完全可避免的浪费。
具体案例:部署工具链先把 BatchNorm 的缩放与偏移参数折叠进卷积权重,再把卷积+ReLU 注册为融合算子,生成单个融合 kernel。在 TensorRT、XLA 等编译器里,这是默认优化项。实测在 NVIDIA A100 上,ResNet50 单卡推理时延降低约 25%,kernel 数量从数百个降到几十个。
Python 演示
import time def busy_wait(ms): end = time.perf_counter() + ms / 1000.0 while time.perf_counter() < end: pass def kernel(compute_ms, mem_mb, bandwidth_mb_per_ms): busy_wait(compute_ms) return compute_ms + mem_mb / bandwidth_mb_per_ms def main(): # 四个算子的计算耗时(毫秒) compute = [0.05, 0.08, 0.06, 0.30] # 每个算子的中间结果大小(MB) sizes = [1.0, 1.0, 1.0, 1.0] bandwidth = 2.0 # MB/ms, 模拟较慢的显存带宽 n = len(compute) sep_time = sum(kernel(compute[i], 2 * sizes[i], bandwidth) for i in range(n)) fused_time = kernel(sum(compute), sizes[0] + sizes[-1], bandwidth) print("分开执行耗时: %.4f ms" % sep_time) print("融合执行耗时: %.4f ms" % fused_time) print("加速比: %.2f x" % (sep_time / fused_time)) print("访存对比: %d MB -> %d MB" % (2 * sum(sizes), sizes[0] + sizes[-1])) if __name__ == "__main__": main()
def kernel(compute_ms, mem_mb, bandwidth_mb_per_ms):一个 kernel 的耗时 = 计算时间 + 数据搬运时间busy_wait(compute_ms)用空循环模拟 GPU 实际算数的时间sum(kernel(compute[i], 2 * sizes[i], bandwidth) for i in range(n))分开执行时每个算子都写一次结果、下个算子再读一次,所以访存按 2 倍中间结果计算kernel(sum(compute), sizes[0] + sizes[-1], bandwidth)融合核把计算量合并成一次执行,访存只剩输入与输出sep_time / fused_time加速比展示访存受限场景下融合的收益
常见误区
误区总厨说把能合并的都合了肯定快,算子融合在任何情况下都会让模型变快,融合得越多性能越好。
正解融合省了访存,却可能增加寄存器压力。当融合核过大、中间结果超出片上存储时,会发生寄存器溢出,性能反而下降。XLA 的代价模型会在收益为负时拒绝融合,甚至在不划算时回退到分开执行。
为何常见:官方示例展示的融合加速动辄数倍,让人误以为融合是“包治百病”的万能优化。
误区一个巨型 kernel 肯定比几个小 kernel 优秀,最好把整个模型的所有算子合成一个。
正解融合核过大会导致寄存器占用过高、GPU 流处理器(SM)占用率暴跌,并行度被严重削弱。真实的编译器会通过代价模型平衡“减少访存”与“保持并行”,在两者之间寻找甜点。
为何常见:把“减少 kernel 启动次数”当成唯一指标,忽略了 GPU 是并行机器、需要足够并发来隐藏延迟。
误区我只要把模型写好,XLA 会自动完成所有融合,源码怎么写无所谓。
正解算子的表达方式直接影响融合机会。比如把 BatchNorm 的参数手动折叠进卷积权重,融合就更容易触发;把多个小算子拆得支离破碎,即使编译器再聪明也找不到可融合的 pattern。理解和写出“可融合模式”是工程优化的基本功。
为何常见:XLA 的自动化程度高,掩盖了源码结构对优化效果的影响,直到打开 profiling 才发现融合根本没发生。
误区反正都是把几个算子合并成一个大 kernel,分类没有意义。
正解横向融合合并的是同层并行、互不依赖的算子,主要省 kernel 启动开销;纵向融合合并的是数据流上串联依赖的算子,真正省去中间结果的访存。两者解决的问题完全不同,分别对应“批量备菜”和“一锅连续出菜”。
为何常见:只看“合并”这个结果,忽略了算子之间的关系才是决定融合方式的关键。
面试题
Q1. 算子融合最直接降低的开销是什么?
- A.显存访问带宽← 正确答案
- B.CPU 核心频率
- C.硬盘读写速度
- D.网络传输延迟
答案:A。融合核把中间结果留在片上寄存器,减少显存往返,因此直接受益的是显存访问带宽。CPU 主频、硬盘、网络与 GPU 内核访存无关。
Q2. Conv、BN、ReLU 依次串联时最合适用什么融合?
- A.横向融合
- B.纵向融合← 正确答案
- C.输入融合
- D.输出融合
答案:B。Conv、BN、ReLU 是同一数据流链路上的串行算子,纵向融合把它们合并成一个核。横向融合针对并行算子,输入/输出融合处理的是图边界。
Q3. 融合决策主要由什么机制来评估“值不值得”?
- A.计算图节点数量
- B.融合代价模型← 正确答案
- C.训练数据集大小
- D.GPU 核心主频
答案:B。融合的收益和风险由代价模型量化——省多少访存、占多少寄存器,收益为正才融合。计算图规模、训练数据、CPU 主频都不是决策依据。
Q4. 融合核过大会带来的典型副作用是什么?
- A.计算精度降低
- B.寄存器溢出变慢← 正确答案
- C.显存泄漏风险
- D.训练无法收敛
答案:B。融合核过大时,所有中间结果都想留在寄存器里,装不下就发生寄存器溢出,性能反而下降。编译器靠代价模型限制融合规模,与精度、泄漏、收敛无关。
Q5. 下列哪些情况常导致融合无法生效?
- A.动态形状张量← 正确答案
- B.中间结果超大← 正确答案
- C.训练批次过大
- D.跨设备算子调用← 正确答案
答案:ABD。动态形状使编译期无法确定访存布局,超大中间结果放不进片上存储,跨设备算子必须先通信再计算,这三类情况融合都难以生效。训练批次大小不影响融合决策。
Q6. XLA 中算子融合优化发生在哪个阶段?
- A.前端图捕获阶段
- B.HLO 优化阶段← 正确答案
- C.后端指令调度
- D.运行时任务调度
答案:B。XLA 流程是前端捕获转 HLO,随后优化 Pass 在 HLO 图上做融合,最后后端生成机器码。融合属于 HLO 优化阶段的图变换。
Q7. 关于横向融合与纵向融合描述正确的有哪些?
- A.纵融合一数据流路径← 正确答案
- B.横融合并行同层算子← 正确答案
- C.横向可省中间结果
- D.纵向合并无收益
答案:AB。纵向融合沿数据流合并链式算子,省中间结果访存;横向融合合并并行同层算子,减少启动开销。“横向省中间结果”“纵向无收益”的说法正好把两者搞反了。
Q8. 访存受限场景下融合 N 个算子,加速比理论上接近什么?
- A.对数增长 logN
- B.线性增长 N 倍← 正确答案
- C.平方增长 N 平方
- D.指数增长 2 的 N 次方
答案:B。访存受限时,分开执行约访存 2N·S,融合后约 2S,比值约为 N。计算受限时收益小于 N,但不会达到平方或指数级别。
Q9. 融合核相比分开执行有哪些执行特征?
- A.中间结果不出显存← 正确答案
- B.内核启动次数减少← 正确答案
- C.计算精度必然提升
- D.输入输出各访存一次← 正确答案
答案:ABD。融合核的特征是中间结果留片上、kernel 启动从 N 次变 1 次、显存只保留输入输出各一次访问。精度并不必然提升,浮点运算顺序还可能带来微小差异。
Q10. 动态形状下融合常被回退的原因是什么?
- A.无法预编译最优核← 正确答案
- B.动态形状计算准
- C.融合后显存更小
- D.与优化器不兼容
答案:A。动态形状使缓冲区大小、访存布局在编译期未知,无法预编译最优融合核,XLA 常回退到非融合路径或触发重新编译。这与计算精度、显存大小、优化器无关。