4. Conv+BN融合:原理推导、数学等价变换、融合后的计算量分析

Conv+BN融合,这可以说是算子融合里最经典、也最实用的一个案例了。我刚开始做模型部署的时候,第一个优化的就是它。说白了,就是把卷积层和它后面的批量归一化层合并成一个新的卷积层。为什么要这么做?因为推理的时候,BN其实是个累赘——它引入了额外的计算和访存开销。

4.1 为什么BN在推理时是“累赘”?

先回忆一下BN在训练时的作用:它把中间特征归一化到均值为0、方差为1的分布,然后再学一个缩放和平移。公式长这样:

y = γ * (x - μ) / √(σ² + ε) + β

其中μ是均值,σ²是方差,γ是缩放因子,β是偏移量,ε是个防止除零的小常数。

训练时,这些参数都是可学习的。但推理时,μ和σ²已经固定了——它们是训练集上统计出来的全局均值与方差。所以推理时的BN,本质上就是一个线性变换。你想想看,卷积也是一个线性变换(严格说是仿射变换)。两个线性变换串在一起,是不是可以合并成一个?

核心思想:推理时,Conv和BN都是线性操作,可以合并为一个等价的卷积层,从而省掉BN的计算和访存开销。

4.2 数学推导:一步步拆解

好,我们来推一下。假设卷积的输出是z,那么:

z = W * x + b

其中W是卷积权重,b是偏置,x是输入特征图。

然后BN对z做归一化:

y = γ * (z - μ) / √(σ² + ε) + β

把z代入:

y = γ * (W * x + b - μ) / √(σ² + ε) + β

整理一下,把系数提出来:

y = [γ / √(σ² + ε)] * (W * x + b - μ) + β
   = [γ / √(σ² + ε)] * W * x + [γ / √(σ² + ε)] * (b - μ) + β

看到了吗?这其实就是一个新的卷积:

y = W' * x + b'

其中:

W' = γ / √(σ² + ε) * W
b' = γ / √(σ² + ε) * (b - μ) + β

个人经验:我在项目中遇到过好几次,有人直接把BN层删掉,结果精度掉得一塌糊涂。千万别这么干!融合不是删除,是把BN的参数吸收进卷积里。数学上完全等价,精度不会有任何损失。

4.3 数学等价变换的代码实现

下面我给出一个PyTorch风格的融合实现。注意,这里假设卷积没有bias(很多框架为了兼容BN,会把卷积的bias设为False),如果有bias,公式里要带上。

def fuse_conv_bn(conv_weight, conv_bias, bn_mean, bn_var, bn_gamma, bn_beta, bn_eps):
    """
    将Conv和BN融合为一个新的Conv权重和偏置
    """
    # 计算缩放因子
    scale = bn_gamma / torch.sqrt(bn_var + bn_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

这段代码很短,但很关键。你想想看,原来推理时要先算卷积,再读BN的四个参数(μ、σ²、γ、β),再做乘加运算。融合之后,这些全没了,就一个卷积搞定。

我曾经踩过的坑:有一次我忘了处理卷积的bias。如果卷积本身有bias,融合公式里要把bias项也乘上scale。否则结果会差一个常数偏移。虽然不影响精度趋势,但数值上就不等价了。做部署的同学,数值等价是底线。

4.4 融合后的计算量分析

我们来算一笔账。假设输入特征图大小是C_in × H × W,输出通道数是C_out,卷积核大小是K×K。

融合前:

  • 卷积计算量:C_out × C_in × K² × H × W 次乘加
  • BN计算量:C_out × H × W 次乘加(归一化+缩放+平移)
  • 总计算量:C_out × H × W × (C_in × K² + 1)

融合后:

  • 卷积计算量:C_out × C_in × K² × H × W 次乘加
  • BN计算量:0
  • 总计算量:C_out × C_in × K² × H × W

节省的计算量就是BN的那部分:C_out × H × W。看起来不多?但别忘了,BN还涉及额外的访存——要读μ、σ²、γ、β四个参数,还要写中间结果。融合之后,这些访存全省了。

对比项 融合前 融合后 节省比例
计算量(乘加) C_out×H×W×(C_in×K²+1) C_out×C_in×K²×H×W 约 1/(C_in×K²) 的乘加
额外访存(参数) 4×C_out 个浮点数 0 100%
中间结果写回 需要写回卷积输出 不需要 省掉一次大张量读写

举个例子。假设C_in=64, C_out=64, K=3, H=W=56。融合前BN的计算量是64×56×56≈20万次乘加。融合后这部分就没了。虽然20万相对于卷积的64×64×9×56×56≈1.15亿次乘加来说占比不大,但BN的访存开销其实更致命——它需要把卷积输出的整个特征图读出来再写回去,这个带宽消耗在移动端或边缘设备上是很贵的。

我的建议:在GPU上,Conv+BN融合主要省的是kernel launch开销和显存带宽。在CPU或NPU上,省掉BN的访存往往比省掉那点计算量更重要。我做过一个项目,在ARM CPU上融合后推理速度提升了15%~20%,主要就是访存减少带来的收益。

4.5 融合后的结构图

下面我用一张SVG图来展示融合前后的对比。左边是原始结构,右边是融合后的结构。你可以直观地看到,BN层被“吸收”进了卷积里。

融合前 输入特征图 Conv2d BatchNorm 输出特征图 融合后 输入特征图 Fused Conv 输出特征图 BN参数被吸收进Conv权重和偏置

嗯,这张图很直观。融合后,整个计算图变短了,少了一个节点。这意味着什么?少了一次kernel launch,少了一次中间张量的分配和释放,少了一次数据搬运。在推理框架里,这些都是实打实的性能收益。

4.6 实际部署中的注意事项

最后聊几个实际部署时容易忽略的点:

  • 精度问题:融合是数学等价的,理论上精度不变。但如果你用的是FP16或INT8量化模型,融合前后的数值可能会有微小差异——因为乘加顺序变了。我建议融合后做一次数值对比,确保误差在可接受范围内。
  • 多分支网络:像ResNet这种有残差连接的网络,BN融合要小心。如果BN后面接了add操作,融合后需要调整add的输入。这个稍微复杂一点,但原理是一样的。
  • 训练与推理分离:融合只在推理时做。训练时千万别融合——BN在训练时依赖batch内的统计量,是动态的,没法提前吸收进卷积里。

一个小技巧:很多推理框架(比如TensorRT、ONNX Runtime)会自动做Conv+BN融合。但如果你自己手写推理代码,或者用自定义后端,手动融合一下能省不少事。我个人习惯在模型导出阶段就把BN融合掉,这样导出的ONNX模型更干净,也更容易调试。

好了,Conv+BN融合就讲到这里。说白了就是一次线性变换的合并,推导不难,收益却很实在。下一节我们会聊更复杂的融合模式,比如Conv+ReLU、Conv+Add+ReLU这些。但万变不离其宗——核心思想都是把连续的线性或简单非线性操作合并成一个算子,减少访存和kernel launch开销。

可可精品资料库