融合策略基础:水平融合与垂直融合
算子融合这事儿,说白了就是「把多个小操作打包成一个」。但怎么打包?往哪个方向打包?这里头有讲究。
我个人习惯把融合策略分成两大类:水平融合和垂直融合。这两个概念,你理解透了,后面讲自动搜索、代价模型什么的,就顺了。
水平融合:同层兄弟的合并
水平融合,也叫「同层融合」。它处理的是同一计算图深度上的多个算子。
举个例子,你有一个输入张量,同时要经过 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,每次做融合前过一遍。
这张图里,我特意把水平融合和垂直融合的适用场景放在左侧。你对照着看,就能明白什么时候该用哪种策略。
总结一下:水平融合省带宽,垂直融合省启动。粒度选择看寄存器,触发条件看依赖。这四个点,是算子融合的基石。
好了,这一章的内容就这些。下一章我们聊聊更进阶的话题——自动融合搜索。不过那是后话了,先把基础打牢。