读 ncnn 源码(XXXVIII):消除冗余塑形——eliminate_flatten/reshape 的瘦身之道

ncnnoptimize 的图优化工具箱中,“算子消除”是一个重要分支。它旨在移除那些对计算结果没有贡献的冗余层。Dropout(推理时)、Pooling(1x1)Noop 是因其“恒等映射”特性而被消除的典型。本篇,我们将分析另一类可被消除的层:ReshapeFlatten,它们在特定上下文中同样是“恒等映射”。

eliminate_..._after_global_poolingeliminate_flatten_after_innerproduct 这一系列 Pass 关注的就是 GlobalAveragePoolingInnerProduct 之后紧跟的 FlattenReshape 操作。

TL;DR

  1. 目标: 识别并消除 GlobalAveragePooling -> Flatten/ReshapeInnerProduct -> Flatten 这样的冗余塑形模式。
  2. 核心原理 (语义上的 No-op):
    • GlobalAveragePooling: 其输出 blob 的形状为 [1, 1, C](或 ncnn Matw=1, h=1, c=channels)。
    • InnerProduct: 其输出 blob 的形状为 [num_output, 1, 1](或 ncnn Matw=num_output, h=1, c=1)。
    • Flatten / Reshape (to 1D): 这些层的作用是将多维张量“拍平”成一维向量。
    • 结论: GlobalAveragePoolingInnerProduct 的输出在逻辑上已经是“平”的(一维向量)。对一个已经是一维(或 1x1xC)的张量再次执行 Flatten 或等效的 Reshape,是一个语义上的冗余操作(Semantic No-op)
  3. 模式匹配:
    • eliminate..._after_global_pooling: 查找 Pooling 层,并检查 pooling->global_pooling == 1。然后查找其后紧跟的 Flatten 层或特定参数的 Reshape 层(如 reshape->h == -233 && reshape->c == -233,这在 ncnn 中常用于表示“拍平”)。
    • eliminate_flatten_after_innerproduct: 查找 InnerProduct 层,然后查找其后紧跟的 Flatten 层。
  4. 图结构修改 (短路): 这三个函数均采用我们在第 36 篇中分析过的标准“短路”(Short-Circuiting)手法。将前驱层(PoolingInnerProduct)的 top blob 直接指向后继层(Flatten/Reshape)的 top blob,并更新 blobproducer,最后将 Flatten/Reshape 层标记为 "ncnnfused"
  5. 效果: 移除了冗余的层调度和元数据操作,简化了计算图,使网络结构更清晰。

1.融合动机:当塑形(Reshape/Flatten)遇到“已塑形”

FlattenReshape 层的作用是改变张量的逻辑视图,通常不涉及实际的计算(在 ncnn 中,如果内存连续,reshape 只是修改元数据,是零拷贝操作)。但即使是零拷贝,它在计算图中仍然是一个独立的层,会带来层调度的开销。

ncnnoptimize 敏锐地识别到,某些层的输出在语义上已经是“扁平”的:

  • Pooling(global_pooling=1): 无论输入 [W, H, C] 有多大,输出都是 [1, 1, C]
  • InnerProduct: 无论输入是什么,输出都是 [num_output, 1, 1]

当这些层后面跟一个 Flatten 层时(Flatten 的目标是产生一个 [W*H*C, 1, 1] 的向量),Flatten 实际上什么也不用做,因为输入 [1, 1, C] 拍平后还是 [C, 1, 1](在 InnerProduct 情况下同理)。这种操作是完全多余的。

为什么这个“多余”的层会出现?

在 PyTorch 或 TensorFlow 中,模型的定义非常严格:

  1. 一个 GlobalAveragePooling 层的输出维度是 [N, C, 1, 1]
  2. 一个 InnerProduct (全连接, nn.Linear) 层严格要求它的输入是2D的,即 [N, Features]

因此,模型作者必须在两者之间手动插入一个 Flatten 操作,把 [N, C, 1, 1] 显式地“压平”成 [N, C]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# PyTorch 示例
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.pool = nn.AdaptiveAvgPool2d((1, 1))
# nn.Linear 要求输入是 [N, 512]
self.fc = nn.Linear(512, 1000)

def forward(self, x):
x = self.pool(x) # x 的维度是 [N, 512, 1, 1]

# 必须的“官僚主义”步骤
# 否则 self.fc(x) 会报错
x = torch.flatten(x, 1) # x 的维度变成 [N, 512]

x = self.fc(x)
return x

当这个模型被导出到 ONNX 时,ONNX 会忠实地记录下这个结构: [Pooling] -> [Flatten] -> [InnerProduct]


为什么 ncnn 认为它“多余”?

ncnnInnerProduct 层实现得更“智能”或者说更“灵活”。

ncnn::InnerProduct 层的设计哲学是:“我不在乎你输入的是几维,我只管把它‘隐式地’(implicitly)拍平,然后做矩阵乘法。”

  • 你给 InnerProduct 输入 [N, C, 1, 1]
    • 它会把它当作 [N, C*1*1] (即 [N, C]) 来处理。
  • 你给 InnerProduct 输入 [N, C, 7, 7]
    • 它会把它当作 [N, C*7*7] 来处理。

这就是关键点:对于 ncnn::InnerProduct 层来说,[Flatten]完全是多余的InnerProduct 层自己就内置了“拍平”这个动作。


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

eliminate_flatten_after_global_poolingeliminate_reshape_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
int NetOptimize::eliminate_flatten_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. 模式匹配:查找紧随其后的 Flatten
int top_blob_index = layers[i]->tops[0];
size_t j = i + 1;
for (; j < layer_count; j++)
{
if (layers[j]->type != "Flatten") continue;
if (layers[j]->bottoms.size() != 1) continue;
if (layers[j]->bottoms[0] == top_blob_index) break; // 确认连接
}
if (j == layer_count) continue; // 未找到

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

// 3. 图结构修改 (短路)
int top_blob_index_final = flatten->tops[0]; // 获取 Flatten 的输出 blob
pooling->tops[0] = top_blob_index_final; // 将 Pooling 的输出直接指向它
blobs[top_blob_index_final].producer = i; // 更新 blob 生产者为 Pooling (索引 i)
flatten->type = "ncnnfused"; // 标记 Flatten 为无效
}
return 0;
}

eliminate_reshape_after_global_pooling 的逻辑与此一致,只是将 type != "Flatten" 替换为 type != "Reshape",并增加了对 Reshape 参数的检查 (reshape->h == -233 && reshape->c == -233),以确保它是一个执行“拍平”操作的 Reshape


3. 源码解析:eliminate_flatten_after_innerproduct

这个 Pass 的逻辑与 eliminate_flatten_after_global_pooling 完全相同,只是将生产者层的类型从 Pooling 换成了 InnerProduct

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
int NetOptimize::eliminate_flatten_after_innerproduct()
{
const size_t layer_count = layers.size();
for (size_t i = 0; i < layer_count; i++)
{
// 1. 模式匹配:找到 InnerProduct
if (layers[i]->type != "InnerProduct")
continue;

// 2. 模式匹配:查找紧随其后的 Flatten
int top_blob_index = layers[i]->tops[0];
size_t j = i + 1;
// ... (与上面完全相同的 for 循环查找 Flatten) ...
if (j == layer_count) continue;

ncnn::InnerProduct* innerproduct = (ncnn::InnerProduct*)layers[i];
ncnn::Flatten* flatten = (ncnn::Flatten*)layers[j];
fprintf(stderr, "eliminate_flatten_after_innerproduct %s %s\n", innerproduct->name.c_str(), flatten->name.c_str());

// 3. 图结构修改 (短路)
int top_blob_index_final = flatten->tops[0];
innerproduct->tops[0] = top_blob_index_final;
blobs[top_blob_index_final].producer = i;
flatten->type = "ncnnfused";
}
return 0;
}

4. 结语

这一系列 eliminate_... Pass 展现了 ncnnoptimize 在图优化层面上的“智能”。它不仅能处理代数层面的融合(如 Conv+BN),还能理解算子在形状(Shape)语义上的冗余

GlobalAveragePoolingInnerProduct 的输出在逻辑上已经是 1D 向量,任何后续的 Flatten 或等效 Reshape 操作都是不必要的。通过“短路”这些冗余的塑形层,ncnnoptimize 进一步简化了计算图,减少了不必要的层调度开销,使得最终执行的网络更加精简高效。