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 验证性能收益 输出融合后的计算图

这张图的逻辑其实很简单:先看算子类型是否相同,相同就水平融合;不同就看它们是否计算连续且没有数据依赖冲突,是就垂直融合;都不满足就别硬融。最后一定要用profiler验证一下,别凭感觉。

总结一下我的经验:

  • 水平融合适合"多路并行"的场景,比如多分支卷积
  • 垂直融合适合"流水线"场景,比如 Conv+BN+ReLU
  • 融合前一定要做性能分析,别盲目
  • 有时候不融合反而是最优解

好了,关于水平融合和垂直融合,今天就聊这么多。这两种策略是算子融合的基石,理解透了,后面讲更复杂的融合模式时你会觉得轻松很多。

可可精品资料库