读 ncnn 源码(XL):算子替换——当“卷积”退化为“全连接”

ncnnoptimize 的优化策略中,“算子替换”是提升性能的关键手段之一。我们之前分析的 replace_prelu_with_leaky_relu 是一个典型。本篇,我们将深入探讨 replace_convolution_with_innerproduct_after_global_poolingreplace_convolution_with_innerproduct_after_innerproduct 这两个 Pass,它们联手优化了神经网络(尤其是分类头部)中一个极其常见的低效模式。

TL;DR

  1. 目标: 识别并替换 GlobalAveragePooling -> ConvolutionInnerProduct -> Convolution 这样的计算模式。
  2. 核心原理 (语义等价): GlobalAveragePooling (GAP) 和 InnerProduct (IP) 的输出都是空间维度为 1x1 的张量(即 [1, 1, C][N, 1, 1])。当一个 Convolution 层(通常是 1x1 卷积)接收这种“扁平”的 1x1xC 张量作为输入时,其“滑动窗口”操作实际上已经退化,在数学上完全等价于一个 InnerProduct (全连接) 操作。
  3. 性能考量: Convolution 是一个通用的、重型的层,其 forward 实现需要处理复杂的空间滑动、padding、stride、dilation 等逻辑。而 InnerProduct 是一个高度特化和优化的层,其 forward 实现本质上是一个轻量级的 GEMM (通用矩阵乘法)。InnerProduct 替换在 1x1 输入上执行的 Convolution 是一次巨大的性能胜利
  4. 替换动作: ncnnoptimize 创建一个全新的 InnerProduct 层,然后将原 Convolution 层的所有参数(num_output, bias_term …)和权重数据(weight_data, bias_data, activation_params …)逐一迁移到新 InnerProduct 层中。
  5. 图结构修改: 在 layers 列表中,用新创建的 InnerProduct 层替换掉原有的 Convolution 层,并释放 Convolution 对象的内存。
  6. ...after_innerproduct 的特殊性: 此 Pass 使用 for(;;) 无限循环,每次对网络进行全量扫描和替换,直到某一次扫描不再发生任何替换为止。这是为了处理 IP -> Conv -> Conv -> ... 这样的长链条,确保它们被迭代地替换为 IP -> IP -> IP -> ...

1. 融合动机:当卷积不再“卷”

Convolution 层的核心是其空间“卷积”特性——即滑动一个 N x M 的窗口在 W x H 的特征图上进行运算。然而,在现代 CNN 的分类头部,GlobalAveragePooling (GAP) 层被广泛用于替代传统的 Flatten + InnerProduct

GAP 层的输出是一个 [1, 1, Channels] 的张量。如果这个张量(或者另一个 InnerProduct[N, 1, 1] 输出)被送入一个 Convolution 层(几乎总是 1x1 卷积,k=1, s=1, p=0),那么这个 Convolution 层的“滑动窗口”在 1x1 的输入上根本无法滑动。

  • Conv(k=1x1)[W, H, C_in] 上的计算:Output[x, y, c_out] = sum(Input[x, y, c_in] * W[c_out, c_in, 0, 0]) + Bias[c_out]
  • W=1, H=1 时,上述计算变为:Output[0, 0, c_out] = sum(Input[0, 0, c_in] * W[c_out, c_in, 0, 0]) + Bias[c_out]

这在数学上与 InnerProduct 层的 Output[c_out] = sum(Input[c_in] * W[c_out, c_in]) + Bias[c_out] 完全等价

既然计算是等价的,ncnnoptimize 就会选择实现更轻量、优化更极致InnerProduct 层来替换这个“大材小用”的 Convolution 层。


2. 源码解析:replace_..._after_global_pooling

这个 Pass 处理 GAP -> Conv 的情况。

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
int NetOptimize::replace_convolution_with_innerproduct_after_global_pooling()
{
const size_t layer_count = layers.size();
for (size_t i = 0; i < layer_count; i++)
{
// 1. 模式匹配:找到 Global Pooling
if (layers[i]->type != "Pooling") continue;
ncnn::Pooling* pooling = (ncnn::Pooling*)layers[i];
if (pooling->global_pooling == 0) continue; // 必须是 Global Pooling

// 2. 模式匹配:查找紧随其后的 Convolution
int top_blob_index = layers[i]->tops[0];
size_t j = i + 1;
// ... (find Convolution j) ...
if (j == layer_count) continue;

ncnn::Convolution* convolution = (ncnn::Convolution*)layers[j];
fprintf(stderr, "replace_convolution_with_innerproduct_after_global_pooling %s %s\n", pooling->name.c_str(), convolution->name.c_str());

// 3. 创建新的 InnerProduct 替代层
ncnn::InnerProduct* innerproduct = (ncnn::InnerProduct*)ncnn::create_layer_cpu("InnerProduct");

// 4. 继承图连接关系
innerproduct->type = "InnerProduct";
innerproduct->name = convolution->name;
innerproduct->bottoms = convolution->bottoms; // 输入不变 (来自 GAP)
innerproduct->tops = convolution->tops; // 输出不变

ncnn::ParamDict pd;
innerproduct->load_param(pd); // 加载空参数

// 5. 核心:参数与权重迁移
// 将 Convolution 的所有参数原封不动地迁移给 InnerProduct
innerproduct->num_output = convolution->num_output;
innerproduct->bias_term = convolution->bias_term;
innerproduct->weight_data_size = convolution->weight_data_size;
innerproduct->int8_scale_term = convolution->int8_scale_term;

// 权重和偏置 Mat 也是直接“交接”
innerproduct->weight_data = convolution->weight_data;
innerproduct->bias_data = convolution->bias_data;
// ... (迁移 INT8 和 activation 参数) ...
innerproduct->activation_type = convolution->activation_type;
innerproduct->activation_params = convolution->activation_params;

// 6. 图结构修改:用新层替换旧层
layers[j] = innerproduct;
delete convolution; // 销毁原 Convolution 对象
}
return 0;
}

关键点InnerProduct 层和 Convolution 层(当 k=1, h=1 时)的权重布局是兼容的(都是 [num_output, num_input][num_output, num_input, 1, 1]),因此 weight_databias_data 可以直接迁移,无需重新计算,开销极小。


3. 源码解析:replace_..._after_innerproduct

这个 Pass 处理 IP -> Conv 的情况,其替换逻辑与 ..._after_global_pooling 完全相同,唯一的区别在于它使用了一个迭代循环

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
int NetOptimize::replace_convolution_with_innerproduct_after_innerproduct()
{
const size_t layer_count = layers.size();
for (;;) // 迭代执行,直到没有替换发生
{
bool replaced = false;

for (size_t i = 0; i < layer_count; i++)
{
// 1. 模式匹配:找到 InnerProduct
if (layers[i]->type != "InnerProduct") continue;

// 2. 模式匹配:查找紧随其后的 Convolution
// ... (find Convolution j) ...
if (j == layer_count) continue;

// ... (获取 innerproduct 和 convolution 指针) ...
fprintf(stderr, "replace_convolution_with_innerproduct_after_innerproduct %s %s\n", ...);

// 3. 创建新的 InnerProduct (innerproduct2)
ncnn::InnerProduct* innerproduct2 = (ncnn::InnerProduct*)ncnn::create_layer_cpu("InnerProduct");

// 4. 继承图连接关系 (同上)
// 5. 参数与权重迁移 (同上)
// ...

// 6. 图结构修改
layers[j] = innerproduct2;
delete convolution;

replaced = true; // 标记本轮发生了替换
}

if (!replaced)
break; // 如果一整轮都没有发生替换,则优化收敛,退出循环
}

return 0;
}

为什么需要迭代? 考虑一个 GAP -> Conv1 -> Conv2 -> Conv3 的结构。

  • 第一轮: GAP -> Conv1replace_..._after_global_pooling 替换为 GAP -> IP1
  • 第二轮: 网络变为 GAP -> IP1 -> Conv2 -> Conv3
  • replace_..._after_innerproduct 第一轮: 匹配到 IP1 -> Conv2,将其替换为 IP1 -> IP2。网络变为 GAP -> IP1 -> IP2 -> Conv3
  • replace_..._after_innerproduct 第二轮: 匹配到 IP2 -> Conv3,将其替换为 IP2 -> IP3。网络变为 GAP -> IP1 -> IP2 -> IP3
  • replace_..._after_innerproduct 第三轮: 未找到 IP -> Conv 模式,replaced = false,循环退出。

这个迭代循环确保了整个由 1x1 卷积构成的分类头都能被完整地转换为 InnerProduct 链。


4. 结语

replace_convolution_with_innerproduct_... 系列 Pass 是 ncnnoptimize 中基于语义等价进行算子替换的典范。它深刻理解 Convolution 在特定输入(1x1xC)下的计算退化,并果断将其替换为专为此类计算(GEMM)而生的 InnerProduct 层。这种优化对于现代 CNN 模型的分类头(通常是 GAP + 1x1 Conv)具有显著的性能提升,它避免了 Convolution 层复杂的泛用逻辑,转而执行轻量、高效的矩阵乘法,是 ncnn 实现极致性能的又一利器。