这家餐厅有一套严格的后厨流程:切菜师傅把土豆切丝,装进保鲜盒,贴上标签,送进冷库;炒菜师傅接到订单,先派帮工去冷库把那盒土豆丝取回来,再起锅开炒。炒完装盘,如果还有一道摆盘工序,又要再进一次冷库。整间后厨一半的力气都花在“装盒—入库—取盒”上,真正炒菜的时间反而很短。 AI 编译器里的“垂直融合”,就是来打破这套流程的:让切菜师傅和炒菜师傅站在同一个操作台前,切好的丝顺着砧板直接滑进锅里。对应到 GPU 计算,就是把计算图中前后相接的算子(比如逐元素加法 add 和激活函数 ReLU)合并成一个核函数(kernel),中间结果不再写回显存再读出来,而是留在寄存器里直接传给下一个算子。 这个优化直击现代芯片最贵的资源——内存带宽。显存的读写速度比芯片计算慢一到两个数量级,省掉中间张量的每次往返,都可能让整个模型快上好几倍。下面,我们就从这间后厨出发,把垂直融合的动机、原理、合法性、寄存器压力、性能账和编译器实现一次讲透。

岗位关联

掌握垂直融合,你就握住了 AI 编译器的第一把性能钥匙:能看懂内核启动与访存开销的来源,也能理解同一模型在不同推理框架里速度差异的根源。

目录
  1. 可视化演示
  2. 融合动机
  3. 工作原理
  4. 合法条件
  5. 寄存器分配
  6. 性能效果
  7. 编译器实现
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示垂直融合三维度:融合原理(未融合3个kernel间2次VRAM中间张量读写→融合后1个kernel寄存器传递零中间访问)、Conv-BN-ReLU经典模式(三算子串行依赖模式匹配融合为单个FusedConvBNReLU kernel)、融合收益(VRAM读写6次→2次计算强度FLOP/Byte从1提升至3倍带宽瓶颈消除),三模式切换理解编译器核心优化技术。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+C1+'

融合动机

前文说过,后厨里真正的炒菜时间只占一小段,绝大多数时间花在装盒、入库、取盒、解冻上。假设传送带(内存带宽)每秒只能送 100 份食材,而灶台每秒能炒 1000 份,那么整条流水线就被传送带卡死了。GPU 也是同样的道理:显存带宽远低于芯片的浮点计算速度。对加法、激活这类“每份食材只需轻轻一炒”的算子,计算本身眨眼就完成,反而是一直在等数据进出显存。

一个 kernel 在 GPU 上执行至少访问两次内存:读输入、写输出。当 add 和 relu 两个算子组成链条时,add 写出的中间张量先落回显存,relu 再把它读出来——这个中间张量就白白贡献了两次全量读写。更别提每次 kernel 启动还有启动开销:好比每道工序都要重新点火、重新叫帮工,单次不过几微秒,算子一多就积少成多。

对访存密集(memory-bound)的算子来说,总耗时近似等于访存时间:

所以后厨改革的第一动机不是换一口更快的锅,而是少跑腿。垂直融合解决的正是“跑腿”问题:让数据待在离计算最近的地方,别动不动就回冷库。

Tmem=MB\displaystyle T_{\text{mem}} = \frac{M}{B}
访存时间由“搬运总量 ÷ 带宽”决定:搬运的字节越少,等待就越短。
  1. M(搬运的总字节数)所有算子读写显存的数据量之和。每多一个中间张量,M 就多两次全量读写,像是多送一趟冷库。
    M=∑l=1L(Il+Ol)\displaystyle M = \sum_{l=1}^{L} \big( I_l + O_l \big)
  2. B(内存带宽)GPU 单位时间能从显存搬运多少字节,好比后厨传送带的运力。
    B  (GB/s)\displaystyle B \;(\text{GB/s})
  3. T_mem(访存耗时)搬完这些数据至少需要的时间。逐元素算子计算极快,T_mem 往往就决定了总耗时。
    Tmem=MB\displaystyle T_{\text{mem}} = \frac{M}{B}

工作原理

改革方案其实很朴素:把切菜台和炒锅搬到一起,甚至让同一位师傅左手切、右手炒。切好的土豆丝不装盒,顺砧板直接滑进锅;炒完出锅,同一只手顺势完成下一步。从“三道工序、三次入库”变成“一条龙、零入库”。

编译器做垂直融合,就是把计算图上串行的多个 kernel 合并成一个 kernel。合并后的 kernel 在同一个循环体里依次执行两个算子,前一个算子的输出保存在寄存器变量中,后一个算子立刻消费——这正是数据局部性(data locality)的胜利,尤其是时间局部性:刚算出来的数据马上用掉,不让它“冷却”到显存里。

写成伪代码,区别一目了然:

注意融合后循环体里的 t 就是一个寄存器变量,relu 立刻消费它,完全没有经过显存。编译器还会把整个循环按 tile 分块,让每个数据块在片上处理完再换下一块,进一步守住局部性。融合的本质,就是把“仓库中转”改成“手递手”。

text8 行
# 融合前:两次循环,中间张量两次进出显存for i in range(N): mid[i] = x[i] + y[i]for i in range(N): out[i] = relu(mid[i]) # 融合后:一次循环,中间值留在寄存器for i in range(N):    t = x[i] + y[i]    out[i] = relu(t)

合法条件

并不是所有工序都能并台。比如第二道工序其实在等酱料而不是土豆丝,你把两台并在一起也没用;又比如第一道切丝、第二道要块,规格对不上。还有的工序会“夹带私货”——炸鸡时顺便记一笔账、改动别的食材,那就不能随便和别人合并。

编译器判断能否融合,第一步是做数据依赖分析:确认算子 B 的输入恰好是算子 A 的输出,且 B 不依赖更早的其他算子;如果 A 的输出元素是由多个输入归约而来的(比如求和),B 还必须能逐元素对齐。第二步是检查张量形状与内存布局是否一致,或者能否由广播规则静态推导。第三步是确认算子没有副作用——不修改全局状态、不依赖随机种子这类不可控输入。

综合起来,合法融合需要满足四条:单一的生产者—消费者关系;形状与布局一致或可推导;算子语义确定且无副作用;融合后的计算结果与原图完全等价。编译器通常会先做拓扑排序得到依赖图,再用模式匹配挑选出可融合组(fusion group)。在厨房里,这就意味着要先看菜谱、对刀工规格、查额外动作,确认无误才把两台并到一起——合法性检查不是流程负担,而是防止“出锅的菜不是客人要的菜”。

寄存器分配

并台之后新的烦恼来了:切好的菜要先放在操作台上等油热,可操作台只有那么大——GPU 里一个线程的寄存器数量是固定的,通常只有几十个。同一时刻要用的半成品太多,台面放不下,只能把多出来的菜先放回门口货架,用的时候再取。这来回一趟虽比冷库近,但也要时间。厨师管这叫“临时挪货”,编译器管这叫溢出(spill)。

融合后的 kernel 在一个循环体里同时执行多个算子,多个中间值在同一时刻存活(live),寄存器压力(register pressure)随之上升。编译器要做寄存器分配,先通过生命周期分析确定每个中间值的存活区间,再尽量让存活区间不重叠的中间值复用同一个寄存器。比如 t = x[i] + y[i] 算出后,t 只在紧接着的 relu 里用到,存活区间极短,很快就能被下一个循环迭代的变量复用。

当同时存活的中间值超过可用寄存器数量时,编译器只能把部分变量溢出到栈上(本地内存)。这正是“融合链越长越好”的危险所在:链条越长,同时存活的中间值越多,溢出风险越大。因此,实际编译器在融合时往往会评估额外寄存器需求,必要时放弃融合或改用共享内存中转。好的后厨管理不是无限并台,而是在“少跑腿”和“台面够用”之间找到平衡。

性能效果

改革效果可以用后厨的一笔账算清:从前每道工序都要“从冷库取料、完工送回冷库”各一趟;并台之后,全流程只取一次料、上一次菜。省下的跑腿趟数,全是纯赚的时间。

设一条链上有 k 个逐元素算子,每个张量有 N 个元素,每个元素占 s 字节。融合前每个算子都要从显存读输入、把输出写回显存,一共 2k 次全量访问;融合后全局只读一次输入、写一次输出,只有 2 次访问。于是搬运总量从 2kNs 降到了 2Ns:

账算完,老板笑了:同一间厨房,食材搬运减半甚至减到四分之一,出菜自然更快。实测量也印证了这一点——对访存密集的网络(如残差网络、检测网络的前几层)做垂直融合,端到端提速 1.3 到 3 倍很常见。反过来,像大矩阵乘法这类计算密集层,计算时间已经占满,省掉的那点访存占比很小,融合收益自然有限。用行话说,垂直融合把计算访存比(每搬运一字节能做的浮点运算次数)抬高了 k 倍,把算子从“等数据”的带宽受限区推向“算得过来”的计算受限区。

Mfused=Munfusedk\displaystyle M_{\text{fused}} = \frac{M_{\text{unfused}}}{k}
融合后显存搬运量降为原来的 k 分之一:两个算子融合省一半,四个算子融合省四分之三。
  1. k(串联算子个数)同一条数据通路里串行执行的算子数。add→relu 链条里 k = 2,像是两道工序并台。
    k=2\displaystyle k = 2
  2. M_unfused(融合前总搬运量)每个算子都读一次输入、写一次输出,共 k 个算子,就是 2k 次全量访问;每次 N 个元素、每元素 s 字节。
    Munfused=2k×N×s\displaystyle M_{\text{unfused}} = 2k \times N \times s
  3. M_fused(融合后总搬运量)全局只读一次输入、只写一次输出,中间结果全留在寄存器里。
    Mfused=2×N×s\displaystyle M_{\text{fused}} = 2 \times N \times s
  4. 收益倍数两者相除,恰好是 k。
    MunfusedMfused=k\displaystyle \frac{M_{\text{unfused}}}{M_{\text{fused}}} = k

编译器实现

不同连锁餐厅的“厨房管理软件”对并台的支持很不一样。TVM 像一家讲究手工的精品餐厅:总管可以手工指定并台方案,也可以让自动搜索算法在上千种并台方式里挑最优;XLA 更像标准化的中央厨房:预先把常见菜谱做成模板,流水线一匹配就直接套用,稳定高效。

具体来说,TVM 提供算子调度 API,通过 fuse 这类调度原语把多个 compute 合并到同一个循环里;图优化阶段还有 FuseOps 这样的 pass 做自动融合,再结合自动调优(如 Ansor)同时搜索融合决策与分块大小。XLA 则把计算图表示为 HLO,对逐元素、广播等常见模式做模板化融合(fusion instruction),并且支持多输出融合,把 fan-out 出去的多个消费者一起包进同一个融合组。

两者的差异很有意思:TVM 偏向探索式,对任意算子的表达能力更自由,但搜索空间大、调优成本高;XLA 偏向规则匹配,融合模式稳定、易验证,但灵活性略逊。它们共通的地方在于:都先在图上做合法性分析,再决定融合方案,最后交给后端处理寄存器分配和代码生成。就像同一道土豆丝,精品店手工改刀、中央厨房模板化批量切——殊途同归,目标都是让菜别回冷库。

AI 应用场景

自动驾驶推理引擎的延迟优化

阶段:模型编译与推理优化

解决的问题:自动驾驶要求毫秒级完成目标检测,模型里 ReLU、残差加、归一化这类逐元素算子会产生大量中间张量。传统方式每层独立 kernel 执行,显存往返严重拖慢延迟;垂直融合让数据在寄存器里“手递手”,能砍掉 30% 以上的端到端延迟。

具体案例:某团队将 YOLO 检测网络中 Conv+BN+ReLU 三段融合,在嵌入式 GPU 上端到端推理从 35ms 降到 22ms,顺利满足实时性红线。

大模型训练的显存与吞吐优化

阶段:训练加速与显存优化

解决的问题:训练中 cast、scale、bias 等逐元素算子产生的中间张量抬高显存峰值,导致 batch 上不去。融合后中间张量不再整块驻留显存,腾出的空间可以装更大的 batch,直接提升训练吞吐。

具体案例:在混合精度训练中,把 float32 转 bf16 的 cast 与后续归一化算子融合,省下一整张中间量的显存占用,同一张卡上的 batch size 提升了约 20%。

手机端低功耗部署

阶段:端侧推理部署

解决的问题:手机和边缘设备的 DRAM 带宽小、功耗预算紧张,访存越多越慢越费电。垂直融合降低 DRAM 访问次数,相当于同时优化了延迟和功耗两本账。

具体案例:某手机端实时超分模型在融合多个逐元素算子后,整机功耗下降 18%,帧率提升 25%,发热明显缓解。

Python 演示

python49 行
import numpy as np  def run_unfused(x, y):    n = len(x)    load = 0    store = 0    mid = np.empty(n)    for i in range(n):                # kernel 1: add        load += 2                      # 读 x[i] 和 y[i]        mid[i] = x[i] + y[i]    store += n                         # 写中间张量 mid    out = np.empty(n)    for i in range(n):                 # kernel 2: relu        load += 1                      # 读 mid[i]        out[i] = mid[i] if mid[i] > 0 else 0    store += n                         # 写输出 out    return out, load, store  def run_fused(x, y):    n = len(x)    load = 0    store = 0    out = np.empty(n)    for i in range(n):                 # 单一 kernel: add + relu        load += 2                      # 只读 x[i] 和 y[i]        h = x[i] + y[i]                # 中间值留在寄存器        out[i] = h if h > 0 else 0     # 立刻消费, 不再读中间量    store += n                         # 只写输出 out    return out, load, store  def main():    rng = np.random.default_rng(0)    x = rng.standard_normal(1000)    y = rng.standard_normal(1000)     out_a, la, sa = run_unfused(x, y)    out_b, lb, sb = run_fused(x, y)     assert np.allclose(out_a, out_b)   # 两种方式结果完全一致    print('未融合: load=%d store=%d 合计=%d' % (la, sa, la + sa))    print('融合后: load=%d store=%d 合计=%d' % (lb, sb, lb + sb))    print('内存访问降低倍数: %.1fx' % ((la + sa) / (lb + sb)))  if __name__ == '__main__':    main()
目的:用两个 Python 函数分别模拟“add+relu 未融合”和“垂直融合”两种执行方式,统计各自的内存读写次数,验证融合恰好把访存降为一半。
  1. mid = np.empty(n)未融合时需要显式创建中间张量,这就是“半成品要先装盒”。
  2. h = x[i] + y[i]加法结果直接留在“寄存器”变量 h 里,不落显存。
  3. out[i] = h if h > 0 else 0紧接着做 relu,中间值零往返。
  4. assert np.allclose(out_a, out_b)验证融合前后数学结果完全一致,合法性第一关。
  5. (la + sa) / (lb + sb)算出内存访问的降幅,理论账(2 倍)和代码账对上。

常见误区

误区我觉得垂直融合很简单,把 add 和 relu 的循环代码抄到一个函数里,结果一样就完事了,编译器不过是个“代码合并工具”。

正解编译器必须在计算图上做严格的数据依赖分析、形状与布局校验、副作用检查,确认“B 只消费 A 的输出”且全程语义等价,才会真正融合。好比两位厨师并台前,总厨要先核对菜谱:第二道工序确实只等这道半成品,刀工规格一致,中途没有偷偷改别的食材。代码拼接只是表象,合法性分析才是融合能安全上线的根基。

为何常见:从“结果等价”的角度看,手写合并确实能算出相同结果,容易让人忽略编译器层面那些看不见的静态检查。

误区既然融合能省访存,那就把所有能串起来的算子全部融进一个 kernel,链条越长性能越好。

正解融合收益主要花在访存密集的算子组合上。对计算密集的大矩阵乘,计算时间已经占满,省掉的访存占比很小;而且链条过长会让同时存活的中间值超过寄存器容量,触发溢出到栈,多出来的访存反而拖慢性能。操作台就那么大,硬塞十道菜的半成品,只能频繁挪货。

为何常见:入门教程都用 add+ReLU 这种“融合即大赚”的例子,容易把特例推广成“永远有效”。

误区融合之后中间张量就不占任何内存了,显存占用也会清零。

正解中间结果不会再整块写回显存,但它在寄存器或片上存储里仍有短暂的生存期,会占据寄存器资源;寄存器不够时还会 spill 到栈,产生少量本地内存读写。“消除中间张量”指的是消除显存往返,而不是消除计算过程中的一切临时数据。

为何常见:教材常画“中间张量被删掉”的简化图,读者容易把图上画的那一笔删除,理解成物理上的彻底消失。

误区垂直融合只能用于 element-wise 算子,卷积、归约、矩阵乘都没法融合。

正解逐元素只是最典型、最好讲的情形。conv+BN、matmul+激活、conv+add 等组合在生产中被广泛融合,只是编译器需要额外处理归约缓冲、广播对齐、跨线程边界等细节。只要依赖与形状能对齐、语义等价,垂直融合的舞台远比“逐元素”三个字大得多。

为何常见:入门材料几乎都用 add+relu 举例,让读者误以为这就是全部适用范围。

面试题

Q1. 垂直融合中的“垂直”指的是什么?

单选题 · easy

  • A.计算图拓扑上串行相连的算子← 正确答案
  • B.并行执行的不同分支算子
  • C.同一算子内的多线程任务
  • D.跨设备传输数据的通道

答案:A。垂直指数据流方向上首尾相接:A 的输出恰好是 B 的输入,像前后两道工序。并行分支是“水平”融合的领域,同一算子多线程是细粒度并行,跨设备传输属于通信优化,都不在垂直融合的范畴。

Q2. 下列哪组算子最适合做垂直融合?

单选题 · easy

  • A.两个全连接层的矩阵乘法
  • B.逐元素加法和 ReLU 激活← 正确答案
  • C.数据加载与模型保存操作
  • D.两个不同设备间的通信原语

答案:B。逐元素算子访存密集、形状一致,融合收益最大。两个矩阵乘之间必须等完整的计算结果,无法逐元素串起来;数据加载、模型保存和跨设备通信都不属于同一张计算图里可融合的算子链。

Q3. 垂直融合消除的主要开销是什么?

单选题 · mid

  • A.算子自身的计算延迟
  • B.中间张量的显存读写← 正确答案
  • C.网络权重参数的存储
  • D.模型训练的优化器开销

答案:B。融合消除的是中间张量“写回显存再读回”的两次往返,计算本身一分没少。权重存储和优化器开销属于模型内存与训练层面的问题,与融合无关。

Q4. 融合后,中间结果通常保存在哪里?

单选题 · mid

  • A.显存中的全局缓冲
  • B.核函数内的寄存器← 正确答案
  • C.磁盘上的内存映射
  • D.独立的消息队列

答案:B。融合后的循环体里,前一个算子的输出直接留在寄存器中供下一个算子消费,这正是“切完菜直接下锅”。如果放在显存全局缓冲,那就等于没有融合。

Q5. 判断两个算子能否垂直融合,需要满足哪些条件?

多选题 · mid

  • A.算子间存在直接数据依赖← 正确答案
  • B.张量形状与布局一致← 正确答案
  • C.算子语义是确定无副作用的← 正确答案
  • D.中间结果必须写回显存再读取

答案:ABC。融合要求 B 的输入就是 A 的输出,形状布局能对齐,且算子无副作用。D 恰恰是融合要消灭的行为,作为条件显然是错的。

Q6. 对 k 个逐元素算子串联,融合后内存访问量变为原来的多少?

单选题 · mid

  • A.二分之一
  • B.k 分之一← 正确答案
  • C.两倍
  • D.不变

答案:B。融合前每个算子一读一写,共 2k 次全量访问;融合后全局只读一次输入、写一次输出,共 2 次。两者相除正好是 k,所以两个算子融合省一半,链条越长省得越多。

Q7. 计算访存比(每字节搬运对应的浮点运算数)对融合决策的意义是?

单选题 · hard

  • A.比率越低的算子越值得融合← 正确答案
  • B.比率越高的算子越值得融合
  • C.比率与融合决策完全无关
  • D.比率只影响量化精度

答案:A。计算访存比低说明算子是带宽受限(访存密集),省访存的收益大;比率高说明计算已占满时间,融合收益很小。逐元素算子比率极低,所以是垂直融合最典型的受益对象。

Q8. 融合链过长可能导致哪些问题?

多选题 · hard

  • A.寄存器压力过大触发溢出← 正确答案
  • B.模型参数量明显增加
  • C.核函数过大影响指令缓存← 正确答案
  • D.自动调优搜索空间膨胀← 正确答案

答案:ACD。同时存活的中间值变多会挤压寄存器并触发溢出,核函数代码变长会影响指令缓存命中,自动调优的搜索空间也会随融合选项膨胀。融合不改变算子的权重数量,所以 B 是干扰项。

Q9. 关于垂直融合中“中间张量”的说法,正确的是?

单选题 · easy

  • A.融合后中间张量不再整块读写显存← 正确答案
  • B.融合后中间张量完全不存在了
  • C.中间张量只存在于磁盘交换区
  • D.中间张量与融合优化无关

答案:A。融合后中间结果以局部变量形式短暂活在寄存器里,不再整块回显存。说“完全不存在”是错的,临时存留仍有生存期和管理成本,只是量级天差地别。

Q10. 关于主流编译器的垂直融合实现,下列说法正确的是?

多选题 · extreme

  • A.TVM 支持算子的手工与自动融合← 正确答案
  • B.XLA 对逐元素模式做模板化融合← 正确答案
  • C.所有编译器都采用同一套融合算法
  • D.融合策略会影响中间结果的生存期← 正确答案

答案:ABD。TVM 提供 fuse 调度原语并可自动搜索融合方案,XLA 用规则模板重写 HLO 图,两者策略不同。融合会改变中间值的存活区间,进而影响寄存器分配,所以 D 也正确。C 显然不符合各家编译器百花齐放的现实。