想象你是一家中央厨房的总厨。客人点了一道由十几道工序组成的菜,按老规矩,每做完一道工序,半成品都要装盘、放回冰箱;下一道工序开始时,再把盘子从冰箱端出来。切完菜送回冰箱,焯完水又送回冰箱,炒的时候再拿出来……你很快就发现,大部分时间不是花在做饭上,而是花在一趟趟开冰箱、端盘子上。 AI 模型的执行也是这样:神经网络是一张计算图,每个节点是一个算子,每个算子独立地从显存读出数据、算完写回。中间结果在显存里来回搬运,往往比计算本身更耗时。XLA(加速线性代数)编译器扮演的角色,就是那位懂得统筹的总厨——它通过“算子融合”,把能合并的步骤合成一个整体,让半成品不再落地,一次就把菜出锅。 本文从算子融合的概念出发,沿着 XLA 的编译管线,讲清融合的分类、决策算法、性能收益与工程挑战,带你看懂 AI 编译器里这场“一锅出”的厨房革命。

岗位关联

理解算子融合,等于拿到了 AI 编译器优化的入场券:它能帮你读懂性能瓶颈、看懂 XLA/TVM 的优化日志,并亲手把模型训练和推理的时延降下来。

目录
  1. 可视化演示
  2. 算子融合概念
  3. XLA编译管线
  4. 融合分类方式
  5. 融合关键算法
  6. 性能优化特性
  7. 实践与挑战
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示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 个算子串联的计算链,分开执行时每个算子都要写一次中间结果、再被下一个算子读一次;而融合后,整个链路只读一次输入、写一次输出:

所以算子融合的本质,就是让中间结果“不落地”。总厨把切、焯、炒合成一锅出,省下的正是那一趟趟端盘子的时间。

Memseparate=∑i=1N2Si,Memfused=Sin+Sout\displaystyle \text{Mem}_{\text{separate}} = \sum_{i=1}^{N} 2S_i, \qquad \text{Mem}_{\text{fused}} = S_{\text{in}} + S_{\text{out}}
  1. Mem\_separate(分开执行的总访存量)N 个算子各自独立执行,每个算子的输出都要写回显存、供下一个算子读取。
    Memseparate\text{Mem}_{\text{separate}}
  2. 2S\_i(每个算子贡献的两次访存)第 i 个算子的输出尺寸为 S\_i,写一次、下一个算子读一次,合计 2S\_i。
    2Si2S_i
  3. 累加求和(总访存=每个算子访存之和)把 N 个算子的 2S\_i 全部加起来,就是分开执行在显存中搬运的数据总量。
    ∑i=1N2Si\sum_{i=1}^{N} 2S_i
  4. Mem\_fused(融合后只保留首尾两次)中间结果留在芯片内部的寄存器或共享内存里,不再写入显存,因此只统计输入和输出。
    Memfused=Sin+Sout\text{Mem}_{\text{fused}} = S_{\text{in}} + S_{\text{out}}

XLA编译管线

中央厨房接单后有一套完整流程:先由服务员把客人需求写成工单(前端捕获),再由总厨把工单整理成标准菜谱(HLO 图),接着总厨逐段审视菜谱、决定哪些步骤可以合并(优化 Pass),最后把定稿的菜谱交给炉头执行(后端代码生成)。XLA 编译器就是这座厨房的大脑。

XLA 的完整管线大致是:前端把 PyTorch、TensorFlow 等框架的计算图翻译成 HLO(高级优化中间表示);随后进入优化阶段,几十个 Pass 轮番对 HLO 图做变换,算子融合就发生在这里;优化后的 HLO 再次被翻译成 LLVM IR,最终由后端生成 GPU(PTX)或 TPU 的机器指令。

融合在图中就是一个“折叠”的操作:从一份 HLO 图里挑选 k 个节点,把它们替换成一个融合节点。

这条管线里,融合是唯一能“删边”的环节——总厨合并菜谱上的并列步骤,让整道菜的流程变短。

G′=Fuse(G,{v1,v2,…,vk})\displaystyle G' = \text{Fuse}\bigl(G, \{v_1, v_2, \ldots, v_k\}\bigr)
  1. G(原始计算图)编译前端产出的 HLO 图,节点是算子,边是数据依赖。
    GG
  2. \{v\_1, v\_2, …, v\_k\}(候选算子集合)被选中参与融合的一组算子节点,通常是数据流上相邻、形状匹配的算子。
    {v1,v2,…,vk}\{v_1, v_2, \ldots, v_k\}
  3. Fuse(融合变换)把 k 个节点折叠成一个融合节点,原节点间的中间边随之消失。
    Fuse\text{Fuse}
  4. G'(融合后的计算图)新图中融合节点内部是一个小型的子图,对外只暴露输入和输出,整体被编译成一个 kernel。
    G′G'

融合分类方式

厨房里的统筹有两种思路。第一种是横着批处理:十道菜都要用蒜末,那就一次剁一大碗,做菜时随手抓一把,不必每道菜重新剥蒜。第二种是纵着串流程:同一道菜,从洗、切到焯、炒一口气做完,中间不让半成品回冰箱。这两种思路,在编译器里分别叫横向融合和纵向融合。

  • 横向融合:把同层、互不依赖的算子合并。比如多个形状相同的 elementwise 算子(加、乘、激活)并行执行,各自启动一个小 kernel 太浪费,合成一个大 kernel 后,启动开销从 N 次变 1 次。
  • 纵向融合:把数据流上串联的算子合并。比如 Conv → BN → ReLU 是一条链,前一个算子的输出直接喂给后一个,合成一个核后中间结果不再访问显存。
  • 输入融合:把多个上游算子的计算结果合并后一次性交付给下游消费者,常用于减少重复读取。
  • 输出融合:把下游多个消费者的公共上游合并处理,同样是为了少搬几趟数据。

横向融合的特点是“并行批量”,纵向融合的特点是“串联到底”。实际模型中 Conv+BN+ReLU 是最经典的纵向融合案例:推理时 BN 的均值和方差已经固定,可以折叠成卷积的缩放系数,三段流水线瞬间变成一步到位。总厨心里那本账,就是看哪种统筹方式省得更多。

融合关键算法

中央厨房每天要面对成百上千道菜,总厨不可能把每两种食材的组合都试一遍。他会凭经验先找“相邻、且合并明显划算”的步骤下手:先并收益最大的,并完再看下一步。XLA 里的融合决策,用的正是这套思路——启发式搜索候选算子对,用代价模型评估收益,贪心迭代直到没有正收益为止。

具体的收益公式长这样:把两个算子 i 和 j 各自执行的代价相加,减去合并后一个核的代价,再归一化,得到融合带来的相对收益。

贪心策略会反复挑选 Gain 最大的相邻算子对执行融合,直到找不到正收益为止。同时,融合还要受峰值内存约束:如果融合核的中间结果需要在寄存器里同时存活,而数量超过了硬件寄存器上限,即使收益为正也会被否决。这就像总厨发现“这锅菜需要八个灶眼,但厨房只有四个”——再划算也得放弃。

Gain(i,j)=Ci+Cj−Ci⊕jCi+Cj\displaystyle \text{Gain}(i,j) = \frac{C_i + C_j - C_{i \oplus j}}{C_i + C_j}
  1. C\_i、C\_j(两个算子单独执行的代价)代价通常由计算时间和访存时间构成,访存受限的算子里,访存占大头。
    Ci+CjC_i + C_j
  2. C\_{i⊕j}(合并后一个核的代价)融合核只需要一次访存、一次计算,但寄存器压力也可能增大。
    Ci⊕jC_{i \oplus j}
  3. 分子(省下的代价)分开执行的总代价减去融合后的代价,数值越大说明越划算。
    Ci+Cj−Ci⊕jC_i + C_j - C_{i \oplus j}
  4. 分母归一化(相对收益)除以分开执行的代价,得到“省了百分之多少”,方便在不同候选对之间横向比较。
    Ci+Cj−Ci⊕jCi+Cj\frac{C_i + C_j - C_{i \oplus j}}{C_i + C_j}

性能优化特性

一锅出的好处不只是少洗几个碗。从执行的角度看,它至少带来三件好事:第一,访存次数骤降,半成品不再来回进出显存;第二,内核启动开销减少,GPU 不必为每个小算子付出调度和 launch 的固定成本;第三,融合核内部有机会做指令级并行编排,算子和算子之间的依赖变成寄存器间的直通数据,而不是等显存回包。

其中访存的收益最为关键。在访存受限(memory-bound)的场景下,算得快不快基本由“搬了多少数据”决定。假设 N 个算子的中间结果大小相近,都是 \bar{S},带宽为 B,那么:

灶台火力(带宽)越紧张,一锅出的优势就越明显。大模型训练和推理往往就是这种“火力不足”的厨房——显存带宽供不应求,算子融合因此成为最立竿见影的优化手段。

Speedup=2NSˉ/B2Sˉ/B=N\displaystyle \text{Speedup} = \frac{2N\bar{S}/B}{2\bar{S}/B} = N
  1. 分子(分开执行的总访存时间)N 个算子各自写一次、读一次中间结果,总搬运量 2N\bar{S},除以带宽 B 得到时间。
    2NSˉB\frac{2N\bar{S}}{B}
  2. 分母(融合后的访存时间)融合核只读一次输入、写一次输出,搬运量只有 2\bar{S}。
    2SˉB\frac{2\bar{S}}{B}
  3. 加速比(两者相除)访存受限时,融合的加速倍数约等于被融合的算子数量 N。
    2NSˉ/B2Sˉ/B\frac{2N\bar{S}/B}{2\bar{S}/B}
  4. 适用条件这个公式假设计算时间可以忽略,中间结果尺寸相近;如果计算本身很重(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 演示

python33 行
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()
目的:用“计算+访存”的简化模型直观对比融合前后的耗时差异。
  1. def kernel(compute_ms, mem_mb, bandwidth_mb_per_ms):一个 kernel 的耗时 = 计算时间 + 数据搬运时间
  2. busy_wait(compute_ms)用空循环模拟 GPU 实际算数的时间
  3. sum(kernel(compute[i], 2 * sizes[i], bandwidth) for i in range(n))分开执行时每个算子都写一次结果、下个算子再读一次,所以访存按 2 倍中间结果计算
  4. kernel(sum(compute), sizes[0] + sizes[-1], bandwidth)融合核把计算量合并成一次执行,访存只剩输入与输出
  5. sep_time / fused_time加速比展示访存受限场景下融合的收益

常见误区

误区总厨说把能合并的都合了肯定快,算子融合在任何情况下都会让模型变快,融合得越多性能越好。

正解融合省了访存,却可能增加寄存器压力。当融合核过大、中间结果超出片上存储时,会发生寄存器溢出,性能反而下降。XLA 的代价模型会在收益为负时拒绝融合,甚至在不划算时回退到分开执行。

为何常见:官方示例展示的融合加速动辄数倍,让人误以为融合是“包治百病”的万能优化。

误区一个巨型 kernel 肯定比几个小 kernel 优秀,最好把整个模型的所有算子合成一个。

正解融合核过大会导致寄存器占用过高、GPU 流处理器(SM)占用率暴跌,并行度被严重削弱。真实的编译器会通过代价模型平衡“减少访存”与“保持并行”,在两者之间寻找甜点。

为何常见:把“减少 kernel 启动次数”当成唯一指标,忽略了 GPU 是并行机器、需要足够并发来隐藏延迟。

误区我只要把模型写好,XLA 会自动完成所有融合,源码怎么写无所谓。

正解算子的表达方式直接影响融合机会。比如把 BatchNorm 的参数手动折叠进卷积权重,融合就更容易触发;把多个小算子拆得支离破碎,即使编译器再聪明也找不到可融合的 pattern。理解和写出“可融合模式”是工程优化的基本功。

为何常见:XLA 的自动化程度高,掩盖了源码结构对优化效果的影响,直到打开 profiling 才发现融合根本没发生。

误区反正都是把几个算子合并成一个大 kernel,分类没有意义。

正解横向融合合并的是同层并行、互不依赖的算子,主要省 kernel 启动开销;纵向融合合并的是数据流上串联依赖的算子,真正省去中间结果的访存。两者解决的问题完全不同,分别对应“批量备菜”和“一锅连续出菜”。

为何常见:只看“合并”这个结果,忽略了算子之间的关系才是决定融合方式的关键。

面试题

Q1. 算子融合最直接降低的开销是什么?

单选题 · easy

  • A.显存访问带宽← 正确答案
  • B.CPU 核心频率
  • C.硬盘读写速度
  • D.网络传输延迟

答案:A。融合核把中间结果留在片上寄存器,减少显存往返,因此直接受益的是显存访问带宽。CPU 主频、硬盘、网络与 GPU 内核访存无关。

Q2. Conv、BN、ReLU 依次串联时最合适用什么融合?

单选题 · easy

  • A.横向融合
  • B.纵向融合← 正确答案
  • C.输入融合
  • D.输出融合

答案:B。Conv、BN、ReLU 是同一数据流链路上的串行算子,纵向融合把它们合并成一个核。横向融合针对并行算子,输入/输出融合处理的是图边界。

Q3. 融合决策主要由什么机制来评估“值不值得”?

单选题 · mid

  • A.计算图节点数量
  • B.融合代价模型← 正确答案
  • C.训练数据集大小
  • D.GPU 核心主频

答案:B。融合的收益和风险由代价模型量化——省多少访存、占多少寄存器,收益为正才融合。计算图规模、训练数据、CPU 主频都不是决策依据。

Q4. 融合核过大会带来的典型副作用是什么?

单选题 · mid

  • A.计算精度降低
  • B.寄存器溢出变慢← 正确答案
  • C.显存泄漏风险
  • D.训练无法收敛

答案:B。融合核过大时,所有中间结果都想留在寄存器里,装不下就发生寄存器溢出,性能反而下降。编译器靠代价模型限制融合规模,与精度、泄漏、收敛无关。

Q5. 下列哪些情况常导致融合无法生效?

多选题 · mid

  • A.动态形状张量← 正确答案
  • B.中间结果超大← 正确答案
  • C.训练批次过大
  • D.跨设备算子调用← 正确答案

答案:ABD。动态形状使编译期无法确定访存布局,超大中间结果放不进片上存储,跨设备算子必须先通信再计算,这三类情况融合都难以生效。训练批次大小不影响融合决策。

Q6. XLA 中算子融合优化发生在哪个阶段?

单选题 · easy

  • A.前端图捕获阶段
  • B.HLO 优化阶段← 正确答案
  • C.后端指令调度
  • D.运行时任务调度

答案:B。XLA 流程是前端捕获转 HLO,随后优化 Pass 在 HLO 图上做融合,最后后端生成机器码。融合属于 HLO 优化阶段的图变换。

Q7. 关于横向融合与纵向融合描述正确的有哪些?

多选题 · hard

  • A.纵融合一数据流路径← 正确答案
  • B.横融合并行同层算子← 正确答案
  • C.横向可省中间结果
  • D.纵向合并无收益

答案:AB。纵向融合沿数据流合并链式算子,省中间结果访存;横向融合合并并行同层算子,减少启动开销。“横向省中间结果”“纵向无收益”的说法正好把两者搞反了。

Q8. 访存受限场景下融合 N 个算子,加速比理论上接近什么?

单选题 · mid

  • A.对数增长 logN
  • B.线性增长 N 倍← 正确答案
  • C.平方增长 N 平方
  • D.指数增长 2 的 N 次方

答案:B。访存受限时,分开执行约访存 2N·S,融合后约 2S,比值约为 N。计算受限时收益小于 N,但不会达到平方或指数级别。

Q9. 融合核相比分开执行有哪些执行特征?

多选题 · mid

  • A.中间结果不出显存← 正确答案
  • B.内核启动次数减少← 正确答案
  • C.计算精度必然提升
  • D.输入输出各访存一次← 正确答案

答案:ABD。融合核的特征是中间结果留片上、kernel 启动从 N 次变 1 次、显存只保留输入输出各一次访问。精度并不必然提升,浮点运算顺序还可能带来微小差异。

Q10. 动态形状下融合常被回退的原因是什么?

单选题 · hard

  • A.无法预编译最优核← 正确答案
  • B.动态形状计算准
  • C.融合后显存更小
  • D.与优化器不兼容

答案:A。动态形状使缓冲区大小、访存布局在编译期未知,无法预编译最优融合核,XLA 常回退到非融合路径或触发重新编译。这与计算精度、显存大小、优化器无关。