读 ncnn 源码(XXVII):fuse_innerproduct_batchnorm——全连接层的 BN 融合

在本系列的前几篇中,我们已经详细探讨了 ncnnoptimize 如何将 BatchNorm 层融合到各种卷积变体(Convolution, ConvolutionDepthWise, Deconvolution, DeconvolutionDepthWise)中。InnerProduct 层,即全连接层(Fully Connected Layer),作为神经网络(尤其是分类头部)的另一个关键组件,同样可能后接 BatchNorm 层。为了实现彻底的优化,ncnn 提供了 fuse_innerproduct_batchnorm Pass 来处理 InnerProduct -> BatchNorm 这一模式。

本篇,我们将剖析该函数的源码,理解其如何将 BN 融合的通用原理应用于全连接层。

TL;DR

  1. 目标: 将 InnerProduct (全连接) 层后紧随的 BatchNorm 层进行融合,消除 BatchNorm 在推理时的计算开销。
  2. 模式匹配: 遍历网络 layers,查找 InnerProduct -> BatchNorm 的直接连接模式。
  3. 数学原理: fuse_convolution_batchnorm 完全一致InnerProduct 也是一种线性变换 (y=Wx+bfcy = Wx + b_{fc}),后接 BatchNorm 仍构成连续线性链。融合公式不变:
    • 令 BN 参数导出系数 b = slope / sqrt(var + eps)a = bias - slope * mean / sqrt(var + eps)
    • 新权重 Wfc=WfcbW'_{fc} = W_{fc} \cdot b (注意这里的乘法是按输出维度进行的)。
    • 新偏置 bfc=bfcb+ab'_{fc} = b_{fc} \cdot b + a (逐输出维度计算)。
  4. 代码实现: 几乎与 fuse_convolution_batchnorm 完全相同
    • 计算中间系数 ab
    • 遍历 InnerProduct 层的每个输出单元 i (对应 BN 的 channels)。
    • 将连接到该输出单元的所有权重 (weight_per_outch 个元素) 乘以 b[i]
    • 更新该输出单元的偏置 bias[i] = bias[i] * b[i] + a[i] (如果原层无偏置,则先创建)。
  5. 图结构修改: 将 InnerProduct 层的 top 指向原 BatchNormtop,更新 blobproducer,并将 BatchNorm 标记为 "ncnnfused"
  6. 效果: 消除了 BatchNorm 层的计算和内存访问,对于使用了 InnerProduct + BatchNorm 结构的网络(例如某些模型的分类头)有优化效果。


1. 融合动机:线性本质的统一

InnerProduct 层执行的是标准的矩阵乘法加偏置操作:y=Wx+bfcy = Wx + b_{fc}。这显然是一个线性变换。因此,当其后跟随另一个线性变换 BatchNorm 时,这两个操作在数学上可以合并。

BatchNorm 的缩放和平移参数“烘焙”进 InnerProduct 层的权重矩阵 W 和偏置向量 b_{fc} 中,可以在推理时省去 BatchNorm 的计算,从而提升性能。


2. 数学原理

核心数学原理是:两个连续的线性变换(Linear Transformation)可以合并为一个单一的线性变换。

“Inner Product”(内积)层在深度学习框架中(如 Caffe)通常指的是“Fully Connected”(FC,全连接)层或“Dense”层。其数学形式是一个线性变换:y=Wx+by = Wx + b

“Batch Normalization”(BN,批量归一化)层在推理时也是一个线性变换。

下面是详细的数学推导过程:

1. 两个独立层的数学表示

假设我们有一个全连接层(Inner Product),紧跟着一个批量归一化层(Batch Norm)。

a) 全连接层 (FC)

全连接层的计算非常简单,它对输入 xx 进行一次线性变换:

yfc=Wfcx+bfcy_{fc} = W_{fc} \cdot x + b_{fc}

  • xx:输入向量。
  • WfcW_{fc}:全连接层的权重矩阵。
  • bfcb_{fc}:全连接层的偏置(bias)向量。
  • yfcy_{fc}:全连接层的输出。
b) 批量归一化层 (BN)

BN 层在训练(training)和推理(inference)时的行为是不同的。

  • 训练时:BN 层会计算当前 mini-batch 的均值 μbatch\mu_{batch} 和方差 σbatch2\sigma_{batch}^2,并用它们来归一化。
  • 推理时:BN 层使用在整个训练过程中累积的“滑动平均均值”(running mean)μrun\mu_{run} 和“滑动平均方差”(running variance)σrun2\sigma_{run}^2这些值是固定的常量。

BN 层的推理计算公式如下:

ybn=γ(yfcμrunσrun2+ϵ)+βy_{bn} = \gamma \left( \frac{y_{fc} - \mu_{run}}{\sqrt{\sigma_{run}^2 + \epsilon}} \right) + \beta

  • yfcy_{fc}:BN 层的输入(即上一层 FC 的输出)。
  • μrun\mu_{run}:训练时保存的运行均值。
  • σrun2\sigma_{run}^2:训练时保存的运行方差。
  • γ\gamma:BN 层的可学习缩放(scale)参数。
  • β\beta:BN 层的可学习平移(shift)参数。
  • ϵ\epsilon:一个很小的常数,用于防止除以零。

2. 融合 (Fusion) 的数学推导

我们的目标是找到一组新的权重 WfusedW_{fused} 和偏置 bfusedb_{fused},使得单一的全连接层 yfused=Wfusedx+bfusedy_{fused} = W_{fused} \cdot x + b_{fused} 能够产生与 FCBNFC \rightarrow BN 序列完全相同的结果 ybny_{bn}

推导过程如下:

  1. 将 FC 层的公式 yfc=Wfcx+bfcy_{fc} = W_{fc} \cdot x + b_{fc} 代入 BN 层的公式中:

    ybn=γ((Wfcx+bfc)μrunσrun2+ϵ)+βy_{bn} = \gamma \left( \frac{(W_{fc} \cdot x + b_{fc}) - \mu_{run}}{\sqrt{\sigma_{run}^2 + \epsilon}} \right) + \beta

  2. 展开并重新排列上式,使其变为 Ax+BA \cdot x + B 的形式。我们先把分母定义为一个常量 CbnC_{bn}(因为 σrun2\sigma_{run}^2ϵ\epsilon 都是常量):

    Cbn=σrun2+ϵC_{bn} = \sqrt{\sigma_{run}^2 + \epsilon}

  3. 代入 CbnC_{bn} 并展开:

    ybn=γCbn(Wfcx+bfcμrun)+βy_{bn} = \frac{\gamma}{C_{bn}} (W_{fc} \cdot x + b_{fc} - \mu_{run}) + \beta

  4. 继续展开,将 γCbn\frac{\gamma}{C_{bn}} 乘进去:

    ybn=(γCbnWfc)x+(γ(bfcμrun)Cbn)+βy_{bn} = \left( \frac{\gamma}{C_{bn}} \cdot W_{fc} \right) x + \left( \frac{\gamma \cdot (b_{fc} - \mu_{run})}{C_{bn}} \right) + \beta

  5. 现在,我们将所有作用于 xx 的项组合为新的权重 WfusedW_{fused},并将所有常量项组合为新的偏置 bfusedb_{fused}

    ybn=(γCbnWfc)Wfusedx+(γ(bfcμrun)Cbn+β)bfusedy_{bn} = \underbrace{\left( \frac{\gamma}{C_{bn}} W_{fc} \right)}_{W_{fused}} x + \underbrace{\left( \frac{\gamma (b_{fc} - \mu_{run})}{C_{bn}} + \beta \right)}_{b_{fused}}

3. 融合结果:新的 WW'bb'

通过上述推导,我们得到了融合后“新”全连接层的权重和偏置:

融合后的权重 WfusedW_{fused}

Wfused=γσrun2+ϵWfcW_{fused} = \frac{\gamma}{\sqrt{\sigma_{run}^2 + \epsilon}} W_{fc}

融合后的偏置 bfusedb_{fused}

bfused=γ(bfcμrun)σrun2+ϵ+βb_{fused} = \frac{\gamma (b_{fc} - \mu_{run})}{\sqrt{\sigma_{run}^2 + \epsilon}} + \beta

注意:这里的 γ\gamma, β\beta, μrun\mu_{run}, 和 σrun2\sigma_{run}^2 都是向量(维度与 yfcy_{fc} 的通道数/特征数相同)。因此,上述乘法和除法是逐元素(element-wise)进行的。例如,计算 WfusedW_{fused} 时, WfcW_{fc}每一行(对应一个输出神经元)都乘以 γ\gamma1/σrun2+ϵ1/\sqrt{\sigma_{run}^2 + \epsilon} 向量中的相应元素。

特殊情况:如果 FC 层没有偏置

在某些网络设计中,FC 层后面如果(明确知道)要跟一个 BN 层,会设置 bfc=0b_{fc} = 0 来节省参数。在这种情况下,融合后的偏置 bfusedb_{fused} 简化为:

bfused (if bfc=0)=γμrunσrun2+ϵ+βb_{fused} \text{ (if } b_{fc}=0 \text{)} = \frac{-\gamma \cdot \mu_{run}}{\sqrt{\sigma_{run}^2 + \epsilon}} + \beta


3. 代码实现:复用 BN 融合核心逻辑

fuse_innerproduct_batchnorm 的代码实现与卷积类 BN 融合 Pass 展现了高度的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
int NetOptimize::fuse_innerproduct_batchnorm()
{
const size_t layer_count = layers.size();
for (size_t i = 0; i < layer_count; i++) // 遍历查找 InnerProduct
{
if (layers[i]->type != "InnerProduct") continue;
int top_blob_index = layers[i]->tops[0];

// 查找后续的 BatchNorm
// ... (模式匹配代码与 fuse_convolution_batchnorm 完全一致) ...
size_t j = i + 1;
// ... (find BatchNorm j) ...
if (j == layer_count) continue;

ncnn::InnerProduct* innerproduct = (ncnn::InnerProduct*)layers[i];
ncnn::BatchNorm* batchnorm = (ncnn::BatchNorm*)layers[j];

fprintf(stderr, "fuse_innerproduct_batchnorm %s %s\n", innerproduct->name.c_str(), batchnorm->name.c_str());

// --- 参数变换核心 (与 fuse_convolution_batchnorm 完全一致) ---
{
int channels = batchnorm->channels; // = innerproduct->num_output
float eps = batchnorm->eps;

// 1. 计算中间系数 a 和 b
std::vector<float> a(channels);
std::vector<float> b(channels);
for (int ch = 0; ch < channels; ch++)
{
float sqrt_var = static_cast<float>(sqrt(batchnorm->var_data[ch] + eps));
a[ch] = batchnorm->bias_data[ch] - batchnorm->slope_data[ch] * batchnorm->mean_data[ch] / sqrt_var;
b[ch] = batchnorm->slope_data[ch] / sqrt_var;
}

// 2. 如果 InnerProduct 没有偏置,则创建并初始化为 0
if (innerproduct->bias_term == 0)
{
innerproduct->bias_term = 1;
innerproduct->bias_data = ncnn::Mat(channels);
innerproduct->bias_data.fill(0.f);
}

// 3. 计算每个输出单元对应的权重数量
// 对于 InnerProduct, weight_data_size = num_output * num_input
// weight_per_outch = num_input (连接到单个输出单元的所有输入的权重)
const int weight_per_outch = innerproduct->weight_data_size / channels;

float* weight = innerproduct->weight_data; // 指向 FC 权重
float* bias = innerproduct->bias_data; // 指向 FC 偏置

// 4. 遍历每个输出单元 (channel),更新权重和偏置
for (int ch = 0; ch < channels; ch++)
{
// 获取指向连接到当前输出单元 ch 的所有权重的指针
float* fc_weight_outch = weight + weight_per_outch * ch;
// 将连接到该输出单元的所有权重乘以 b[ch]
for (int k = 0; k < weight_per_outch; k++)
{
fc_weight_outch[k] *= b[ch]; // W'_fc = W_fc * b
}
// 更新偏置 b'_fc = b_fc * b + a
bias[ch] = bias[ch] * b[ch] + a[ch];
}
} // --- 参数变换结束 ---

// --- 图结构修改 (标准融合操作) ---
int top_blob_index_final = batchnorm->tops[0];
innerproduct->tops[0] = top_blob_index_final;
blobs[top_blob_index_final].producer = i;
batchnorm->type = "ncnnfused";
// --- 图结构修改结束 ---
}
return 0;
}

关键点:

  • 模式匹配: 查找 InnerProduct -> BatchNorm 的直接连接。
  • 参数计算: 计算 a, b 系数的逻辑不变。
  • 权重更新: InnerProduct 的权重 W 通常是 [num_output, num_input] 的形状。weight_per_outch 在这里代表 num_input。代码正确地将连接到第 ch 个输出单元的所有输入权重(即权重矩阵的第 ch 行)乘以该输出通道对应的 b[ch]
  • 偏置更新: 将偏置 bias[ch] 更新为 bias[ch] * b[ch] + a[ch]
  • 图修改: 标准的重定向连接 + 标记融合操作。

4. 意义:优化全连接部分

虽然现代 CNN 架构越来越倾向于使用全局平均池化(Global Average Pooling)后直接接一个 InnerProduct 层进行分类,减少了全连接层的比重,但在某些网络或特定任务中,仍然可能存在 InnerProduct 后接 BatchNorm 的结构(例如,某些旧的网络设计,或者在需要对全连接层输出进行归一化的场景)。

fuse_innerproduct_batchnorm Pass 确保了 ncnn 对这种情况也能进行优化,消除了不必要的 BN 计算,对于包含此类结构的模型可以带来性能提升。


5. 结语

fuse_innerproduct_batchnorm 将 ncnn 中成熟的 BatchNorm 融合策略扩展到了全连接层 (InnerProduct)。其实现与卷积层 BN 融合高度一致,再次证明了合并连续线性变换是图优化中一条普遍适用的核心原则。通过系统性地覆盖各种层类型(卷积、反卷积、深度变体、全连接)与 BatchNorm 的融合,ncnnoptimize 工具能够有效地“压缩”网络计算图,减少冗余操作,为模型在资源受限环境下的高效推理提供有力保障。

该封面图片由Cristian OlteanPixabay上发布