2、手工融合原理:手工融合的基本思想、常见的融合模式(Conv+BN、Conv+ReLU、MatMul+Add)
好,咱们进入正题。手工融合,说白了就是靠人肉去识别计算图中的「连续操作」,然后把它们合并成一个算子。你想想看,深度学习模型里动辄几百层,每一层都单独启动一次GPU kernel,那开销得多大?我早年调一个ResNet-50,光kernel launch overhead就占了将近15%的时间,心疼得不行。
手工融合的核心思想其实就一句话:减少访存,减少kernel启动次数。把多个连续的小操作捏成一个,中间结果不用写回显存,直接在寄存器或L1 cache里流转。嗯,这里要注意,不是所有操作都能随便融合的,得看数据依赖和计算语义。
手工融合的基本思想:
- 消除中间结果的显存读写
- 减少GPU kernel launch次数
- 提升计算密度和缓存利用率
2.1 Conv + BN 融合
这个是我在项目中遇到最多的场景。BN层在推理阶段其实就是一个线性变换:y = gamma * (x - mean) / sqrt(var + eps) + beta。而卷积也是一个线性操作。两个线性操作串在一起,完全可以合并成一个。
具体怎么做?我习惯把BN的参数先「吸收」进卷积的权重里。假设卷积的权重是W,偏置是b,BN的四个参数分别是gamma、beta、mean、var。融合后的新权重W'和偏置b'就是:
// 伪代码:Conv+BN融合
W' = W * gamma / sqrt(var + eps)
b' = (b - mean) * gamma / sqrt(var + eps) + beta
你看,这样推理时就不需要单独跑BN了。我曾经在一个MobileNetV2的部署项目里,靠这个融合把推理速度提升了8%。注意,训练阶段不能这么干,因为BN的mean和var是不断变化的。
个人经验: 融合后的卷积层,如果原始卷积没有bias,记得把b'直接设成融合后的值。我见过有人忘了处理bias,结果精度直接崩了。
2.2 Conv + ReLU 融合
ReLU就是max(0, x),一个简单的逐元素操作。把它和卷积融合,本质上就是在卷积的最后一个步骤——写回结果之前,先过一遍ReLU。很多深度学习框架的底层库(比如cuDNN、MIOpen)都直接支持这种融合模式。
为什么能融合?因为ReLU没有跨通道或跨空间的数据依赖,它只看单个元素的值。你想想看,卷积算完一个输出像素点,马上就能判断它要不要置零,何必先写回显存再读出来做ReLU?
// 伪代码:Conv+ReLU融合kernel
for each output pixel (n, c, h, w):
float acc = 0.0;
for each input channel and kernel element:
acc += input[...] * weight[...];
// 融合ReLU
output[n][c][h][w] = max(0.0, acc + bias[c]);
我建议你在写融合kernel时,把ReLU的阈值也做成可配置的。有些网络用Leaky ReLU或者PReLU,阈值不是0。嗯,提前留个接口,后面改起来方便。
避坑指南: 我曾经在融合Conv+ReLU时,忘了考虑in-place操作的问题。如果输入和输出是同一块显存,ReLU会覆盖掉卷积的中间结果,导致梯度计算出错。推理时没问题,但训练时一定要小心。
2.3 MatMul + Add 融合
这个其实就是全连接层加偏置的融合。MatMul算完矩阵乘法后,紧接着一个Add(通常是加bias)。很多框架里叫「FullyConnected」或者「Linear」层,本质上就是MatMul + Add的融合体。
融合方式很简单:在MatMul的累加循环里,把bias加到最后的结果上。但要注意,bias的维度通常和输出维度一致,需要做broadcast。
// 伪代码:MatMul+Add融合
for i in range(M):
for j in range(N):
float sum = 0.0;
for k in range(K):
sum += A[i][k] * B[k][j];
// 融合Add(bias broadcast)
C[i][j] = sum + bias[j];
我个人习惯把这种融合写成模板函数,支持float16和float32两种精度。因为在实际部署时,量化模型经常用fp16,融合kernel能省掉一次类型转换的开销。
2.4 常见融合模式总结
| 融合模式 | 融合原理 | 收益点 | 注意事项 |
|---|---|---|---|
| Conv + BN | 参数吸收,将BN线性变换并入Conv权重 | 消除BN kernel启动和中间显存读写 | 仅推理阶段可用;注意bias处理 |
| Conv + ReLU | 在卷积写回前执行ReLU | 减少一次显存读写和kernel启动 | 注意in-place问题;支持变体ReLU |
| MatMul + Add | 在矩阵乘累加循环中嵌入bias加法 | 减少一次kernel启动和broadcast开销 | 注意bias维度broadcast;支持fp16 |
核心要点: 手工融合的本质是「算子代数化简 + 访存优化」。你不需要依赖任何编译器,自己手写一个融合kernel,往往就能获得立竿见影的效果。但缺点也很明显——每换一个网络结构,可能就得重新写一套融合规则。
好了,手工融合的原理就这些。说白了就是靠经验去识别那些「可以合并的连续操作」,然后手动改写kernel。下一节我们会聊自动融合,看看编译器是怎么替我们干这些脏活累活的。
这张图展示了手工融合的核心逻辑:把多个连续操作(Conv + BN/ReLU/Add)合并成一个融合算子,消除中间的显存读写。左边是原始计算图,右边是融合后的效果。你仔细看,融合后少了两条数据搬移的路径,这就是性能提升的关键。
我的建议: 刚开始做手工融合时,先从Conv+ReLU入手,它最简单也最安全。等熟悉了kernel编写流程,再挑战Conv+BN的参数吸收。别一上来就想搞大融合,容易翻车。
好了,手工融合的原理就这些。说白了就是靠经验去识别那些「可以合并的连续操作」,然后手动改写kernel。下一节我们会聊自动融合,看看编译器是怎么替我们干这些脏活累活的。