水平融合与垂直融合:算子的合纵连横
说到算子融合,我脑子里第一个蹦出来的画面,就是当年在某个推理项目里,看着计算图里密密麻麻的小算子,头皮发麻。那时候我就意识到,不搞融合,性能根本没法看。
今天咱们聊聊两种最核心的融合策略:水平融合和垂直融合。说白了,一个是在同一层把兄弟算子合并,另一个是在流水线上把上下游算子串起来。
水平融合:同类算子的「抱团取暖」
水平融合,也叫同类型算子合并。你想想看,如果计算图里有好几个并行的卷积,或者好几个并行的全连接,它们互不依赖,那为什么还要分开算?
我个人的习惯是,遇到这种场景,直接把它们合并成一个更大的算子。这样做的好处很明显:
- 减少内核启动开销:每个算子启动都要调用一次CUDA kernel,合并后一次搞定
- 提高数据局部性:合并后的算子可以复用缓存中的数据
- 减少显存带宽占用:中间结果不用写回显存
举个例子,假设你有四个并行的卷积层,输入都是同一张特征图:
// 融合前
conv1(input, weight1, bias1) -> output1
conv2(input, weight2, bias2) -> output2
conv3(input, weight3, bias3) -> output3
conv4(input, weight4, bias4) -> output4
// 融合后
fused_conv(input, [weight1, weight2, weight3, weight4],
[bias1, bias2, bias3, bias4]) -> [output1, output2, output3, output4]
我在项目中遇到过一种情况,某个模型里有32个并行的1x1卷积,每个卷积的输入输出通道数都一样。一开始没做水平融合,推理速度慢得离谱。后来我把它们合并成一个batch卷积,性能直接提升了3倍多。
核心要点:水平融合的关键在于「同类型」和「并行」。如果算子类型不同,或者有数据依赖关系,那就别硬融了。
垂直融合:流水线上的「接力赛」
垂直融合就更有意思了。它把计算图中前后相连的算子合并成一个,形成一个流水线式的计算单元。
最常见的例子就是 Conv + BN + ReLU 的融合。你想想看,这三个算子在推理时是串行执行的:先卷积,再批归一化,最后激活。每个算子都要读写一次显存,这中间的开销有多大?
我曾经在一个项目里做过统计,Conv + BN + ReLU 这三个算子,如果分开执行,显存带宽的浪费能达到40%以上。融合之后,所有计算都在寄存器里完成,中间结果根本不用写回显存。
垂直融合的数学原理其实很简单:
// 原始计算
y = Conv(x, w, b)
z = BN(y, gamma, beta, mean, var)
out = ReLU(z)
// 融合后的计算
// 将BN的参数融合到卷积的weight和bias中
w' = w * gamma / sqrt(var + eps)
b' = (b - mean) * gamma / sqrt(var + eps) + beta
out = ReLU(Conv(x, w', b'))
嗯,这里要注意,BN在推理时是线性变换,所以可以安全地融合到卷积里。但如果是训练阶段,BN的统计量会变化,那就不能这么干了。
小技巧:我建议在做垂直融合时,先画一张计算图,把每个算子的计算量和访存量标出来。这样你就能一眼看出哪些算子适合融合,哪些不适合。
融合策略选择:什么时候该用哪种?
这个问题其实没有标准答案。我个人的经验是,根据计算图的拓扑结构和硬件特性来决定。
下面这张表是我自己总结的,你可以参考一下:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 多个同类型并行算子 | 水平融合 | 减少内核启动次数,提高并行度 |
| 前后相连的算子 | 垂直融合 | 减少中间结果读写,降低带宽压力 |
| 计算密集 + 访存密集混合 | 优先垂直融合 | 计算和访存可以重叠,提高利用率 |
| 小算子过多 | 水平融合 + 垂直融合 | 先水平合并同类项,再垂直串联 |
| 大算子 + 小算子 | 谨慎融合 | 大算子计算时间长,小算子可能被阻塞 |
说实话,融合策略的选择没有银弹。我曾经在一个项目里,把所有能融合的算子都融了,结果性能反而下降了。后来一分析,发现是因为融合后的算子太大,寄存器不够用,导致寄存器溢出,性能反而更差。
避坑指南:我曾经犯过一个错误,把 Conv + BN + ReLU 融合后,又强行把后面的 Pooling 也融进去。结果因为 Pooling 的访存模式跟卷积不一样,导致缓存命中率暴跌。所以,融合不是越多越好,要适可而止。
融合策略的决策流程
为了让你更直观地理解,我画了一张流程图,展示了我平时做融合决策的步骤:
这个流程看起来简单,但实际做的时候,你还要考虑很多细节。比如,融合后的算子会不会导致寄存器压力过大?会不会影响缓存命中率?这些都需要通过性能分析工具来验证。
实战中的融合策略选择
我记得有一次,我在优化一个目标检测模型。模型里有大量的 1x1 卷积和 3x3 卷积,它们交替出现。一开始我打算做垂直融合,把 Conv + BN + ReLU 都融了。但后来发现,1x1 卷积和 3x3 卷积的计算密度不一样,融合后反而导致负载不均衡。
后来我换了个思路:先把所有 1x1 卷积做水平融合,再把所有 3x3 卷积做水平融合,最后再把融合后的结果做垂直融合。这样既减少了内核启动次数,又保持了计算负载的均衡。
说白了,融合策略的选择,本质上是一个权衡的过程。你要在计算效率、访存效率、并行度之间找到一个平衡点。
总结一下我的经验:
- 小模型、小算子:优先做水平融合,减少内核启动开销
- 大模型、大算子:优先做垂直融合,减少中间结果读写
- 混合场景:先水平再垂直,或者先垂直再水平,具体看计算图结构
- 遇到性能瓶颈:用 profiler 分析,看是计算瓶颈还是访存瓶颈,再决定融合策略
嗯,关于水平融合和垂直融合,今天就聊这么多。这两种策略是算子融合的基础,掌握了它们,你就能应对大部分推理引擎的优化场景了。