第1章:算子融合策略与内存访问优化

1.1 为什么我们需要算子融合?

各位同学好,我是老张。今天咱们聊聊算子融合。说白了,这就是把多个小算子合并成一个大算子。为什么要这么做?我刚开始做AI芯片时也纳闷——分开算不是挺好吗?

后来我踩了个坑。有一次部署一个轻量级模型,算力利用率死活上不去。一分析才发现,GPU大部分时间都在等数据,真正算的时间少得可怜。这就是典型的「内存墙」问题。

你想想看,每个算子执行时都要:

  • 从显存读数据
  • 计算
  • 把结果写回显存

如果算子特别小,读写时间比计算时间还长。这就像你每次只搬一块砖,大部分时间花在来回走路上。算子融合,就是把这些小砖块打包成大箱子,一次搬完。

核心收益:减少中间数据的读写次数,提升计算密度。

1.2 三种常见融合模式

我这些年做编译器优化,遇到的融合模式基本就三类。咱们一个一个说。

1.2.1 横向融合:Element-wise算子融合

横向融合,也叫同质融合。就是把多个element-wise算子合并成一个。比如:

// 融合前
y1 = relu(x)
y2 = sigmoid(x)
z = y1 + y2

// 融合后
z = relu(x) + sigmoid(x)

嗯,这里要注意。融合后只需要读一次x,写一次z。中间结果y1和y2不用落盘。我在项目里见过一个模型,光靠横向融合就省了30%的显存带宽。

我的经验:横向融合对带宽受限的场景特别有效。比如MobileNet这种轻量网络,算力不是瓶颈,带宽才是。

1.2.2 纵向融合:Conv+BN+ReLU

纵向融合,也叫异质融合。最常见的就是Conv+BN+ReLU。这三个算子串在一起,在推理时可以合并成一个。

为什么会这样?因为BN在推理时是线性变换:

BN(x) = γ * (x - μ) / σ + β

而Conv也是线性变换。两个线性变换可以合并成一个。我建议你在做模型部署时,优先考虑这种融合。它几乎零成本,收益却很大。

融合方式 适用场景 收益
Conv+BN 推理阶段 减少一次kernel launch
Conv+BN+ReLU 推理阶段 减少两次kernel launch + 一次中间读写
Conv+Add+ReLU ResNet类网络 减少一次中间读写

我记得有一次帮客户优化一个ResNet-50模型。光是把Conv+BN+ReLU融合掉,推理速度就快了15%。客户当场就懵了——代码一行没改,性能就上去了。

1.2.3 跨层融合:ResNet的残差融合

跨层融合稍微复杂点。它涉及不同分支的算子合并。以ResNet为例:

// 残差块结构
shortcut = x
main = Conv(x)
main = BN(main)
main = ReLU(main)
main = Conv(main)
main = BN(main)
output = main + shortcut
output = ReLU(output)

这里有个技巧。最后一个ReLU可以和前面的Add融合。因为ReLU是element-wise操作,Add也是element-wise操作。两者合并后,可以减少一次数据搬运。

避坑指南:我曾经在融合残差分支时犯过错误。把shortcut路径上的数据也一起融合了,结果精度掉了0.5%。后来才发现,shortcut路径上的数据不能随便改,它可能被其他层复用。

1.3 融合策略的核心逻辑

说了这么多,咱们总结一下。算子融合的核心逻辑其实就一句话:减少中间数据的读写次数

我画了个图,帮你理解这个逻辑:

算子融合核心逻辑 未融合 算子A 写中间结果 算子B 写最终结果 融合后 融合算子(A+B) 写最终结果 少了一次中间读写! 融合 核心收益:减少中间数据读写 → 降低带宽压力 → 提升计算效率

你看,融合前要写两次数据(中间结果+最终结果),融合后只需要写一次。对于大模型来说,这省下来的带宽非常可观。

1.4 融合策略的选择

那么问题来了:什么时候该用哪种融合?我个人的经验是:

  1. 先做纵向融合:Conv+BN+ReLU这种,零成本高收益,无脑做
  2. 再做横向融合:看带宽利用率,如果带宽是瓶颈,优先做
  3. 最后做跨层融合:这个要小心,得分析数据依赖关系

我的建议:别想着一次把所有融合都做完。先跑一遍profiling,看看哪些算子是热点。热点算子优先融合,收益最大。

嗯,今天就先聊到这儿。算子融合是个大话题,后面咱们还会深入讲每种融合的具体实现。记住核心:减少中间数据读写,这是所有优化的出发点。


公众号:蓝海资料掘金营,微信deep3321