读 ncnn 源码(XXXVI):算子消除——eliminate_dropout/pooling1x1/noop/split

ncnnoptimize 的图优化工具箱中,除了“算子融合”(如 Conv+BN)和“算子替换”(如 PReLU->ReLU),还有一类至关重要的优化 Pass:算子消除 (Operator Elimination)。这类优化的目标是识别并彻底移除那些在推理阶段计算无效结构冗余的层。

本篇,我们将集中分析 eliminate_dropout, eliminate_pooling1x1, eliminate_noop, 和 eliminate_split 这四个函数。它们虽然处理的层类型不同,但其核心思想和图修改手法几乎完全一致:找到“无效层”,并将其从计算图中“短路”掉

TL;DR

  1. 目标: 识别并消除四种在特定条件下对计算图无实际贡献的层:Dropout(推理时)、Pooling(1x1, s1)NoopSplit(单路输出)。
  2. 核心原理 (Identity Mapping): 这些层在特定配置下都退化成了恒等映射 (Identity Mapping),即 Output = Input。它们的存在除了引入额外的层调度和内存访问外,对计算结果毫无贡献。
  3. 模式匹配: 遍历 layers,查找目标层类型,并检查其退化为恒等映射的关键条件
    • eliminate_dropout: dropout->scale == 1.f(这是 Inverted Dropout 在推理时的标准行为)。
    • eliminate_pooling1x1: kernel=1x1, stride=1x1, pad=0, 且 global_pooling
    • eliminate_noop: 层类型为 NoopNoop 层本身就是恒等映射,用于占位)。
    • eliminate_split: Split 层的有效消费者(consumer != -1不多于 1 个
  4. 图结构修改 (Short-Circuiting): 这是所有消除类 Pass 的通用手法:
    • 找到被消除层 L唯一输入 Blob (bottom_blob_index)。
    • 向上游反向查找到该 blob生产者层 Any
    • 找到 L 层的唯一有效输出 Blob (top_blob_index_final)。
    • 执行短路:将 Any 层的输出 top 直接修改为 top_blob_index_final
    • 更新 top_blob_index_finalproducerAny 层的索引 j
    • L 层(Dropout, Pooling, Noop, Split)的 type 标记为 "ncnnfused",以便在推理时跳过。
  5. 效果: 彻底从计算图中移除了冗余节点,减少了层调度的开销、消除了中间 blob 的内存读写,使得计算图更加紧凑和高效。

1. 算子消除的动机:移除“恒等映射”

在推理阶段,网络中任何等价于 Output = Input 的操作都是冗余的。ncnnoptimize 通过一系列 Pass 来专门清除这些“路障”。

  • Dropout: 如第 29 篇所述,推理时 scale == 1.0(Inverted Dropout)的 Dropout 层就是恒等映射。
  • Pooling(k=1, s=1): 一个 1x1 的池化核,以 1 为步长滑动,不进行任何补边,其唯一的计算就是 output[x,y] = input[x,y](无论是 Max 还是 Avg),这显然是恒等映射。
  • Noop: “No Operation” 层,它被设计出来就是作为占位符或调试锚点,其 forward 实现就是直接返回输入,是纯粹的恒等映射。
  • Split (单路输出): Split 层的本意是将一个输入复制到多个输出分支。如果经过其他优化 Pass(例如分支剪枝)后,这个 Split 层只剩下一个或零个有效消费者,那么它就退化成了一个简单的“直通”管道,同样是恒等映射。

2. 模式匹配:识别“恒等”条件

这四个函数的前半部分都是在进行模式匹配,以确认目标层是否满足“恒等映射”的退化条件。

eliminate_dropout:

1
2
3
4
5
6
// ... (find Dropout layer i) ...
ncnn::Dropout* dropout = (ncnn::Dropout*)layers[i];
// 核心条件:scale 必须为 1.0
if (dropout->scale != 1.f)
continue;
// ... (find producer 'any' at index j) ...

eliminate_pooling1x1:

1
2
3
4
5
6
7
8
9
10
// ... (find Pooling layer i) ...
ncnn::Pooling* pooling = (ncnn::Pooling*)layers[i];
// 核心条件:必须是 1x1 kernel, 1x1 stride, 0 padding, 且非 global pooling
if (pooling->pad_left != 0 || pooling->pad_right != 0 || pooling->pad_top != 0 || pooling->pad_bottom != 0)
continue;
if (pooling->kernel_w != 1 || pooling->kernel_h != 1 || pooling->stride_w != 1 || pooling->stride_h != 1)
continue;
if (pooling->global_pooling != 0)
continue;
// ... (find producer 'any' at index j) ...

eliminate_noop:

1
2
3
4
5
// ... (find Noop layer i) ...
ncnn::Layer* noop = layers[i];
if (noop->bottoms.empty()) { /* ... (处理无输入 Noop) ... */ continue; }
// 核心条件:类型为 "Noop" 本身就是条件
// ... (find producer 'any' at index j) ...

eliminate_split:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... (find Split layer i) ...
ncnn::Layer* split = layers[i];
int real_split_output_count = 0;
int real_split_top_blob_index = -1;
// 遍历所有输出 blob
for (size_t j = 0; j < split->tops.size(); j++) {
int top_blob_index_final = split->tops[j];
if (blobs[top_blob_index_final].consumer != -1) { // 检查是否有消费者
real_split_output_count += 1;
real_split_top_blob_index = j; // 记录最后一个有效输出
}
}
// 核心条件:有效输出不多于 1 个
if (real_split_output_count > 1)
continue;
// ... (find producer 'any' at index j) ...

3. 图结构修改:通用的“短路” (Short-Circuiting) 手法

一旦模式匹配成功,所有这四个函数都会执行一套几乎完全相同的图修改逻辑,即“短路”掉当前层。

通用逻辑 (以 eliminate_pooling1x1 为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 0. 确认匹配成功
ncnn::Layer* any = layers[j]; // 生产者层 (索引 j)
ncnn::Pooling* pooling = (ncnn::Pooling*)layers[i]; // 待消除层 (索引 i)

fprintf(stderr, "eliminate_pooling1x1 %s %s\n", any->name.c_str(), pooling->name.c_str());

// 1. 获取 pooling 层的输出 blob 索引
int top_blob_index_final = pooling->tops[0];

// 2. 将生产者 any 的输出 (any->tops[top_i])
// 直接连接到 pooling 层的输出 blob
any->tops[top_i] = top_blob_index_final;

// 3. 更新该 blob 的生产者信息,指向 any 层 (索引 j)
blobs[top_blob_index_final].producer = j;

// 4. 标记 pooling 层为无效
pooling->type = "ncnnfused";

图示:

  • 优化前: Layer[j] -> Blob[A] -> Layer[i] (e.g., Pooling1x1) -> Blob[B] -> …
  • 优化后: Layer[j] -> Blob[B] -> …
  • Blob[A] 变为孤岛 (producer=j, consumer=-1),Layer[i] 变为 "ncnnfused"

eliminate_split 的逻辑类似,它将 any->tops[top_i] 直接连接到 split->tops[real_split_top_blob_index]Split 唯一的有效输出)。eliminate_noop 也是如此。


4. 结语

eliminate_dropout, eliminate_pooling1x1, eliminate_noop, 和 eliminate_split 共同展示了 ncnn 图优化中“算子消除”这一重要策略。这些 Pass 专注于识别并移除计算图中的“恒等映射”节点。

它们的核心实现(模式匹配 + 图结构短路)高度一致,体现了图优化的通用范式。通过移除这些冗余层,ncnnoptimize 不仅减少了运行时的层调度开销,更重要的是消除了不必要的中间 blob 内存读写,使得计算图更加紧凑、高效,为端侧推理的极致性能奠定了坚实基础。