第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 融合策略的核心逻辑
说了这么多,咱们总结一下。算子融合的核心逻辑其实就一句话:减少中间数据的读写次数。
我画了个图,帮你理解这个逻辑:
你看,融合前要写两次数据(中间结果+最终结果),融合后只需要写一次。对于大模型来说,这省下来的带宽非常可观。
1.4 融合策略的选择
那么问题来了:什么时候该用哪种融合?我个人的经验是:
- 先做纵向融合:Conv+BN+ReLU这种,零成本高收益,无脑做
- 再做横向融合:看带宽利用率,如果带宽是瓶颈,优先做
- 最后做跨层融合:这个要小心,得分析数据依赖关系
我的建议:别想着一次把所有融合都做完。先跑一遍profiling,看看哪些算子是热点。热点算子优先融合,收益最大。
嗯,今天就先聊到这儿。算子融合是个大话题,后面咱们还会深入讲每种融合的具体实现。记住核心:减少中间数据读写,这是所有优化的出发点。
公众号:蓝海资料掘金营,微信deep3321