在城市的快递分拣中心,每天早晨都堆着几百个包裹。如果每个包裹都单独派一名快递员跑一趟,光穿背心、扫码出库、查路线就要花掉好几分钟——车还没发动,半天就耗在了“准备出发”上。聪明的站长定了一条规矩:去同一个小区、顺路同类的包裹,装进同一辆车,一趟送完。省下的不是路上时间,而是每一趟出发前那笔固定的“启动开销”。 AI 编译器里的水平融合,做的就是同样的事。深度学习模型的计算图里有几十上百个算子(operator),每个算子都会被编译成一个 kernel(GPU 上执行的一段程序)。每启动一个 kernel,CPU 都要向 GPU 下发一次指令,这笔开销与 kernel 里干多少活无关。水平融合把“具有相同输入”或“独立执行的同类算子”合并成一个 kernel,让 GPU 一趟车跑完原本三趟的活,既减少了启动次数,又提升了并行度。
理解水平融合,是看懂编译器图优化的一把钥匙。掌握它,你就能读懂推理框架的优化日志,也能在实际部署中通过配置融合选项显著压低延迟。
可视化演示
展示Transformer自注意力机制的QKV矩阵计算、注意力权重归一化及多头并行处理流程,步进切换查看注意力核心步骤。
本节配有交互动效(进入视口后自动加载,约 3KB)。
QKV计算:线性变换生成查询键值 输入序列经线性变换生成Query Key Value三个矩阵 Q用于查询K用于匹配V提供输出内容 Q=X*Wq, K=X*Wk, V=X*Wv QKV是注意力计算的基础
融合基本概念
快递站里最常听见的声音是站长喊“出车!”。每喊一声,快递员就要从椅子上站起来、穿反光背心、扫码装车、查路线——这一整套动作,无论包裹是一个还是一百个,时间一分不少。在 GPU 计算的世界里,“出车”就是一次 kernel 启动,而快递员的固定准备时间,就是启动开销。
深度学习模型在训练或推理前,会被编译器转换成一幅计算图:节点是算子(比如加法、ReLU、矩阵乘),边是张量流动。编译器接下来要回答一个关键问题:这些算子怎么翻译成 GPU 上真正执行的 kernel?朴素的做法是一个算子一个 kernel,但小算子越多,启动开销就越刺眼。
于是有了算子融合。按合并对象之间的关系,融合分成两类。垂直融合合并的是“生产者—消费者”关系的算子,a 算完把结果交给 b,相当于接力赛——快递员送完一段,下一位在中转站接力;经典案例是把 Conv 和 ReLU 合成一个 kernel,省掉中间结果的显存往返。水平融合合并的则是“相同输入”或“独立执行的同类算子”,相当于拼单——三个包裹都去同一栋楼,装一辆车走一趟。
所以水平融合的本质,是快递站的那条规矩:拼单的前提是“顺路又同类”,而不是不管三七二十一把所有包裹硬塞一辆车——至于什么算顺路,正是下一节要讲的匹配条件。
- F_horizontal 变换水平融合把一组算子映射成一个融合算子,如同快递站把多个包裹归并到一辆车。
- op_1 至 op_k被融合的 k 个算子,是拼单前的 k 个独立包裹。
- op_fused融合后生成的新算子,对应一次 kernel 启动,是那辆装满包裹的快递车。
- s.t. 约束条件s.t. 是“使得/满足”之意,要求算子间输入相同或类型相同,作为拼单合法性的判据。
水平匹配条件
老站长在墙上贴了一张拼单须知:去不同小区的不能拼,生鲜和普通件不能混装,超过车厢容积的不能硬塞。水平融合同样有一张“拼单须知”,每条都对应一个编译期可检查的匹配条件。
编译器在图优化阶段会逐个扫描算子对,判断它们能否水平融合。判断的依据集中在四个维度。第一,相同输入依赖:两个算子读同一个张量(例如 add 和 multiply 都读取 tensor A),融合后这个张量只需读一次,能省访存带宽。第二,算子类型一致:一般要求算子属于同一类别(如都是 element-wise,即逐元素运算的算子),同类算子的计算模式接近,才能在同一个 kernel 里用同一种线程布局处理;类型差太远(一个矩阵乘、一个 ReLU),融合后的 kernel 会变得复杂且难以优化。第三,形状兼容:输入输出张量的形状和内存布局一致,否则线程与数据的映射关系无法统一。第四,无副作用约束:算子不能有全局副作用(比如更新全局计数器、写外部文件),否则合并后执行次序改变会破坏语义。
拼单须知上的每一条,都能在图结构里“对号入座”地检查。只有四张通行证全部盖章的算子对,才配被装进同一辆快递车。
- fusable 谓词判断两个算子能否进行水平融合的函数,编译器在图优化阶段逐对调用。
- 输入相同或类型相同两个条件满足其一即可,这是拼单的“顺路”依据。
- 形状兼容记号 ~ 表示兼容,要求张量形状与内存布局一致,否则线程映射无法统一。
- 无副作用约束取反号表示“副作用必须为假”,保证融合前后程序语义不变。
合并调度策略
包裹装上车只是开始。到了小区,快递员是先送三号楼还是先送七号楼,走哪条路线能少爬楼梯,这才是真正决定一趟配送快慢的细节。水平融合后,新 kernel 内部同样要重新做一次“派送路线规划”。
融合完成不代表优化完成。合并后的 kernel 必须回答三个问题:谁来执行、执行多少、数据怎么读。典型的调度策略包含四点。单 kernel 统一调度:所有被融合的算子跑在同一次 kernel 启动里,由一个统一的 kernel 函数体处理,CPU 侧只发一次指令。线程块重划分:不再沿用单个算子的线程块配置,而是按合并后的总工作量重新计算。数据并行展开:把原本串行启动的独立计算展开到不同 SM(流式多处理器,GPU 的计算核心单元)上并行执行,这正是“提升并行度”的来源——一个算子可能填不满 GPU,k 个拼起来却能占满。访存复用优化:对相同输入只读取一次,把数据留在寄存器或共享内存里供多个算子复用。
一趟车出门,走哪条路、先爬哪栋楼,都是调度策略说了算。GPU 上那套线程块分配,就是快递员手里的派送地图——地图画得好,一车顶三车。
- N_blocks融合后 kernel 的线程块数量,决定 GPU 上的并行度。
- k × n 总工作量k 个独立算子各处理 n 个元素,合并后可视为 k×n 份独立工作。
- 向上取整符号⌈·⌉ 保证最后余下的元素也有线程承接,不会丢活。
- T_block每个线程块内的线程数,取 128 或 256 的倍数通常效率最高。
启动开销分析
快递老手的账本上,一趟配送的时间分成两块:出发前的准备时间和路上的派送时间。准备时间再短,它也是固定的——不管你这一趟装一个包裹还是十个。这就是“固定开销”最扎心的地方:它是叠加的。
GPU 编程里,CPU 每次启动一个 kernel,都要经过“准备参数→下发指令→GPU 队列排队→执行”的流程。即使现代框架做了异步提交,每一次启动的 CPU 侧指令开销仍然真实存在,通常在几十微秒量级。可别小看这几十微秒——当一个 element-wise 算子本身只算 10 微秒时,启动开销是计算时间的五倍。
关键在对比。假设融合前 k 个算子各自启动,总时间是 k 份“启动开销 + 计算时间”;水平融合后只启动一次,计算仍然要做(每个元素该算的逃不掉),但启动开销只剩一份。于是省下来的时间是 (k-1) 倍的启动开销。这个模型解释了为什么算子越小、融合收益越夸张。
账算明白了,站长就懂了:拼单拼的不是热闹,是“少付几遍出车费”。启动开销占比越高的活,越值得水平融合——这也是下一节要验证的 GPU 收益。
- L 启动开销单次 kernel 启动开销,像快递员每次出车的固定准备时间,与装货量无关。
- E 计算时间单个算子的计算时间,像路上真实的派送时间。
- k(L+E)融合前总时间,k 次启动付出 k 份启动开销,括号展开为 kL + kE。
- L + kE融合后总时间,只有一次启动,计算时间保持 kE 不变。
- ΔT 节省量两者相减,启动开销部分从 kL 缩成 L,净省 (k-1)L。
GPU性能收益
拼单试运行一个月后,站长拉出配送报表:车次少了 60%,每趟车满载率上去了,小区门口排队的电瓶车也少了。GPU 的 performance report 同样会告诉你:启动次数、SM 占用率、访存带宽——每一项都在讲同一个故事。
水平融合的收益在 GPU 上分为三个层次,对应三种资源:时间、计算单元、带宽。一是延迟收益:启动次数从 k 降到 1,CPU 侧下发指令的密集程度大幅下降,GPU 队列的空洞变少,端到端单次推理延迟降低。二是并行度收益:独立算子合并后,总工作量 k×n 可以展开到更多线程块,把原本闲置的 SM 填满;单个小算子常常只用到 GPU 的一小块算力,拼起来才能“满载发车”。三是访存收益:相同输入只读一次,省下的显存带宽在小算子场景下非常可观。
实际数据上,在包含大量 element-wise 算子的 Transformer 推理图中,水平融合通常能带来 10%~30% 的端到端加速;小算子密集的老模型上收益更明显。一个典型场景:某模型每层末尾有 4 个逐元素算子,单独启动每次计算约 8 微秒、启动开销约 50 微秒,融合后单层耗时从 232 微秒降到 82 微秒,接近三倍提速。
GPU 报表上的红箭头一翘,站长的拼单方案才算真正落地。但报表也提醒:不是所有算子都适合拼单——这就要说到最后的调优经验了。
- S 加速比融合前后总耗时之比,大于 1 表示融合更快。
- 分子 k(L+E)融合前总耗时,k 份启动开销叠加。
- 分母 L+kE融合后总耗时,只剩一份启动开销。
- 极限分析当 L 远大于 E 时,S 趋近于 k,说明小算子场景收益最大。
配置调优经验
拼单上瘾后,站长把鸡蛋、沙发、汽油桶都塞进一辆车——结果生鲜坏了、沙发蹭花了、小区还进了消防队。拼单有边界。优秀的站长会建立一套“拼单策略”:先看数据,再定规则,最后小范围试跑。
编译器同样内置了融合的“拼单策略”,用户可以在部署时通过配置项影响它。开源框架(如 TVM、TensorRT、ONNX Runtime)普遍支持融合开关与融合等级。调优经验可以总结为四步。打开融合开关:将编译优化等级调到 O2/O3,或显式开启 fusion 选项。依靠启发式规则:编译器默认做一轮启发式判断——启动开销占比高于阈值、算子规模小、类型匹配的算子对优先触发融合。用性能剖析验证:跑一遍 profiler(如 nsys、nvprof),看 kernel 耗时分布和启动次数,定位哪些小 kernel 值得融合。权衡副作用:留意融合后的寄存器压力与占用率变化,融合过度可能触发寄存器溢出(register spill,寄存器不够用被迫把数据存回显存),反而拖慢执行。实践中“小算子优先、大算子谨慎”是普遍心法。
站长的拼单策略最终变成墙上的一张流程图:看数据 → 定规则 → 试跑 → 复盘。水平融合的调优没有银弹,只有“剖析驱动”这一条踏实路——它把玄学变成了工程。
- Occupancy 占用率SM 上活跃 warp(一组 32 个线程的调度单位)数与硬件上限的比值。
- N_active warps当前 kernel 实际可并行的 warp 数。
- N_max warps per SM硬件允许的最大 warp 数,由 GPU 架构决定。
- 调优平衡点占用率太低说明 GPU 没吃饱,太高可能伴随寄存器压力,要取平衡。
AI 应用场景
推理引擎的图优化阶段
阶段:模型编译与图优化
解决的问题:部署阶段的推理图里挤满了逐元素的小算子(激活、残差加法、缩放),每个单独 kernel 的计算只有几微秒到几十微秒,而启动开销往往比计算还贵。传统做法是原样执行每个算子,CPU 频繁下发指令,GPU 经常空转等待。水平融合把顺路的同类算子合并成一次启动,让 CPU 少喊话、GPU 多干活,直接压低延迟。
具体案例:某团队在 T4 GPU 上部署 BERT 推理服务,每个 Transformer 层末尾有 3 个逐元素算子。用推理引擎的图优化开启水平融合后,这三个算子合并为一个 kernel,端到端 P99 延迟下降约 18%,吞吐提升 22%。
训练框架的算子合并
阶段:训练图编译(如 PyTorch 的图模式编译)
解决的问题:训练时前向与反向计算图同样包含大量逐元素算子,单独启动在数据并行规模大时浪费宝贵的 GPU 算力。水平融合让多个独立算子并发展开到更多线程块,提高 SM 占用率,同时把相同输入只读一次,缓解显存带宽压力。
具体案例:用 torch.compile 编译一个视觉模型,自动融合了 backbone 中多个独立的 ReLU 与加法算子,训练吞吐提升 8%~15%,显存峰值降低约 5%。
Python 演示
"""水平融合性能模拟:对比分离启动与融合启动的耗时""" class FusionSimulator: def __init__(self, launch_us=50, compute_us=30, parallel_gain=2.0): self.launch_us = launch_us # 单次 kernel 启动开销(微秒) self.compute_us = compute_us # 单个算子计算耗时(微秒) self.parallel_gain = parallel_gain # 融合后并行度提升系数 def run_separate(self, op_count): """融合前:k 个算子分别启动,每个都要付一份启动开销""" return op_count * (self.launch_us + self.compute_us) def run_fused(self, op_count): """水平融合:只启动一次,计算量因并行展开而摊薄""" return self.launch_us + self.compute_us * op_count / self.parallel_gain def report(self, op_count): t0 = self.run_separate(op_count) t1 = self.run_fused(op_count) print(f"算子数={op_count:2d} 分离={t0:6.1f}us 融合={t1:6.1f}us 加速比={t0 / t1:.2f}x") def main(): sim = FusionSimulator(launch_us=50, compute_us=30) for n in (2, 4, 8, 16): sim.report(n) if __name__ == "__main__": main()
self.launch_us = launch_us记录单次 kernel 启动的固定开销,相当于快递员每次出车的准备时间。def run_separate(self, op_count)模拟融合前:k 个算子各自启动,每次都支付一份启动开销。return op_count * (self.launch_us + self.compute_us)对应公式 T= k(L+E),k 次启动叠加。def run_fused(self, op_count)模拟水平融合:所有算子合并成一个 kernel,只启动一次。self.parallel_gain代表独立算子展开到更多 SM 带来的并行收益,是简化模型的核心假设。print(f"...")输出各算子数下的对比,加速比随 k 增大而上升。if __name__ == "__main__":可执行入口,保证模块既可导入也可直接运行。
常见误区
误区既然融合能省启动开销,那就把计算图里所有算子都合并成一个超大的 kernel,一步到位,肯定最快。
正解水平融合只合并“满足匹配条件”的算子:相同输入或独立同类、形状兼容、无副作用。乱拼单就像把生鲜和家具塞进同一辆车,绕路、超载反而更慢。融合过度会导致寄存器压力上升、占用率下降,甚至触发寄存器溢出(register spill),性能不升反降。编译器会做启发式判断,而不是无脑全融。
为何常见:把“算子融合”理解成单一操作,忽略了匹配条件和硬件资源约束,只看到启动开销这一个变量。
误区水平融合把好几个算子合并了,数学运算量肯定也变少了吧?模型应该会“算得快一倍”。
正解水平融合主要省的是 kernel 启动开销和部分额外访存,算子本身的数学计算量基本不变——每个元素该做的加法、激活一个都逃不掉。收益来自“少付 (k-1) 份出车费”和并行度提升,不是计算被约掉了。真正能省中间存储操作的,是垂直融合(如 Conv+ReLU 避免中间张量写回显存),两者收益来源不同。
为何常见:把水平融合和垂直融合的收益混为一谈,误以为“融合”就是“数学上合并同类项”。
误区编译器说这两个算子能融合,那就放心融合吧,反正不会比原来差。
正解可融合只代表“语义上安全”,不代表“性能上划算”。小 kernel 融合收益大;但本来就把 GPU 占满的大算子,合并后可能因调度灵活性降低、寄存器溢出而变慢。就像逢车必拼,有时等拼单的时间比单独发车还久。正确做法是融合后用 profiler 验证,用实测数据说话。
为何常见:忽略了融合后的线程块重划分、寄存器分配等二次开销,把“可融合”等同于“该融合”。
误区都是把算子合并,名字里都有“融合”,区别不大,随便记一个就行。
正解垂直融合(vertical fusion)合并的是有生产者—消费者关系的算子(a 算完,b 用 a 的结果),像接力赛,省的是中间张量的显存往返;水平融合合并的是相同输入或独立的同类算子,像拼单,省的是启动开销、提升并行度。两者依赖关系不同、匹配条件不同、收益模型也不同。
为何常见:只看到“融合”两个字的表面相似,没有深入分析算子之间的数据依赖关系。
面试题
Q1. 水平融合主要针对哪类算子进行合并?
- A.具有相同输入或独立同类算子← 正确答案
- B.存在生产者消费者级联算子
- C.包含复杂循环控制流态算子
- D.输入输出形状完全不同的算子
答案:A。水平融合合并相同输入或独立执行的同类算子,如同把顺路包裹拼一辆车;生产者—消费者关系由垂直融合处理,复杂控制流和形状不兼容的算子不满足融合条件。
Q2. 水平融合最主要的直接收益是什么?
- A.大量减少内核启动次数← 正确答案
- B.显著缩小模型的参数量
- C.直接降低训练数据的维度
- D.自动提升卷积核的尺寸
答案:A。水平融合把 k 次 kernel 启动合并为 1 次,直接收益是减少启动开销。它不改变模型参数量、不改变数据维度,也与卷积核尺寸无关,那些是模型结构层面的问题。
Q3. 以下哪项属于水平融合的匹配条件?
- A.算子类型一致且输入张量相同← 正确答案
- B.算子必须分属不同计算设备
- C.算子输出必须是单个标量值
- D.算子只能出现在循环体内部
答案:A。匹配条件包括相同输入依赖、算子类型一致、形状兼容、无副作用。分属不同设备、输出标量、必须出现在循环内都不属于水平融合的要求,甚至与融合目标相悖。
Q4. 水平融合后单个算子的数学计算量通常会怎样?
- A.基本不变省的是启动开销← 正确答案
- B.自动减少到原来的约一半
- C.翻倍增加以保证计算正确
- D.完全消失并合并成单个标量
答案:A。水平融合不改变每个算子本身的数学计算,该算的元素一个不少,省的是启动开销和部分多余访存。把计算量“减少一半”或“翻倍”都没有理论依据,是混淆了垂直融合的收益模型。
Q5. kernel 启动开销在 GPU 上主要来自哪里?
- A.CPU 向 GPU 下发指令的交互开销← 正确答案
- B.GPU 内部寄存器文件读写等待
- C.显存带宽不足引起的排队拥塞
- D.模型参数量过大导致的页面换入
答案:A。kernel 启动指 CPU 准备参数并向下发指令给 GPU 的过程,开销集中在 CPU—GPU 交互链路。寄存器读写等待、显存带宽拥塞都发生在 kernel 执行阶段,与启动动作无关。
Q6. 设单次启动开销为 L,单算子计算为 E,k 个算子水平融合后的加速比是?
- A.k(L+E)/(L+kE)← 正确答案
- B.(L+kE)/(k(L+E))
- C.(k-1)L/(kE)
- D.kL/(L+kE)
答案:A。融合前总耗时 k(L+E),融合后总耗时 L+kE,加速比是两者之比,即 A。B 是倒数,C 少了计算项,D 少了融合后的计算时间,都不符合启动开销模型。
Q7. 水平融合后的 kernel 应如何重新分配线程块?
- A.按合并后总工作量重新计算← 正确答案
- B.沿用第一个算子的线程块配置
- C.固定只使用一个线程块执行
- D.由用户逐核手工指定线程数
答案:A。融合后总工作量为 k×n,需要按新的总量重新划分线程块才能占满 SM。沿用旧配置会低估工作量,固定单块会浪费并行能力,手工逐核指定既不现实也不可移植。
Q8. 水平融合需要满足哪些匹配条件?
- A.算子拥有相同输入或独立执行← 正确答案
- B.算子类型一致或完全相互兼容← 正确答案
- C.张量形状和数据布局相互兼容← 正确答案
- D.算子必须输出相同的变量名称
答案:ABC。水平融合要求相同输入或独立执行、类型一致或兼容、形状布局兼容,且无副作用。变量名称是编译期符号,与融合正确性无关,属于干扰项。
Q9. 水平融合能带来哪些性能收益?
- A.减少内核启动带来的固定开销← 正确答案
- B.提升 GPU 并行度和资源利用率← 正确答案
- C.相同输入只读一次节省访存带宽← 正确答案
- D.减少模型训练所需的迭代轮数
答案:ABC。水平融合在时间、计算单元、带宽三个层面受益:省启动开销、填满 SM 提升并行度、复用输入省访存。训练轮数由优化算法和数据决定,与算子融合无关。
Q10. 关于水平融合的实战调优经验正确的是?
- A.先做性能剖析再决定是否融合← 正确答案
- B.关注融合后寄存器压力变化← 正确答案
- C.所有同类算子必须无条件融合
- D.用启发式规则控制融合的触发← 正确答案
答案:ABD。实战经验是剖析驱动:先看 profiler 定位小 kernel,再让启发式规则决定是否融合,并持续关注寄存器压力与占用率变化。无条件融合忽略了硬件资源约束,融合过度反而可能变慢。