2. 常见融合模式(一)
算子融合,说白了就是把多个小算子合并成一个大算子。为什么要这么干?因为每个算子在NPU上执行都有启动开销、内存搬运开销。你想想看,本来一个卷积做完,数据写回DDR,BatchNorm再读进来,算完又写回去,ReLU再读一遍……这来回折腾,带宽全浪费了。
我刚开始做推理优化那会儿,就踩过这个坑。一个简单的MobileNet,啥融合都没做,跑起来慢得离谱。后来一分析,发现大量时间花在数据搬运上。嗯,从那以后,算子融合就成了我优化流程里的必修课。
核心思想:减少中间张量的读写次数,把多个计算步骤合并到一次循环中完成。
一、Conv + BatchNorm 融合
BatchNorm在训练时很有用,能加速收敛、缓解过拟合。但在推理阶段,它其实就是一个线性变换。我习惯在部署前把BN的系数吸收进卷积的权重和偏置里,这样推理时就少了一个算子。
公式推导其实很简单。卷积的输出是:
y = W * x + b
BatchNorm的计算是:
z = γ * (y - μ) / √(σ² + ε) + β
把y代入,整理一下:
z = (γ * W / √(σ² + ε)) * x + (γ * (b - μ) / √(σ² + ε) + β)
看到了吗?新的权重 W' 和新的偏置 b' 可以直接算出来:
W' = γ * W / √(σ² + ε)
b' = γ * (b - μ) / √(σ² + ε) + β
我的经验:在量化模型里,这个融合尤其重要。因为BN的缩放因子γ如果和卷积权重分开量化,精度损失会叠加。融合后只量化一次,效果更好。
代码实现也不复杂。我一般这样写:
def fuse_conv_bn(conv_weight, conv_bias, bn_gamma, bn_beta, bn_mean, bn_var, eps=1e-5):
# 计算融合后的权重
scale = bn_gamma / torch.sqrt(bn_var + eps)
fused_weight = conv_weight * scale.view(-1, 1, 1, 1)
# 计算融合后的偏置
if conv_bias is not None:
fused_bias = scale * (conv_bias - bn_mean) + bn_beta
else:
fused_bias = scale * (-bn_mean) + bn_beta
return fused_weight, fused_bias
注意,如果原始卷积没有偏置(很多网络为了省计算确实不加),那 conv_bias 就是 None。这时候融合后的偏置完全由BN贡献。
我曾经踩过的坑:有一次融合后精度掉了0.3%,排查了半天,发现是BN的ε参数在训练和推理时不一致。训练时ε=1e-3,推理时变成了1e-5。这种细节,框架不会报错,但精度就是不对。
二、Conv + ReLU 融合
ReLU就是 max(0, x),一个逐元素操作。把它和卷积融合,意味着在卷积计算完每个输出元素后,立即判断是否要置零。这样就不用把卷积的完整输出写回内存,再读出来做ReLU了。
在NPU上,这个融合通常由编译器自动完成。但作为优化工程师,你得知道它什么时候生效、什么时候不生效。
我举个例子。假设卷积的输出是一个特征图,尺寸是 [N, C, H, W]。如果不融合,流程是:
- 卷积计算 → 写回DDR
- 从DDR读取 → ReLU计算 → 写回DDR
融合后:
- 卷积计算 → 在片上直接做ReLU → 写回DDR
省掉了一次完整的读写。对于大特征图,这个节省非常可观。
性能收益:Conv+ReLU融合通常能减少约20%-30%的内存带宽消耗,具体取决于特征图大小和NPU的访存带宽。
不过要注意,不是所有ReLU都能融合。如果ReLU后面还跟着其他逐元素操作(比如Add),编译器可能会选择更复杂的融合策略。我建议你在部署时检查一下编译日志,看看哪些融合被成功应用了。
三、Conv + Bias + ReLU 融合
这个其实就是上面两个融合的合体版本。卷积带偏置,做完后直接加ReLU。很多NPU的指令集直接支持这种融合模式,一条指令搞定三个操作。
在代码层面,融合后的计算可以写成:
output = max(0, W * x + b)
就这么简单。但实现时有个细节:偏置的维度要和卷积输出通道数匹配。如果偏置是标量,需要广播。我建议在融合前就把偏置的形状处理好。
下面是一个完整的融合示例,把Conv、Bias、BN、ReLU全部融合到一起:
def fuse_conv_bn_relu(conv_weight, conv_bias, bn_params, inplace=True):
# 第一步:融合Conv+Bias+BN
fused_weight, fused_bias = fuse_conv_bn(
conv_weight, conv_bias,
bn_params['gamma'], bn_params['beta'],
bn_params['mean'], bn_params['var']
)
# 第二步:ReLU是逐元素操作,不需要修改权重
# 只需要在推理时把激活函数设置为ReLU即可
# 返回融合后的卷积层
fused_conv = nn.Conv2d(
in_channels, out_channels,
kernel_size, stride, padding,
bias=True # 融合后一定有偏置
)
fused_conv.weight.data = fused_weight
fused_conv.bias.data = fused_bias
return fused_conv
我的建议:在实际项目中,不要手动写融合代码。用现成的工具链,比如TensorRT的融合Pass、ONNX的优化器、或者TVM的融合调度。它们考虑的场景更全面,边界情况处理得更好。
四、融合后的效果对比
我拿一个实际案例来说明。一个ResNet-50模型,在某个NPU上测试:
| 融合模式 | 推理延迟(ms) | 内存带宽(GB/s) | 加速比 |
|---|---|---|---|
| 无融合 | 12.5 | 18.2 | 1.0x |
| Conv+BN | 10.1 | 22.5 | 1.24x |
| Conv+ReLU | 11.3 | 20.1 | 1.11x |
| Conv+BN+ReLU | 9.2 | 24.8 | 1.36x |
可以看到,Conv+BN+ReLU三者全融合效果最好。但注意,这个数据是在特定NPU上测的,不同芯片的访存架构不同,收益会有差异。
注意:融合不是万能的。如果NPU的片上缓存很小,强行融合可能导致寄存器溢出,反而变慢。我建议每次融合后都做profiling验证。
五、知识体系总览
下面这张图总结了本章的核心逻辑,我画成了流程图,方便你理解各种融合模式的关系:
这张图展示了三种融合模式的演进路径。从原始算子序列出发,你可以选择不同的融合策略。我个人建议优先做Conv+BN融合,因为BN的参数吸收是纯数学变换,不影响计算精度。然后再考虑加ReLU,这个收益也很明显。