3、算子融合技术(上):Conv+BatchNorm融合原理、Conv+ReLU融合、实战代码演示
各位同学,今天我们来聊聊算子融合。这玩意儿,说白了就是给模型做「瘦身手术」。我刚开始做部署的时候,总觉得模型训练完就万事大吉了。结果一上端侧设备,延迟直接爆炸。后来才发现,算子融合是优化里最立竿见影的手段之一。
为什么要做融合?你想想看,每个算子在执行时,都有启动开销、内存读写开销。把多个算子合并成一个,就能省掉中间那些不必要的搬运和调度。今天我们先讲两个最经典的组合:Conv+BatchNorm 和 Conv+ReLU。
Conv+BatchNorm 融合原理
这个组合在推理时几乎必做。为什么?因为 BatchNorm 在训练时有用,但推理时它的行为是确定的。说白了,推理时的 BN 就是一个线性变换:y = γ * (x - μ) / √(σ² + ε) + β。
而卷积本身也是线性变换:y = W * x + b。
两个线性变换叠在一起,完全可以合并成一个。我在项目中遇到过不少同学,训练完模型直接导出 ONNX,结果发现 BN 节点还在。嗯,这其实浪费了不少性能。
融合的数学推导很简单。假设卷积的输出是 z = W * x + b_conv,BN 的输入就是 z。那么 BN 的输出为:
y = γ * (z - μ) / √(σ² + ε) + β
= (γ / √(σ² + ε)) * z + (β - γ * μ / √(σ² + ε))
把 z 代入:
y = (γ / √(σ² + ε)) * (W * x + b_conv) + (β - γ * μ / √(σ² + ε))
= [ (γ / √(σ² + ε)) * W ] * x + [ (γ / √(σ² + ε)) * b_conv + (β - γ * μ / √(σ² + ε)) ]
看到了吗?新的卷积权重就是 W' = (γ / √(σ² + ε)) * W,新的偏置就是 b' = (γ / √(σ² + ε)) * b_conv + (β - γ * μ / √(σ² + ε))。
核心要点:融合后的卷积,权重和偏置都变了。但计算量没变,只是省掉了 BN 那一步的内存读写和 kernel 启动开销。
Conv+ReLU 融合
这个更简单。ReLU 就是 max(0, x)。很多硬件或推理框架(比如 TensorRT、ncnn)都支持在卷积计算完成后,直接对输出做 in-place 的 ReLU 操作。
为什么能融合?因为 ReLU 没有可学习参数,也不依赖 batch 统计量。它就是一个逐元素的非线性激活。你可以在卷积的最后一个循环里,直接把结果 clamp 到非负。
我曾经在优化一个 MobileNet 模型时,发现 Conv+BN+ReLU 三个算子串在一起。融合掉 BN 和 ReLU 后,推理速度提升了将近 30%。你想想看,这还只是三个算子的组合。
个人经验:有些框架(比如 ONNX Runtime)会自动做这些融合。但如果你用自定义算子或者手写推理代码,一定要手动实现。我建议你在导出模型时,就检查一下计算图里是否还有独立的 BN 节点。
实战代码演示
下面我用 PyTorch 演示一下 Conv+BatchNorm 的融合过程。注意,这里只做推理时的融合,训练时不要动。
import torch
import torch.nn as nn
def fuse_conv_bn(conv, bn):
"""
融合 Conv2d + BatchNorm2d
返回一个新的 Conv2d,权重和偏置已更新
"""
# 确保是 eval 模式
conv.eval()
bn.eval()
# 获取 BN 参数
gamma = bn.weight.data
beta = bn.bias.data
mean = bn.running_mean
var = bn.running_var
eps = bn.eps
# 计算缩放因子
scale = gamma / torch.sqrt(var + eps)
# 更新卷积权重
fused_weight = conv.weight.data * scale.view(-1, 1, 1, 1)
# 更新卷积偏置
if conv.bias is not None:
fused_bias = (conv.bias.data - mean) * scale + beta
else:
fused_bias = -mean * scale + beta
# 创建新的卷积层
fused_conv = nn.Conv2d(
in_channels=conv.in_channels,
out_channels=conv.out_channels,
kernel_size=conv.kernel_size,
stride=conv.stride,
padding=conv.padding,
dilation=conv.dilation,
groups=conv.groups,
bias=True
)
fused_conv.weight.data = fused_weight
fused_conv.bias.data = fused_bias
return fused_conv
# 使用示例
conv = nn.Conv2d(3, 16, 3, padding=1)
bn = nn.BatchNorm2d(16)
# 模拟训练后的状态
bn.running_mean = torch.randn(16)
bn.running_var = torch.rand(16) + 1.0
fused_conv = fuse_conv_bn(conv, bn)
# 验证结果是否一致
x = torch.randn(1, 3, 32, 32)
with torch.no_grad():
out_original = bn(conv(x))
out_fused = fused_conv(x)
print("最大误差:", (out_original - out_fused).abs().max().item())
# 应该接近 1e-6 级别
避坑指南:我曾经在融合时忘记把 BN 设成 eval 模式,结果 running_mean 和 running_var 还在更新,融合后的权重完全不对。另外,如果卷积层没有 bias,融合后一定要显式加上 bias,否则 BN 的 beta 没地方放。
至于 Conv+ReLU 的融合,在 PyTorch 里可以用 torch.nn.intrinsic 模块:
import torch.nn.intrinsic as nni
# 创建一个 ConvReLU2d 融合模块
conv_relu = nni.ConvReLU2d(
nn.Conv2d(3, 16, 3, padding=1),
nn.ReLU(inplace=True)
)
# 或者从已有的 Conv+ReLU 序列转换
conv = nn.Conv2d(3, 16, 3, padding=1)
relu = nn.ReLU(inplace=True)
fused = nni.ConvReLU2d(conv, relu)
这个融合模块在 forward 时,会在卷积计算完成后直接调用 ReLU,省掉了中间 tensor 的分配和拷贝。
总结一下
今天讲了两个最基础的融合:
- Conv+BN 融合:把 BN 的线性变换吸收到卷积的权重和偏置里。数学上就是乘法和加法合并。
- Conv+ReLU 融合:在卷积计算后直接做 in-place 的 ReLU,减少内存访问。
这两种融合在几乎所有推理框架中都是标配。但理解原理很重要,因为后面我们会讲到更复杂的融合,比如 Conv+BN+ReLU 三合一、残差分支融合等。那些都是在今天的基础上扩展的。
下节课我们会继续讲算子融合的下半部分,包括多分支融合和更高级的图优化技巧。到时候我会分享一个我在实际项目中踩过的坑——融合后精度反而下降了,你猜猜是什么原因?