设想你是快递网点的新人快递员。站长给你一张派送单:同一栋写字楼里,四楼王姐要收文件袋,五楼李哥要收信封,六楼赵小姐要收纸盒,七楼陈叔要收文件夹——四个包裹尺寸几乎一样,而且互不相关。站长规定:送完一个必须回网点取下一个。于是一个上午你来回跑了四趟,电动车大半时间耗在往返路上,真正送包裹的工夫没多少。老师傅看了直摇头:把四个包裹一次装进背包,一趟上楼全送完,剩下的时间还能再接新单。这个“一趟装完、批量派送”的思路,就是 AI 编译器里的水平融合(Horizontal Fusion)。 AI 模型在 GPU 上执行时,要像快递员一样逐个启动算子(operator/kernel)。当多个算子形状相同、互相独立时,逐个启动会浪费大量固定开销,GPU 算力被白白闲置。水平融合把这些同形状的独立算子合并为一个批量算子,一次启动批量执行,让 GPU 像满载的电动车一样被充分利用。这篇文章就带你拆解水平融合的原理、形状匹配、模式匹配算法、Elementwise 与 GEMM 的融合方式,以及 TVM 与 XLA 两大主流编译器的实现差异。

岗位关联

一句话价值:掌握水平融合,你就拿到了 AI 编译器“批量算子”优化的核心钥匙,这是读懂 TVM、XLA 性能调优的第一个关卡。

目录
  1. 可视化演示
  2. 融合基础概念
  3. 算子形状匹配
  4. 模式匹配算法
  5. Elementwise融合
  6. GEMM批量融合
  7. 编译实现差异
  8. 收益与挑战
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示水平融合三维度:融合原理(同层独立算子共享输入X如Q/K/V三次独立GEMM合并为单个BatchedGEMM 3个kernel→1个kernel减少启动开销)、MHA场景(Multi-Head Attention 4个head的Q/K/V投影共享输入batched GEMM一次执行12次GEMM→1次)、性能收益(kernel启动100%→33%降67%访存80%→40%数据复用提升GPU利用率大幅提升),三模式切换理解水平融合优化机制。

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

'+C1+'

融合基础概念

快递员小张第一天派送,来回跑了四趟被老师傅笑话。老师傅教他:把四个同尺寸包裹一次装车,一趟送完。省下的不只是三趟往返,还有每次出发前“穿鞋、启动电动车、规划路线”的固定准备时间——在编译器里,这个固定准备时间叫“内核启动开销”(kernel launch overhead)。

AI 模型的推理过程就像一个忙碌的派送日。GPU 要执行很多算子,其中经常出现一批“形状相同、互不依赖”的算子。比如图像预处理里的 relu(img)、add(bias)、mul(scale),它们各自处理独立的张量,形状完全一样,谁先谁后无所谓。传统编译器让 GPU 一个一个启动它们,每个算子都付一次启动开销。

水平融合的定义就是:把多个形状相同或相似的独立算子合并为批量执行的单个算子。注意它和垂直融合(vertical fusion)有本质区别:垂直融合合并的是一条数据链路上下游的算子,像把“打包→称重→贴单”合并成一个连续动作,省的是中间环节的等待和搬运;水平融合合并的是并行无关的算子,像把四个包裹捆成一捆,省的是“多跑几趟”的启动成本。这也解释了为什么水平融合特别适合提升 Elementwise 和 GEMM 这类算子的 GPU 利用率。

有了水平融合,GPU 这个“快递员”不再为每个小包裹单独出发,而是一趟装满。启动开销从 N 次摊薄到 1 次,计算单元也被填得更满——这正是水平融合被称为“填满 GPU 的智慧”的原因。

S=TunfusedTfused=N×(L+C)L+N×C\displaystyle S = \frac{T_{\text{unfused}}}{T_{\text{fused}}} = \frac{N \times (L + C)}{L + N \times C}
这个公式的通俗含义:融合前 N 个算子要付 N 次启动开销,融合后只付 1 次,省下的时间就是加速比。
  1. 先看融合前(分子)N 个算子各自独立启动并执行,每个算子都要支付一次启动开销 L,再花 C 做计算,所以总耗时是 N 份 (L+C) 相加。
    Tunfused=N×(L+C)T_{\text{unfused}} = N \times (L + C)
  2. 再看融合后(分母)水平融合把 N 个算子捆成一个批量算子,启动只需一次 L,然后批量完成 N 份计算,调度和等待被摊薄。
    Tfused=L+N×CT_{\text{fused}} = L + N \times C
  3. 两者相除得加速比用融合前耗时除以融合后耗时,S 越大说明融合收益越高,这就是“一趟送完”比“四趟单送”快多少倍的量化表达。
    S=N×(L+C)L+N×CS = \frac{N \times (L + C)}{L + N \times C}

算子形状匹配

快递网点深处有一面分拣墙,每个格子贴着尺寸标签:A4 信封格、A5 纸盒格、大件行李箱格。老师傅要求小张:只有同格的包裹才能捆在一起。为什么?因为尺寸不匹配的包裹捆起来,绳子绑不紧,运输中容易散。编译器里的“分拣墙”就是形状匹配。

算子处理的数据叫张量(Tensor),它的形状(shape)用维度和大小描述,比如一个 (256, 256) 的矩阵就像一个 256×256 的大托盘。水平融合的第一道关卡就是确认两个算子的输入输出形状是否匹配。最理想的情况是“完全相同”——两个 elementwise 算子的输入输出都是 (256, 256),就像两个完全一样的 A4 信封。

但现实比理想复杂。形状“相似”也可能融合:广播机制(Broadcasting)允许尺寸为 1 的维度自动拉伸。比如 (256, 1) 和 (256, 256) 两个张量做逐元素运算时,(256, 1) 会沿第二维复制成 (256, 256)。编译器在判断融合时,会检查两个算子的输出是否可以通过广播对齐。更高维度的张量则通过“维度折叠”把多维 shape 压成一维后比较。如果形状既不相等也不能广播对齐,强行融合就要插入 padding 或 reshape,增加额外计算量——精明的编译器会拒绝这种融合。

分拣墙的每一个格子,就是编译器里的一张形状表。只有当包裹尺寸对得上,快递员才敢把它们捆在一起——形状匹配,是水平融合的地基。

∀i:  dim⁡(A)i=dim⁡(B)i    ∨    dim⁡(A)i=1    ∨    dim⁡(B)i=1\displaystyle \forall i: \; \dim(A)_i = \dim(B)_i \;\; \lor \;\; \dim(A)_i = 1 \;\; \lor \;\; \dim(B)_i = 1
通俗理解:两个张量逐维比较,每一维要么尺寸相等,要么其中一方是 1(可以被广播拉伸),全部满足才算是“尺寸对得上”。
  1. 逐维比较对两个算子的输出张量 A 和 B,从第 1 维到最后一维逐一比较尺寸 dim(A)_i 与 dim(B)_i,完全相等是最理想的“同尺寸包裹”。
    dim⁡(A)i=dim⁡(B)i\dim(A)_i = \dim(B)_i
  2. 允许广播某一维上若一方为 1、另一方大于 1,小的一方可以广播拉伸对齐,如同小号箱子上加一块加厚垫片,也能与大箱捆在一起。
    dim⁡(A)i=1    ∨    dim⁡(B)i=1\dim(A)_i = 1 \;\; \lor \;\; \dim(B)_i = 1
  3. 全部维度满足即匹配当每一维都满足“相等或一方为 1”时,形状匹配成功,两个算子进入融合候选列表。
    ∀i:满足上述任一条件\forall i: \text{满足上述任一条件}

模式匹配算法

老师傅随身带着一本磨破边的捆扎手册,上面写着规矩:“同尺寸、同类型的包裹才能捆;目的地是同一栋楼的优先捆;捆数超过三件的要称重检查。”编译器里也有这么一本手册,名字叫“模式匹配规则”。

编译器在计算图上做模式匹配,寻找满足规则的算子集合。比如一条规则是:“连续出现的、输入输出形状相同的 elementwise 算子,可以水平融合。”编译器把计算图当成一张网,从任意节点出发,检查它和邻居节点是否命中规则。

具体流程分四步:第一,遍历计算图,通常用后序遍历(Post-order DFS),保证每个节点的输入先被处理;第二,对照规则表识别候选算子,例如同为 elementwise 且 shape 相同;第三,把候选算子聚成融合组;第四,用代价模型评估融合收益——如果启动开销节省大于融合引入的寄存器压力,就拍板融合。代价模型是这里的核心:它像一个精明的站长,会先称重再决定要不要捆。融合粒度由启发式搜索平衡:batch 太小省不了多少启动开销,batch 太大又可能压垮寄存器,需要在中间找甜点。

老师傅的手册越用越厚,编译器的规则表也在不断扩充。模式匹配算法决定了“哪些包裹能捆”,是水平融合的决策大脑。

F={v∈V∣op(v)∈Pfuse,  shapein(v)=shapeout(v)}\displaystyle F = \{ v \in V \mid \text{op}(v) \in P_{\text{fuse}},\; \text{shape}_{\text{in}}(v) = \text{shape}_{\text{out}}(v) \}
通俗理解:F 是“值得看一眼”的候选节点集合——既符合融合规则里的算子类型,又满足输入输出形状相同。
  1. 候选集合定义图中满足模式规则 P_fuse 的算子才可能被融合,P_fuse 好比快递站长的“可捆包裹清单”。
    op(v)∈Pfuse\text{op}(v) \in P_{\text{fuse}}
  2. 形状约束每个候选算子输入输出形状相同——这样它自己就在“原地”完成元素级操作,像信封不换包装就地贴多条标签。
    shapein(v)=shapeout(v)\text{shape}_{\text{in}}(v) = \text{shape}_{\text{out}}(v)
  3. 取交集成组既满足模式又满足形状条件的节点进入 F,编译器后续对 F 做代价评估,决定最终融合哪些节点。
    F={v∈V∣两者同时成立}F = \{ v \in V \mid \text{两者同时成立} \}

Elementwise融合

快递员一天要送最多的不是什么大件,而是信封和文件袋——轻、小、长得都一样。如果不把它们捆起来一批批送,一天得跑上百趟。GPU 遇到的 elementwise 算子就像这些信封。

Elementwise(逐元素)算子对张量的每个元素做同样的操作:ReLU 把所有负数归零,Add 把偏置加进去,Mul 做缩放。它们的共同特点是逻辑简单、计算量小,每个元素只是做一次算术。这种算子通常是“访存密集型”(memory-bound)——GPU 大部分时间不是在算,而是在等数据从显存搬进来、把结果写回去。

想象一趟运输:一个小算子就要把整个张量从显存搬到寄存器、算完再搬回去。如果 N 个 elementwise 算子各自独立启动,就相当于 N 趟满载率很低的运输。水平融合把 N 个同 shape 的 elementwise 算子合并为一个批量 elementwise 算子:数据只搬一次,N 份计算连续做完,启动次数从 N 降到 1。虽然搬运的字节总数没变,但启动等待和调度开销被抹平,显存带宽始终被占满。

小件攒多了就是大生意。Elementwise 的水平融合是编译器里最常见、收益最稳定的融合手段——因为小算子太多,省下的启动开销最为可观。

Tmem≈2×N×M×bBW\displaystyle T_{\text{mem}} \approx \frac{2 \times N \times M \times b}{\text{BW}}
通俗理解:每个算子要把 M 个元素读一遍、写一遍,N 个算子就是 N 趟往返;除以显存带宽就是访存耗时。融合不能减少字节数,但能让带宽不空转。
  1. 一次读写的字节数每个 elementwise 算子要把 M 个元素从显存读一遍、算完再写回一遍,一次往返是 2×M×b 字节(b 是每个元素占的字节数)。
    2×M×b2 \times M \times b
  2. 乘以算子个数N 个独立算子就是 N 趟这样的往返,总字节数随 N 线性增长。
    2×N×M×b2 \times N \times M \times b
  3. 除以带宽得时间显存带宽 BW 是传送速率,字节数越多耗时越长;水平融合通过减少启动等待让带宽一直被占满。
    Tmem≈2×N×M×bBWT_{\text{mem}} \approx \frac{2 \times N \times M \times b}{\text{BW}}

GEMM批量融合

快递网点不光有信封,也有大件:被子、行李箱、小家电。单个大件就很重,但有一个道理和信封一样——同尺寸的大件也能装同一辆车,一次送达。而且在装满之前,车的利用率是浪费的。

GEMM(通用矩阵乘法)是深度学习中最重要的大件算子。全连接层、attention 的 QKV 投影、卷积的 im2col 都靠它完成矩阵乘法。一个 GEMM 算子 \(C = A \times B\) 的计算量是 O(n³),属于计算密集(compute-bound)算子。当模型里有多个形状相同、互相独立的 GEMM 时——比如 Transformer 的多个 attention 头各自的投影,或 batch 中不同样本的独立矩阵乘——逐个启动就是一辆辆半空的车。

水平融合把它们合并为批量 GEMM(Batched GEMM):形如 \(C_i = A_i \times B_i\) 的一族矩阵乘法,通过一次 GPU 库调用完成。cuBLAS 提供 strided_batched_gemm 接口,接收 batch 维度上按固定步长排列的多个矩阵,一次 kernel 算完整个 batch。底层还会做矩阵分块(Tiling):把大矩阵切成小块分配给不同的流处理器(SM),让每个计算单元都满载,而不是让一部分闲着等数据。

大件虽然少,但每一辆空车浪费的都是大成本。GEMM 批量融合让 GPU 这辆卡车在送大件时也尽量装满——这是推理引擎性能调优的经典招式。

Ci=Ai×Bi,i=1,2,…,N\displaystyle C_i = A_i \times B_i, \quad i = 1, 2, \ldots, N
通俗理解:批量 GEMM 就是“同一个公式套 N 份不同的数据”,GPU 一次启动把 N 份矩阵乘法全部算完。
  1. 单次 GEMM每个矩阵乘法算出 C_i = A_i × B_i,这是线性代数中经典的矩阵乘法,计算量随矩阵维度立方增长。
    Ci=Ai×BiC_i = A_i \times B_i
  2. 批量下标下标 i 表示 batch 中第 i 个样本或 attention 头的矩阵乘法,各 A_i 形状相同,只是数据内容不同。
    i=1,2,…,Ni = 1, 2, \ldots, N
  3. 一起执行批量 GEMM 把 N 个独立的矩阵乘法打包给 GPU 一次执行,复用指令发射和内存搬运,等效“一辆车运 N 件大家具”。
    BatchedGEMM(A1..AN,B1..BN)⇒(C1,…,CN)\text{BatchedGEMM}(A_1..A_N, B_1..B_N) \Rightarrow (C_1, \ldots, C_N)

编译实现差异

同样是“把同尺寸包裹捆一起送”,顺丰和邮政的规矩不一样:顺丰按客户楼层精细分拣,邮政按包裹类型一刀切。AI 编译器的两家“快递公司”——TVM 和 XLA——对水平融合的实现也各有一套。

TVM 是一家做“图级”规划的快递公司。它在计算图(Graph)层面做模式匹配:用后序遍历遍历 Relay 图,每遇到一个节点就对照融合规则表,命中则将相邻算子合并为一个融合算子。TVM 的融合粒度通常比较小,规则驱动明显,开发者可以通过 FuseOps pass 或自定义 pattern 来调整融合行为。它对水平融合的处理侧重“同 shape 的 elementwise 聚类”,把零散的小算子归并成批量执行组。

XLA 则更像一家“指令级”规划的公司。它先把模型翻译成 HLO(High-Level Optimizer)指令序列,然后线性扫描这些指令,将 producer 和 consumer 关系紧密的指令融合为一个 fusion 指令。XLA 的融合更激进,允许复杂的融合拓扑,甚至会把几十个算子塞进一个 fusion,代价是编译时间显著增长。在水平融合这个具体赛道上,XLA 侧重“减少 kernel 总数,能融就融”,对 GEMM 的批量合并也更主动。

两家快递公司的分拣手册不同,但目标一致:少跑几趟、装满车厢。理解 TVM 和 XLA 的差异,能帮你在不同框架下定位性能问题。

PostOrder(v)=⟨PostOrder(c1), PostOrder(c2), …, PostOrder(ck), v⟩\displaystyle \text{PostOrder}(v) = \langle \text{PostOrder}(c_1),\, \text{PostOrder}(c_2),\, \ldots,\, \text{PostOrder}(c_k),\, v \rangle
通俗理解:这是 TVM 后序遍历的“访问清单”——先处理完一个节点的所有输入子节点,再处理它自己,保证融合判断时输入信息已就绪。
  1. 后序遍历定义对节点 v,先递归访问它的 k 个子节点 c_1 到 c_k,最后才访问 v 本身,这样每个算子的输入都先被处理过。
    ⟨PostOrder(c1),…,PostOrder(ck)⟩\langle \text{PostOrder}(c_1), \ldots, \text{PostOrder}(c_k) \rangle
  2. TVM 的用法在 Relay 图上做后序遍历,每遇到一个子节点就检查是否能与父节点融合,逐层向上合并成融合组。
    PostOrder(v)=⟨…,v⟩\text{PostOrder}(v) = \langle \ldots, v \rangle
  3. XLA 的差异XLA 不做递归遍历,而是线性扫描 HLO 指令序列,把连续的、符合条件的 producer/consumer 指令并入同一个 fusion 区域。
    Fusion(v1,v2,…,vn)⇒vfused\text{Fusion}(v_1, v_2, \ldots, v_n) \Rightarrow v_{\text{fused}}

收益与挑战

老师傅也会犹豫:四个包裹捆成一捆确实省事,可要是其中一个客户临时改地址,解绳子重新捆比单独跑一趟还麻烦。收益和风险,要算总账。

收益端很清晰:内核启动次数从 N 降到 1,启动开销摊薄;中间张量不必反复写回显存;GPU 利用率显著提升。挑战端则有几个隐患:第一,融合组过大时 batch 太大导致寄存器溢出,数据被挤到显存,性能反而回退;第二,动态形状下编译期无法确定 shape,匹配无从谈起;第三,融合增加编译时间;第四,融合后的算子难以逐点调试,定位问题像在密不透风的包裹堆里找一件小物件。

用公式看利用率能更直观。融合前每个算子的利用率为 C/(L+C),融合后整个 batch 的利用率为 N×C/(L+N×C)。当 N 增大时,融合后利用率趋近于 1——前提是 batch 没有突破硬件资源上限。这就是为什么所有成熟的编译器都带代价模型:收益大于成本才融合,否则保持原样。

快递公司最终的目标不是“每个包裹都捆”,而是“整体配送效率最高”。水平融合的哲学也一样:权衡之后,让 GPU 每一趟车都跑得值。

Ufused=N×CL+N×C\displaystyle U_{\text{fused}} = \frac{N \times C}{L + N \times C}
通俗理解:GPU 干活的时间占总共花的时间的比例。启动开销 L 被摊到 N 份计算上,N 越大,GPU 越是“一直在干活”。
  1. 有效计算占比融合后 GPU 花在真计算上的时间比例是 N×C 除以总耗时 L+N×C,这就是 GPU 利用率。
    Ufused=N×CL+N×CU_{\text{fused}} = \frac{N \times C}{L + N \times C}
  2. 启动开销被摊薄当 N 足够大时,L 在分母中占比越来越小,利用率趋近于 1。
    lim⁡N→∞Ufused=1\lim_{N \to \infty} U_{\text{fused}} = 1
  3. 与融合前对比融合前每个算子利用率只有 C/(L+C),同样 N 个算子整体利用率低得多;水平融合的本质是把固定成本 L 从 N 次摊成 1 次。
    Uunfused=CL+CU_{\text{unfused}} = \frac{C}{L + C}

AI 应用场景

Transformer多注意力头GEMM融合

阶段:推理引擎图优化 / 算子融合

解决的问题:Transformer 的每个注意力层有多个 head,Q/K/V 投影是多个形状相同、互相独立的 GEMM。如果逐个启动 kernel,启动开销占比高,GPU 计算单元大量闲置。水平融合把 N 个同 shape 的 GEMM 合并为一次 Batched GEMM 调用,让 GPU 满载运行。

具体案例:主流推理引擎(如 TensorRT)在优化 BERT 时,会把 12 个 attention head 的 QKV 投影水平融合为一次批量 GEMM,实测推理延迟可降低约 20%~30%;PyTorch 的 torch.bmm 正是批量 GEMM 的经典接口,底层会尽量走一次 kernel 调用。

图像预处理中的Elementwise融合

阶段:数据预处理流水线 / 图编译

解决的问题:图像预处理常包含归一化(减均值、除方差)、裁切、颜色空间转换等多个 elementwise 步骤,每步都是同 shape 的张量操作,在 GPU 上逐个执行会浪费内存带宽和启动开销。水平融合把它们合并为单个批量 elementwise kernel,数据只从显存读一次、写一次。

具体案例:NVIDIA DALI 和 OpenCV 的 GPU 流水线将归一化中的多个线性变换合并为一个 kernel,输入图像从显存到算完只经过一次往返,预处理吞吐提升明显。

动态图编译时的自动融合

阶段:JIT 编译 / 图优化

解决的问题:PyTorch 这类动态框架在运行时才拿到计算图,逐算子调用 GPU 会产生大量 kernel 启动开销。编译后端在 JIT 编译阶段自动识别可水平融合的同 shape 点级算子组,合并为单个 kernel,减少启动次数。

具体案例:PyTorch 2.x 的 TorchInductor 在图优化阶段会对连续的 pointwise 算子做融合 pass,把多个 elementwise 合并执行,训练和推理的 GPU 利用率都得到提升。

Python 演示

python90 行
"""水平融合(Horizontal Fusion)模拟器演示: 多个形状相同的独立算子如何被模式匹配识别、形状对齐, 并合并为单个批量算子, 从而降低内核启动开销。"""import random  class Operator:    """计算图中的一个算子"""     def __init__(self, name, op_type, shape):        self.name = name        self.op_type = op_type        self.shape = shape  class HorizontalFuser:    """水平融合器: 按(类型, 形状)分组并估算收益"""     def __init__(self, launch_cost=10.0, compute_cost=5.0):        self.launch_cost = launch_cost        self.compute_cost = compute_cost        self.shape_groups = {}   # (op_type, shape) -> [Operator]     def collect_by_shape(self, ops):        """模式匹配第一步: 遍历计算图, 按(类型, 形状)分桶"""        self.shape_groups = {}        for op in ops:            key = (op.op_type, op.shape)            if key not in self.shape_groups:                self.shape_groups[key] = []            self.shape_groups[key].append(op)     def find_fusable_groups(self, min_batch=2):        """找出可水平融合的分组: 同形状同类型且不少于min_batch个"""        fusable = {}        for key, group in self.shape_groups.items():            if len(group) >= min_batch:                fusable[key] = group        return fusable     def estimate_speedup(self, group):        """估算融合加速比: N次启动 vs 1次启动"""        n = len(group)        unfused = n * (self.launch_cost + self.compute_cost)        fused = self.launch_cost + n * self.compute_cost        return unfused / fused  def build_demo_graph():    """构造演示计算图: 4个同shape elementwise + 2个同shape GEMM"""    return [        Operator("relu_1", "elementwise", (256, 256)),        Operator("add_1", "elementwise", (256, 256)),        Operator("mul_1", "elementwise", (256, 256)),        Operator("sigmoid_1", "elementwise", (256, 256)),        Operator("gemm_1", "gemm", (128, 128)),        Operator("gemm_2", "gemm", (128, 128)),    ]  def main():    graph = build_demo_graph()    fuser = HorizontalFuser()    fuser.collect_by_shape(graph)     fusable = fuser.find_fusable_groups()    total_unfused = 0.0    total_fused = 0.0     print("=== 可水平融合的分组 ===")    for (op_type, shape), group in fusable.items():        names = ", ".join(op.name for op in group)        speedup = fuser.estimate_speedup(group)        unfused = len(group) * (fuser.launch_cost + fuser.compute_cost)        fused = fuser.launch_cost + len(group) * fuser.compute_cost        total_unfused += unfused        total_fused += fused        print(f"[{op_type}] shape={shape}: {names}")        print(f"  耗时 {unfused:.1f}ms -> {fused:.1f}ms, 加速 {speedup:.2f}x")     print("\n=== 汇总 ===")    print(f"融合前总耗时: {total_unfused:.1f}ms")    print(f"融合后总耗时: {total_fused:.1f}ms")    print(f"整体加速比: {total_unfused / total_fused:.2f}x")  if __name__ == "__main__":    main()
目的:模拟编译器对计算图做水平融合的全过程——按(类型,形状)分桶识别可融合分组,并量化“启动开销摊薄”带来的加速比。
  1. collect_by_shape遍历计算图,把同类型同形状的算子放进同一个桶,对应文章里的“分拣墙”。
  2. find_fusable_groups过滤出桶内算子数 ≥ 2 的分组,这些就是候选融合组。
  3. estimate_speedup用公式 N×(L+C)/(L+N×C) 估算加速比,N 越大、L 占比越高,收益越明显。
  4. f"耗时 {unfused:.1f}ms -> {fused:.1f}ms"以毫秒为单位展示融合前后的耗时对比,让收益一目了然。

常见误区

误区“水平融合既然能提升 GPU 利用率,那我把模型里能融合的算子全部融合成一个超级大算子,启动次数直接降到 1 次,性能肯定最好。”

正解融合不是无脑合并。超大 batch 可能超出 GPU 寄存器和共享内存容量,导致寄存器溢出(spill)到显存,性能反而回退。真实编译器都有代价模型:先估算融合后的寄存器压力、共享内存占用和访存模式,收益低于成本就放弃融合。就像快递车不是装得越满越好——超载反而开不动。

为何常见:“启动一次肯定比启动 N 次快”的直觉忽略了融合本身也有寄存器、内存、padding 的额外成本。

误区“水平融合就是把 ReLU→Add→Mul 这种一条链上的算子串起来一起执行,类似流水线合并。”

正解那叫垂直融合!垂直融合合并有数据依赖的算子:A 的输出直接传给 B,不把中间结果写回显存。水平融合合并的是互相独立、形状相同的算子:像 4 个不同的 elementwise 分别处理 4 个不同张量,合并后一次 kernel 批量处理。两个优化解决不同问题,一个省“中间搬运”,一个省“往返趟数”。

为何常见:都叫“融合”且都出现在编译器教程里,名字相近让人混淆。

误区“两个 GEMM 输入输出 shape 完全一样,编译器就应该把它们水平融合,不融合就是编译器笨。”

正解GEMM 的水平融合除 shape 外还要求内存布局兼容。cuBLAS 的 strided batch GEMM 要求每个子矩阵的 leading dimension、batch stride 一致;如果两个 GEMM 的数据在显存中排布不同,强行融合要先做 layout 转换(copy),额外开销可能抵消收益。编译器会评估后决定是否融合。

为何常见:只关注 shape 一个变量,忽略 GPU 矩阵库批量接口的布局约束。

误区“水平融合是 GPU 专属优化,CPU 上跑推理模型完全不需要考虑。”

正解CPU 同样存在函数调用开销和 SIMD 向量化利用率问题。像 oneDNN 等 CPU 算子库会把多个同 shape 的小算子合并成 JIT kernel,减少调度开销、提高向量寄存器利用率。只是 GPU 的并行架构让效果更显著,而 CPU 的收益容易被忽视。

为何常见:主流教程都以 GPU 的 kernel launch 为切入点,导致读者以为 CPU 没有此问题。

误区“水平融合是无本万利的优化,任何模型上开启都能稳定加速,应该默认全开。”

正解融合在编译期付出代价:模式匹配、代价模型计算、代码生成都会增加编译时间。XLA 激进的融合策略可能让编译时间从秒级涨到分钟级,对训练场景的频繁编译不友好;推理场景一次性编译则可接受。所以很多框架默认只在推理后端开启激进融合。

为何常见:只盯着运行时的收益,忽略了编译期的成本。

面试题

Q1. 水平融合的核心目标是什么?

单选题 · easy

  • A.减少矩阵中的无效乘法计算
  • B.合并同形状算子减少启动开销← 正确答案
  • C.把浮点运算换成整型运算
  • D.将模型权重压缩存储体积

答案:B。水平融合把多个形状相同、互相独立的算子合并为单个批量算子,核心收益是减少内核启动次数、提升 GPU 利用率。A 是降低计算量,C 是改变数据类型,D 是压缩存储,都不是水平融合要解决的问题。

Q2. 以下哪类算子组合最适合水平融合?

单选题 · easy

  • A.形状相同的多个独立ReLU← 正确答案
  • B.形状不同且串联的卷积算子
  • C.依赖上一个算子输出的归一化
  • D.输入形状各异的两个矩阵乘

答案:A。最适合水平融合的是形状相同且互不依赖的算子,如多个独立的 ReLU。B 和 D 形状不同,C 存在数据依赖,都不满足水平融合的两个前提:形状匹配 + 互相独立。

Q3. 关于水平融合,以下说法正确的是?

单选题 · mid

  • A.它合并的是有依赖关系的算子链路
  • B.它合并的是形状相同互相独立的算子← 正确答案
  • C.它只用于减少模型所占用的显存总量
  • D.它与垂直融合在目标上完全没有任何区别

答案:B。水平融合合并的是形状相同、互相独立的算子,目标是减少启动开销、提升 GPU 利用率。A 描述的其实是垂直融合,C 把融合目标错当成显存压缩,D 忽略了两种融合在对象和目标上的本质差异。

Q4. 两个算子shape分别为(256,1)和(256,256),以下说法正确的是?

单选题 · mid

  • A.可以融合通过广播机制对齐← 正确答案
  • B.不能融合形状必须完全一致
  • C.只能融合已完成的那个维度
  • D.需要先拷贝到CPU再融合

答案:A。广播机制允许尺寸为 1 的维度自动拉伸,(256,1) 沿第二维复制成 (256,256) 后可以与 (256,256) 逐元素运算,因此形状可通过广播对齐而匹配。B、C、D 都是对融合条件的错误理解。

Q5. 水平融合能够带来哪些收益?

多选题 · mid

  • A.提升GPU计算单元的填充率← 正确答案
  • B.完全消除模型计算的数据依赖
  • C.减少GPU内核的启动次数开销← 正确答案
  • D.保证减少模型参数量总和

答案:AC。水平融合把 N 个独立算子合并为批量算子,启动次数从 N 降到 1(C),GPU 计算单元被填得更满(A)。但融合不会消除数据依赖,数据依赖是模型语义决定的;也不会减少参数量,参数总和只与模型结构有关。

Q6. TVM实现水平融合的主要方式是?

单选题 · mid

  • A.通过自动微分反向传播隐式完成
  • B.在计算图上做模式匹配驱动融合← 正确答案
  • C.在HLO指令序列上线性扫描融合
  • D.只对卷积算子做手工特例优化

答案:B。TVM 在计算图(Relay 图)上做后序遍历和模式匹配,命中规则就把相邻算子合并为融合算子。C 描述的是 XLA 的做法,A 与自动微分无关,D 不是 TVM 的通用融合机制。

Q7. 关于XLA的融合策略,以下说法正确的有?

多选题 · hard

  • A.要求每个算子形状各不相同
  • B.在HLO指令级别进行融合操作← 正确答案
  • C.完全不做任何水平方向融合
  • D.融合粒度通常比TVM更加激进← 正确答案

答案:BD。XLA 先把模型翻译成 HLO 指令序列,再线性扫描指令并融合为 fusion 指令(B),且融合粒度通常比 TVM 更激进,允许复杂融合拓扑(D)。A 与形状匹配的要求相反,C 与事实不符。

Q8. 融合加速比公式 S = N×(L+C)/(L+N×C),以下理解正确的是?

单选题 · hard

  • A.N增大时S单调增大逐渐逼近上限← 正确答案
  • B.N增大时S单调减小最终趋近于零
  • C.S的大小与启动开销L完全无关
  • D.当N等于1时加速比取得最大值

答案:A。当 N 增大时 S 单调增大,极限为 (L+C)/C,即“启动开销全摊薄”后的理论上限。B 方向反了,C 忽略了 L 是加速比的关键来源,D 中 N=1 时 S=1 是最小值而非最大值。

Q9. 水平融合实践中的挑战有哪些?

多选题 · hard

  • A.动态形状下编译期匹配难以完成← 正确答案
  • B.所有模型推理精度都会大幅下降
  • C.批量过大导致寄存器溢出性能回退← 正确答案
  • D.融合会给编译过程增加额外时间← 正确答案

答案:ACD。水平融合的挑战包括:动态形状下编译期无法静态匹配(A)、融合组过大致寄存器溢出(C)、编译时间增加(D)。融合不改变算子的数学语义,不会造成精度下降(B 错误)。

Q10. Elementwise算子通常具有什么瓶颈特征?

单选题 · easy

  • A.属于访存密集型计算特征← 正确答案
  • B.属于计算密集型网络层
  • C.属于网络带宽受限型层
  • D.不受任何资源瓶颈限制

答案:A。Elementwise 算子对每个元素做相同操作,计算量小、访存量大,GPU 大部分时间在等数据搬运,是典型的访存密集型(memory-bound)。B 描述的是 GEMM 这类算子,C 的“网络带宽”与显存带宽不是一回事,D 显然不成立。