清晨六点,小龙虾加工厂的车间主任老周正为一件事发愁。流水线上的三道工序——“去壳”“去虾线”“腌制入味”——分别由三个独立车间完成。每完成一道工序,工人们就得把半成品虾仁装箱,推进零下十八度的冷库暂存,下一道工序再取出来。一来一回,光是冷库进出的时间就占了大半天。老周琢磨:能不能让一位老师傅在同一张工作台前,把去壳、去虾线、腌制一次做完?半成品不离开台面,只在开始时从冷库拿一次原料、结束时放一次成品,中间不再折腾冷库。
这个念头,正是 AI 编译器里“算子融合”要做的事。深度学习模型里有大量逐元素运算(Elementwise),比如 x+1、x*2、x-3 这种对每个数字独立操作的步骤。如果每个运算都开一个独立的“车间”(内核),每步之间都要把中间结果存进“冷库”(全局内存),那 GPU 大部分时间都在搬运数据,而不是算数。Elementwise 融合把这些逐元素算子合并成一个内核,让数据一次性从内存取出、连续完成所有运算、再一次性写回。本篇文章就用这套“流水线改造”的逻辑,带你彻底看懂 Elementwise 融合的原理、匹配规则、广播合并、内核生成、内存优化、向量化执行与工程实践。
对 AI 从业者而言,掌握 Elementwise 融合是理解编译器如何加速模型的关键一环。它直接关系到推理引擎的延迟优化与 GPU/CPU 内存带宽的利用效率,是性能调优、部署落地时绕不开的底层能力。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
融合原理
老周在车间里反复看那三条流水线:去壳车间把虾仁装盒,冷藏车花二十分钟运到下个车间;第二个车间拆盒、去虾线、再装盒;第三个车间又拆盒、腌制、再装盒。每一次“装盒—运输—拆盒”都是对冷库的一次全局访问。老周想,如果三个车间变成一张台上连续完成三道工序,那么只需要开工前取一次原料、完工后存一次成品。这个“台面改造”就是算子融合的直觉起点。
在深度学习中,逐元素算子(Elementwise)指的是对张量中每个元素独立施加相同函数的运算,例如 add、mul、ReLU、tanh,它们的输入输出形状一致,且输出第 i 个元素只依赖输入第 i 个元素。这类算子天然适合融合。若不融合,每个算子都会启动一个独立 kernel,每个 kernel 都从全局内存读一次数据、计算、再写回一次。三个算子连用,数组就经历三次读取与三次写入;而全局内存带宽是 GPU 最宝贵的资源之一。
设数组长度为 $L$,算子个数为 $N$,每次全局访问的耗时系数为 $C_m$,每次 kernel 启动开销为 $C_l$,则未融合与融合的总时间分别是:
老周的改造方案在纸上算完,立刻让车间节省了两趟冷藏车的往返。这个从“三趟冷库”到“一趟冷库”的根本性变化,正是 Elementwise 融合最核心的动机:尽量避免中间结果落到全局内存。
- 未融合总时间$N$ 个 kernel 依次执行,每个 kernel 都要完整读一遍 $L$ 长度的数组、写一遍 $L$ 长度的数组,即 $2L$ 次内存访问,再加上一次 $C_l$ 的启动开销,所以是 $N \times (2L \cdot C_m + C_l)$。
- 融合后总时间只启动一个 kernel,它在开头读一次 $L$ 个元素、结尾写一次 $L$ 个元素,启动开销只剩一次 $C_l$,因此是 $2L \cdot C_m + C_l$。
- 加速比将两者相除,分子分母中的 $2L \cdot C_m$ 是公共的访存主体,在内存瓶颈场景下 $C_m \gg C_l$,比值趋近于 $N$。如果 $N=3$,就能获得近似 3 倍的访存时间节省。
匹配规则
不是所有工序都能放心地合并到同一张工作台。老周意识到:如果第二道工序“去虾线”需要把虾先冷藏十五分钟让肉质收紧,那它就不能与第一道工序无缝衔接。同样,编译器在决定两个逐元素算子能否融合时,也要做严格的“合法性检查”。判断一个算子是不是 Elementwise,核心看它是否满足“逐元素访问模式”:输出每个位置的值只依赖输入对应位置的值,不跨元素、不涉及相邻索引。卷积、池化这类涉及邻域窗口的算子就不满足条件。
编译器识别逐元素算子后,还会检查算子之间的数据依赖与形状是否匹配。若前一个算子输出形状与后一个算子输入形状完全一致,索引映射是一一对应的,就可以安全融合。若中间有出度大于一的“分叉”——比如一个结果同时被 exp 和 log 使用——则要视具体编译器策略决定是否融合,因为这会增加寄存器压力。融合条件可写成:
回到车间,老周在墙上贴了一张“可同台工序清单”:只有那些不依赖冷库暂存、不需要等待时间的工序才能合并。AI 编译器里的匹配规则,就是这张“可同台清单”的工程化表达。
- $\text{Elementwise}(A)$验证算子 A 的每个输出元素只与输入的同一下标元素有关,这是逐元素融合的入场券。
- $\text{Elementwise}(B)$验证算子 B 同样不跨元素访问,否则即使 A 是逐元素算子,B 的邻域读取也会破坏融合的单循环结构。
- 形状相等A 的输出形状必须与 B 的输入形状一致,保证索引 $i$ 的元素能无缝地从 A 传给 B,不需要额外的形状变换。
广播合并
老周遇到了新情况:第三车间的“腌制入味”工序,需要按“筐”投放调料。每 100 只虾一个筐,但有的批次一筐只有 50 只。如何让两个数量不同的批次在同一条工作台上同时加工?这就像两个形状不同的张量要做逐元素运算:一个形状是 [100],另一个是 [50]。直接逐元素对齐会失败,因为索引对应不上。
广播(Broadcast)机制解决了这个问题:从最右侧的维度开始依次对齐,凡是长度为 1 的维度,会像“复制”一样被拉伸到与另一个张量的对应维度相同。比如形状 [100, 1] 与 [1, 50] 做逐元素运算,前者在第二维复制 50 份,后者在第一维复制 100 份,最终得到 [100, 50]。融合时,编译器会把广播逻辑“折叠”进同一个循环体,通过控制索引映射避免真正在内存中复制数据。输出形状的推导规则是每个维度取参与张量的较大值:
老周最终定制了一种“可伸缩调料筐”:50 只的批次自动补 50 个空位,工作时长和 100 只的批次完全一致。广播合并就像这种可伸缩筐,让不同形状的张量也能在融合后的同一个循环体里顺利加工。
- 从右向左对齐维度编号 $d$ 从最右边开始比较,因为张量形状的语义从尾维度向头部延伸是深度学习中的约定。
- 逐维取最大输出形状的每个维度取参与运算两个张量在该维度的较大长度,这是广播的最终结果。
- 长度为 1 的拉伸如果某一维度长度为 1,它会被自动复制成另一张量该维度长度,从而实现“隐式复制”而不真的占用额外内存。
内核生成
老周把去壳、去虾线、腌制三道工序写在了一张工序卡上,老师傅拿到卡片后,从冷库取一次原料,在台前连续完成全部三道工序,最后把成品送进冷库。在 AI 编译器中,“这张工序卡”就是融合后生成的单个 kernel。编译器不再为每个算子分别生成独立的内核函数,而是把它们解析为同一个循环体中的多次连续操作。
这段循环合并代码的伪代码逻辑是:遍历输入数组的每个元素 $i$,依次执行第一个算子、第二个算子、第三个算子,把最终结果写入输出数组。中间值不离开寄存器,直接作为下一个算子的输入。生成的内核可以抽象成如下形式:
工序卡让老师傅的双手从未离开过台面,融合 kernel 则让数据从未离开过寄存器。这就是内核生成的本质:把多个 kernel 的“启动—读—写”循环,压缩成一个 kernel 的“启动—连续计算—写”流程。
- 循环范围 $[0, L)$融合后的循环只按输入长度 $L$ 执行一遍,不再为每个算子各执行一遍循环,循环次数直接减少为原来的 $1/N$。
- 函数嵌套 $f_3(f_2(f_1(x[i])))$依次执行三个算子,$f_1$ 的输出直接传给 $f_2$,$f_2$ 的输出直接传给 $f_3$,不需要中间数组。
- 最终写入 $y[i]$只有最后一个算子的输出会写入全局内存,其他中间结果都停留在寄存器中,从而消除全局内存的中间往返。
内存优化
老周算过一笔账:改造前,每道工序之间都要把半成品送进冷库,再取出来。一次冷库往返等于一次写入加一次读取。三道工序就有两次中途往返,虽然冷库本身容量很大,但抽开冷藏门、存取箱子的时间成本极高。Elementwise 融合的内存优化哲学如出一辙:尽可能减少“全局内存往返”的次数。
在未融合的执行流程中,如果数组长度为 $L$、算子个数为 $N$,那么每次算子的输出都要写回全局内存,下一次算子再读回,共发生 $N-1$ 次“写—读”中转。每次中转涉及 $2L$ 次内存访问。融合之后,这部分开销被完全消除:
融合还顺带提升了缓存局部性:单个 kernel 顺序读入一块数据后,在寄存器内完成所有运算,不会像未融合那样反复访问同一块全局内存区域。冷库的大门开关次数少了,整条生产线自然快得多。
- 每次中转的成本 $2L$一次中转意味着将长度为 $L$ 的数组写回全局内存($L$ 次写),下一算子再读回来($L$ 次读),合计 $2L$ 次访问。
- 中转次数 $N-1$$N$ 个算子之间只有 $N-1$ 个“缝隙”,每个缝隙都是一次中转。三个算子只有两个缝隙。
- 乘到一起总节省量是每次成本乘以中转次数,$N=3$、$L=100$ 万时,省下 400 万次内存访问,这正是内存带宽压力骤减的原因。
向量化执行
老师傅手上有一把“四联刀”:一次下去能同时处理四只虾的去壳。这就是 SIMD(单指令多数据)的通俗版——一条机器指令同时操作多个数据元素。融合之后的 kernel 循环体更加规整,编译器可以轻松地把循环向量化,用一条指令处理多个连续元素。
向量化的关键参数是向量宽度:一条 SIMD 指令同时处理的元素个数。它由硬件 SIMD 寄存器宽度除以单个元素的数据位宽决定。以常见的 128 位 SIMD 寄存器与 32 位浮点数为例,向量宽度就是 4。融合后的循环可以直接改写为“每 4 个元素一组”的批处理:
但向量化还有前提:数据地址必须对齐。如果数组起点没有按 16 字节对齐,SIMD 加载指令就无法一次取齐数据。因此融合 kernel 在生成时会尽量申请对齐的内存块,并为循环首尾添加“标量头尾处理”。如果说融合是把流水线合并成一条,那向量化就是给这条线配上满载的集装箱卡车。
- SIMD 寄存器宽度硬件中用于向量运算的寄存器总位数,例如 128 位或 256 位,代表一次能塞入的数据容量。
- 元素位宽单个数据元素占用的 bit 数,
float32是 32 位,int8是 8 位,位宽越小同一条指令能处理越多元素。 - 两者相除将寄存器总容量除以单个元素容量,得到一条指令并行处理的元素个数。融合让循环没有中间写回,编译器能够放心地做这种“成批向量化”变换。
工程实践
老周的流水线改造最终动工了。工程实践层面,Elementwise 融合已经是主流 AI 编译器与推理引擎的标配能力。TVM、XLA 以及 PyTorch 的 Inductor 后端,都有专门的融合 pass 负责识别可融合的逐元素算子序列并生成单一 kernel。深度学习模型的很多基础结构都会受益:残差连接中的 Add+ReLU、归一化中的 Mul+Add、激活前后的 Linear+ReLU 等等。
工程落地的效果评估需要回到时间模型。设单次内存访存时间为 $M$、单算子的计算时间为 $C$,未融合总时间为 $N(M+C)$,融合后为 $M+NC$。这是因为融合只保留了一次整体的读入与写回,但 $N$ 个算子依然要在每个元素上各算一次:
实际项目中,开发者往往通过 profiling 工具观察融合前后的内存吞吐与 kernel 启动次数来验证收益。老周的车间改造也并非万能——若原料本来就极少、冷库距离极近,融合的边际收益就很小。Elementwise 融合的适用场景应具备“长数组、多次逐元素运算、访存受限”这三个特征,这为工程取舍提供了简洁的判据。
- 未融合总时间 $N(M+C)$每个算子都完整执行一轮“读入内存块—计算—写回内存块”,$N$ 个算子重复 $N$ 次。
- 融合总时间 $M+NC$整块数组只读一次($M$),但 $N$ 个算子的计算在流水线上依次发生,总计算时间为 $NC$。
- 加速比公式两式相除。访存占比越高,$M$ 越大,分母越接近 $M$,比值越接近 $N$;反之若计算占比极高,融合收益则有限。
AI 应用场景
场景一:推理引擎的算子融合优化
阶段:模型推理优化
解决的问题:在深度学习推理引擎(如 ONNX Runtime、TensorRT)中,模型往往包含大量小而碎的逐元素算子。传统方式下每个算子单独启动一个 kernel,GPU 大部分时间浪费在 kernel 启动与全局内存往返上,吞吐率被内存带宽卡住。AI 编译器通过 Elementwise 融合将 Add → ReLU → Mul 合并为单个 kernel,显著降低访存压力与启动开销。
具体案例:在 ResNet 的残差分支中,Add(残差相加)后紧跟 ReLU 激活。未融合时,残差相加的中间结果写完显存再被 ReLU 读回;融合后,残差的每个元素加完立即激活,不落显存。实测中,融合后的残差块在 A100 上可减少约 30% 的 kernel 启动时间与约 20% 的显存带宽占用。
场景二:大模型 LayerNorm 的融合实现
阶段:算子开发与编译优化
解决的问题:大模型中的 LayerNorm 包含 求均值 → 求方差 → (x-mean)/sqrt(var+eps) → 缩放加偏置 等多个逐元素阶段。传统逐算子实现需要多次读回中间结果,内存访问量是理论下界的数倍。融合 LayerNorm 让每个元素在一次遍历内完成全部归一化运算。
具体案例:LLM 推理框架(如 vLLM、TensorRT-LLM)在实现 LayerNorm 时,通常手写或自动生成融合 kernel。输入一行数据读入后,先在线程块内归约求均值、方差,再在第二个循环中完成归一化与仿射变换,整行数据不需要回到全局内存。
场景三:移动端与边缘设备的功耗优化
阶段:端侧部署优化
解决的问题:手机、嵌入式设备的 GPU/NPU 内存带宽远低于服务器,kernel 启动开销占比更高。未融合的逐元素算子链在端侧会导致明显的延迟波动与功耗浪费。融合后,算子链变成一次内存遍历,既省电又提速。
具体案例:在移动端部署 MobileNet 时,Conv+BN+ReLU 中的 BN(批归一化)在推理期可折叠进卷积权重,而 ReLU 与后续逐元素算子融合成单个 kernel。端侧推理引擎(如 MNN、NCNN)通过这类融合,使得推理时延下降 40% 以上。
Python 演示
import time class Kernel: """A tiny simulation of a GPU kernel on a production line.""" def __init__(self, launch_cost=50.0, mem_cost=2.0): self.launch_cost = launch_cost self.mem_cost = mem_cost def run(self, reads, writes): """Total time = global memory access time + one launch.""" return (reads + writes) * self.mem_cost + self.launch_cost def main(): L = 1_000_000 kernel = Kernel() # Unfused: 3 kernels, each reads L and writes L once. unfused_time = 0.0 for _ in range(3): unfused_time += kernel.run(L, L) # Fused: 1 kernel still reads L and writes L once. fused_time = kernel.run(L, L) print("unfused time: {:.1f} us".format(unfused_time)) print("fused time: {:.1f} us".format(fused_time)) print("speedup: {:.2f}x".format(unfused_time / fused_time)) if __name__ == "__main__": main()
class Kernel:定义一个模拟 GPU 内核的类,用launch_cost表示启动开销,mem_cost表示每次全局内存访问的耗时。def run(self, reads, writes):计算一个内核执行的总时间:访问次数乘mem_cost,再加上一次启动开销。unfused_time += kernel.run(L, L)未融合方案中,3 个算子每个都要完整读一遍、写一遍数组,所以循环 3 次累加时间。fused_time = kernel.run(L, L)融合方案只需一个内核,读一次、写一次,启动开销也只付一次。print("speedup: {:.2f}x".format(...))输出加速比。当启动开销占比越高,融合的收益越明显。
常见误区
误区初学者往往认为两个逐元素算子放在一起就能无条件融合,反正每个元素都是独立计算的,怎么合都不会出错。
正解逐元素算子的确满足“输出只依赖同下标输入”,但融合还要求形状一致或可广播、数据依赖无环、访存模式连续。如果前一个算子的输出张量在后面的算子中还要被别的算子读取,或形状需要先经过转置等改变布局的操作,融合就会变得复杂甚至不可行。编译器需要逐项检查这些约束,而不是看到 Elementwise 就合并。
为何常见:因为教材上的融合例子往往是 Add+ReLU 这种理想情况,初学者容易把特例当作普适法则,忽略了算子之间的形状和依赖关系。
误区既然融合能把三次内核启动变成一次、把两次内存中转变成 0 次,那理论上应该获得接近 3 倍的加速,甚至更多。
正解加速比受限于内存与计算的比例。如果算子本身计算量极大、元素数量很少,内存访问不再是瓶颈,融合节省的时间有限。而且融合后的单个内核可能包含更多的寄存器压力,导致编译器不得不做溢出(spill),反而增加局部内存访问。实际加速比通常在 1.5 到 2.8 倍之间,需要结合 profiling 数据评估。
为何常见:人们只看到公式中访存项被消掉的理想情形,却忽略了公式成立的前提是内存瓶颈占主导。N 倍加速是最理想上界,不是现实承诺。
误区很多初学者理解广播时,以为编译器会把长度为 1 的维度在内存中真正复制成目标长度,从而产生额外的内存开销。
正解编译器在融合 kernel 中实现广播时,并不做物理复制。它只调整索引映射关系:对应维度长度为 1 时,所有输出索引都映射到同一个输入位置。比如 [100, 1] 与 [1, 50] 广播到 [100, 50],前者在第二维始终读取同一个元素,这只是一个“索引映射”层面的技巧,不产生真实的数据搬运。
为何常见:Numpy、PyTorch 的广播在用户视角中看起来像复制,用户没有深入底层执行机制,自然以为内存里真的存在多份拷贝。
误区有人以为融合后三个算子变成一个,中间值直接“消失”,不需要任何额外存储空间。
正解中间结果并没有消失,只是从全局内存“搬家”到了寄存器。在循环体内部,f1(x[i]) 的结果必须保留在某个寄存器里等待 f2 消费。如果算子链很长、寄存器不够用,编译器仍然要插入 spill 指令,把部分中间值写到局部内存。因此融合减少的是全局内存访问,而非消除所有中间状态。
为何常见:人们把“不写回全局内存”误解为“不占存储”,忽略了寄存器本身就是存储层级的一部分。
误区部分开发者以为融合一旦完成,编译器就会自动把循环向量化,性能问题全部解决。
正解向量化依赖数据地址的对齐与循环的连续访问特征。如果输入数组起始地址未按向量宽度对齐,SIMD 指令无法一次加载,编译器只能退化为标量循环或生成对齐/未对齐两套通路。工程上,融合 kernel 的内存分配需要显式对齐(如 posix_memalign 或 CUDA 的 cudaMalloc),并配合编译器 pragma 才能获得理想的向量化效果。
为何常见:现代编译器确实具备自动向量化能力,但这种自动优化有严格前置条件,并不总是能自动生效。
面试题
Q1. Elementwise 融合最核心的收益是什么?
- A.减少算子实现的代码量
- B.减少全局内存访问次数← 正确答案
- C.提升模型准确率
- D.减少训练数据的尺寸
答案:B。Elementwise 融合把多个逐元素算子合并为单个 kernel,最主要的收益是避免中间结果反复写入和读回全局内存,从而大幅减少内存访问次数。代码量、准确率、训练数据都与融合优化无关。
Q2. 融合后的中间结果主要存放在哪里?
- A.全局内存
- B.寄存器← 正确答案
- C.磁盘
- D.网络缓存
答案:B。融合 kernel 在一个循环体里依次执行多个算子,中间结果直接保存在寄存器中,传递给下一个算子使用。只有最终结果写回全局内存。如果寄存器溢出,才会落到局部内存,但那不是设计目标。
Q3. 以下哪种算子通常不适合与 ReLU 直接融合?
- A.Add
- B.Mul
- C.Conv2D← 正确答案
- D.Tanh
答案:C。Add、Mul、Tanh 都是逐元素算子,可以安全地与 ReLU 融合到同一循环体。Conv2D 涉及邻域窗口的数据访问,不是逐元素算子,直接融合会破坏单元素循环的规整性,需要单独的卷积优化策略。
Q4. 两个形状分别为 [3, 1] 和 [1, 4] 的张量做广播逐元素运算,输出形状是?
- A.
[3, 4]← 正确答案 - B.
[1, 1] - C.
[4, 3] - D.
[3, 1]
答案:A。广播从最右侧维度开始对齐,取每个维度的最大值。第一维 max(3,1)=3,第二维 max(1,4)=4,因此输出形状为 [3, 4]。[4, 3] 只是维度顺序不同,不对。
Q5. 向量化执行中的“向量宽度”取决于什么?
- A.数组长度和算子数量
- B.SIMD 寄存器宽度除以元素位宽← 正确答案
- C.全局内存的容量大小
- D.CPU 的核心数量
答案:B。向量宽度由硬件 SIMD 寄存器宽度(如 128 位)除以单个数据元素的位宽(如 32 位 float)得到,即一条指令可并行处理 4 个 float32。数组长度、内存容量、核心数都不直接影响单条指令的向量宽度。
Q6. 关于 Elementwise 融合,以下说法正确的有?
- A.融合后只启动一个 kernel← 正确答案
- B.融合后中间结果不写回全局内存← 正确答案
- C.融合一定使计算量减半
- D.融合可减少 kernel 启动开销← 正确答案
答案:ABD。融合将多个算子合并为一个 kernel,所以启动次数从 N 次降为 1 次;中间值在寄存器中流转,不经过全局内存;启动开销也随 kernel 数量减少而下降。但计算量(FLOPs)并没有减少,只是访存减少了,因此“计算量减半”错误。
Q7. 编译器判断两个逐元素算子可融合,不需要以下哪个条件?
- A.两者都是逐元素访问模式
- B.前一个算子输出形状等于后一个算子输入形状
- C.两个算子的权重参数完全相同← 正确答案
- D.数据依赖关系允许合并
答案:C。融合的准入条件是逐元素访问模式、形状匹配与依赖安全。算子是否共享权重参数与能否融合毫无关系,每个算子有自己的参数,融合后的 kernel 依然会分别使用各自参数完成计算。
Q8. 未融合时 N 个逐元素算子的总内存访问次数是(设数组长度 L)?
- A.2L
- B.NL
- C.2NL← 正确答案
- D.2N/L
答案:C。每个算子都要读一次 L 个元素、写一次 L 个元素,即 2L 次访问。N 个算子合计 2NL 次。融合后只剩一次读与一次写,共 2L 次,因此节省量为 2L(N-1)。
Q9. 哪些情况下融合的收益会明显缩小?
- A.数组长度 L 非常小← 正确答案
- B.算子计算量极大且访存占比低← 正确答案
- C.算子链中存在大量数据分叉← 正确答案
- D.全局内存带宽极高且启动开销极低← 正确答案
答案:ABCD。L 很小时访存总量本身可忽略;计算密集时融合只是省访存,对总耗时影响小;数据分叉会增加寄存器压力甚至 spill;若内存带宽极高、启动开销极低,融合前的瓶颈本就不明显。四种情况都会削弱融合的相对收益。
Q10. 融合后的循环结构通常是怎样的?
- A.每个算子各跑一遍完整循环
- B.一个循环内依次执行多个算子← 正确答案
- C.循环嵌套层数增加一倍
- D.完全不再需要循环
答案:B。融合 kernel 将多个算子的函数体合并到同一个循环中,遍历每个元素时连续执行多个算子。这正对应公式 for i in [0,L): y[i] = f3(f2(f1(x[i]))),循环次数从 N 遍降为 1 遍。