融合策略基础:水平融合与垂直融合

算子融合这事儿,说白了就是「把多个小操作打包成一个」。但怎么打包?往哪个方向打包?这里头有讲究。

我个人习惯把融合策略分成两大类:水平融合和垂直融合。这两个概念,你理解透了,后面讲自动搜索、代价模型什么的,就顺了。

水平融合:同层兄弟的合并

水平融合,也叫「同层融合」。它处理的是同一计算图深度上的多个算子。

举个例子,你有一个输入张量,同时要经过 ReLU、Sigmoid、Tanh 三个激活函数,然后结果再拼起来。传统做法是三个算子分别算,各读一次数据,各写一次结果。

水平融合怎么做?把这三个激活函数合并成一个「多路激活」算子。一次读取,三次计算,一次写入。

核心收益:减少内存访问次数。数据从全局内存读一次,比读三次快太多了。

我在项目中遇到过这样一个场景:BERT 模型里的 LayerNorm 后面跟了一堆残差连接和 Dropout。这些操作都是 element-wise 的,数据复用性极高。水平融合后,训练速度提升了 15% 左右。

垂直融合:流水线上的工序合并

垂直融合,也叫「跨层融合」。它把计算图不同深度上的算子串起来。

最经典的例子:Conv + BN + ReLU 融合。这三个操作在推理时几乎必做融合,训练时稍微复杂点,但也能做。

为什么会这样?因为 Conv 的输出直接喂给 BN,BN 的输出直接喂给 ReLU。中间没有分支,没有其他消费者。这种「直来直去」的结构,就是垂直融合的最佳候选。

我的经验:垂直融合的收益往往比水平融合更大。因为它不仅省了内存读写,还省了 kernel launch 的开销。GPU 启动一个 kernel 大概有几十微秒的延迟,融合掉一个就省一次。

融合的粒度选择:粗还是细?

这里有个 trade-off。融合粒度太粗,一个 kernel 里塞太多操作,寄存器压力大,反而变慢。融合粒度太细,融合了个寂寞,收益不明显。

我一般遵循几个原则:

  • 计算密集型算子(如 Conv、MatMul):不要和太多小算子融合,容易把计算打散
  • 访存密集型算子(如 ReLU、Add、Mul):能融尽融,反正瓶颈在带宽
  • 混合场景:看 profiling 结果,哪个 kernel 耗时最长,优先优化它

举个例子,ResNet-50 里有个瓶颈结构:1x1 Conv → 3x3 Conv → 1x1 Conv。中间夹着 BN 和 ReLU。我曾经尝试把整个路径融合成一个 kernel,结果寄存器爆了,性能反而下降。后来拆成两个:前半段和后半段分别融合,效果就好了。

避坑指南:我曾经在融合 Conv+BN+ReLU 时,忘了考虑训练阶段的 BN 需要保存中间统计量。结果融合后的 kernel 没法正确计算梯度,模型训练直接崩了。嗯,从那以后我每次做融合,都会先画清楚前向和反向的计算图。

融合的触发条件:什么时候该融?

不是所有算子都值得融合。我总结了几条触发条件:

条件 说明 优先级
数据依赖紧密 一个算子的输出是另一个算子的唯一输入 高
计算模式相似 都是 element-wise,或者都是 reduction 中
访存模式匹配 数据布局一致,不需要额外转置 高
kernel launch 开销占比高 小算子太多,启动时间超过计算时间 中
寄存器压力可控 融合后寄存器使用不超过硬件限制 高

你想想看,如果一个 Conv 后面跟了五个小算子,每个小算子都要单独 launch 一次 kernel。这五次启动的开销,可能比实际计算还大。这种场景,融合的收益就非常明显。

反过来,如果两个算子都是计算密集型的,比如两个大矩阵乘法,强行融合反而会让每个矩阵乘都跑不满 GPU 的算力。得不偿失。

一张图看懂融合策略

下面这张图,是我自己画的一个融合策略决策流程。你可以把它当成一个 checklist,每次做融合前过一遍。

算子融合策略决策流程 候选算子对 数据依赖是否紧密? 计算模式是否相似? 访存模式是否匹配? 寄存器压力是否可控? 执行融合 放弃融合 任一条件不满足,建议放弃融合或调整融合策略 水平融合 同层兄弟算子合并 减少内存访问次数 适合 element-wise 操作 垂直融合 跨层流水线合并 减少 kernel launch 适合 Conv+BN+ReLU

这张图里,我特意把水平融合和垂直融合的适用场景放在左侧。你对照着看,就能明白什么时候该用哪种策略。

总结一下:水平融合省带宽,垂直融合省启动。粒度选择看寄存器,触发条件看依赖。这四个点,是算子融合的基石。

好了,这一章的内容就这些。下一章我们聊聊更进阶的话题——自动融合搜索。不过那是后话了,先把基础打牢。

可可精品资料库