水平融合与垂直融合:算子的合纵连横

说到算子融合,我脑子里第一个蹦出来的画面,就是当年在某个推理项目里,看着计算图里密密麻麻的小算子,头皮发麻。那时候我就意识到,不搞融合,性能根本没法看。

今天咱们聊聊两种最核心的融合策略:水平融合垂直融合。说白了,一个是在同一层把兄弟算子合并,另一个是在流水线上把上下游算子串起来。

水平融合:同类算子的「抱团取暖」

水平融合,也叫同类型算子合并。你想想看,如果计算图里有好几个并行的卷积,或者好几个并行的全连接,它们互不依赖,那为什么还要分开算?

我个人的习惯是,遇到这种场景,直接把它们合并成一个更大的算子。这样做的好处很明显:

  • 减少内核启动开销:每个算子启动都要调用一次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 分析,看是计算瓶颈还是访存瓶颈,再决定融合策略

嗯,关于水平融合和垂直融合,今天就聊这么多。这两种策略是算子融合的基础,掌握了它们,你就能应对大部分推理引擎的优化场景了。


专注资料整理