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]。如果不融合,流程是:

  1. 卷积计算 → 写回DDR
  2. 从DDR读取 → ReLU计算 → 写回DDR

融合后:

  1. 卷积计算 → 在片上直接做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 + BatchNorm Conv + ReLU Conv + Bias + ReLU 权重吸收BN参数 激活函数内联 偏置+激活合并 融合后的单一算子 收益:减少内存访问次数,降低延迟,提升吞吐

这张图展示了三种融合模式的演进路径。从原始算子序列出发,你可以选择不同的融合策略。我个人建议优先做Conv+BN融合,因为BN的参数吸收是纯数学变换,不影响计算精度。然后再考虑加ReLU,这个收益也很明显。