4、水平融合与垂直融合:融合策略的选择艺术
算子融合这件事,说白了就是让计算更紧凑。我刚开始做模型部署时,总觉得一个个算子单独跑也没啥问题。直到有一次在嵌入式设备上跑一个轻量级模型,发现大部分时间都花在了数据搬运上,而不是真正的计算。嗯,那时候我才真正理解——融合不是炫技,是刚需。
今天咱们聊聊两种最基础的融合方式:水平融合和垂直融合。这两种策略,几乎覆盖了90%以上的融合场景。
水平融合:同类型算子合并
什么叫水平融合?说白了,就是把多个相同类型的算子,合并成一个更大的算子。比如连续好几个卷积层,或者连续好几个ReLU层,它们之间没有其他类型的操作干扰。
我举个例子你就明白了:
# 融合前:三个独立的卷积
conv1 = Conv2d(3, 64, 3)
conv2 = Conv2d(3, 64, 3)
conv3 = Conv2d(3, 64, 3)
# 融合后:一个更大的卷积
conv_fused = Conv2d(3, 192, 3) # 输出通道合并
你想想看,三个卷积各自独立计算,每个都要从显存读权重、读输入,写输出。融合之后,一次读取,一次计算,一次写入。显存带宽的压力瞬间就下来了。
核心收益:
- 减少内核启动次数(GPU上这个开销不小)
- 提高数据复用率(权重只需要加载一次)
- 减少显存读写(中间结果不用落盘)
我在项目中遇到过这样一个场景:一个检测模型里有12个并行的3x3卷积,分别处理不同尺度的特征。刚开始一个个跑,GPU利用率只有30%左右。水平融合之后,利用率直接飙到75%以上。效果立竿见影。
垂直融合:连续算子合并
垂直融合就更有意思了。它把前后相连的不同类型算子,合并成一个复合算子。最常见的例子就是 Conv + BN + ReLU 的融合。
为什么会这样?因为这三个操作在计算上是连续的,而且BN和ReLU的计算量相对较小,但每次都要读写中间结果。融合之后,中间结果直接留在寄存器里,不用来回倒腾。
# 融合前
x = Conv2d(x, weight, bias)
x = BatchNorm2d(x, gamma, beta)
x = ReLU(x)
# 融合后:一个算子搞定
x = ConvBNReLU(x, weight, bias, gamma, beta)
我个人习惯:在写模型时,尽量把 Conv + BN + ReLU 写成一个自定义层。这样不仅推理时方便融合,训练时也更容易维护。算是一种"设计时就考虑部署"的思路吧。
垂直融合的收益主要来自:
- 消除中间张量的显存读写
- 减少内核启动次数
- 利用计算局部性(数据在缓存里就被消费掉了)
融合策略怎么选?
这个问题没有标准答案。我踩过不少坑,总结了几条经验:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 多个同类型算子并行 | 水平融合 | 减少内核启动,提高并行度 |
| 前后算子类型不同但计算连续 | 垂直融合 | 消除中间结果,减少访存 |
| 混合场景(既有并行又有串行) | 先水平再垂直 | 先合并同类项,再串联 |
| 算子计算量差异巨大 | 谨慎融合 | 小算子融合到大算子中可能得不偿失 |
我曾经踩过的坑:有一次把一个大卷积和一个小池化层做了垂直融合。结果因为池化的计算模式太特殊,导致卷积的并行度被破坏,性能反而下降了15%。所以融合不是越多越好,得看具体硬件和算子特性。
我个人建议的做法是:先做一轮自动分析,看看哪些算子适合融合。然后手动验证几个关键融合点。最后用profiler跑一遍,确认收益。别一上来就全盘融合,容易翻车。
融合策略的决策流程
下面这张图是我自己总结的融合决策流程,你可以参考一下:
这张图的逻辑其实很简单:先看算子类型是否相同,相同就水平融合;不同就看它们是否计算连续且没有数据依赖冲突,是就垂直融合;都不满足就别硬融。最后一定要用profiler验证一下,别凭感觉。
总结一下我的经验:
- 水平融合适合"多路并行"的场景,比如多分支卷积
- 垂直融合适合"流水线"场景,比如 Conv+BN+ReLU
- 融合前一定要做性能分析,别盲目
- 有时候不融合反而是最优解
好了,关于水平融合和垂直融合,今天就聊这么多。这两种策略是算子融合的基石,理解透了,后面讲更复杂的融合模式时你会觉得轻松很多。