读 ncnn 源码(XLII):ncnnoptimize 的“编排艺术”——优化 Pass 的依赖与顺序 (ncnnoptimize 完结篇)

ncnnoptimize 系列的前序篇章中,我们已经像解剖学家一样,逐一分析了 fuse_...(融合)、eliminate_...(消除)和 replace_...(替换)三大类优化 Pass 的具体实现。我们理解了 Conv+BN 融合的代数原理,eliminate_dropout 对推理的净化,以及 Conv->IP 替换的语义等价性。

现在,是时候退后一步,从 ncnnoptimize.cppmain 函数视角,欣赏这出优化大戏是如何编排的。main 函数中那几十行看似平铺直叙的 optimizer.fuse_...() 调用,绝非随意的罗列,而是一个经过精心设计的、存在严格依赖关系多遍(Multi-Pass)优化流水线。本篇,我们就来揭示这个“编排”背后的工程智慧。

TL;DR

  1. ncnnoptimize 的本质: 它是一个多遍(Multi-Pass)图优化编译器main 函数定义了所有优化 Pass 的固定执行顺序
  2. 顺序的意义: 优化 Pass 之间存在依赖关系。某些 Pass 的执行会创造出可供后续 Pass 优化的新模式,或者依赖于前序 Pass 完成的清理工作。
  3. 流水线阶段 (Pipeline Stages):
    • 阶段 1:代数融合 (Algebraic Fusion): fuse_..._batchnorm/mul/add。此阶段专注于合并连续的线性计算,将 BNScaleMulAdd 等操作的参数“烘焙”进 Conv / IP 等计算密集型层。这是最基础、最重要的优化。
    • 阶段 2:语义替换与非线性融合 (Semantic & Activation Fusion): replace_prelu..., fuse_..._activation。此阶段处理非线性激活函数,并替换低效模式。fuse_..._activation 必须在代数融合之后,以便将激活操作融合到已经吸收了 BN 等参数的“最终版” Conv / IP 层中。
    • 阶段 3:冗余消除 (Redundancy Elimination): eliminate_dropout, _pooling1x1, _noop, _split, _reshape...。此阶段负责清理所有语义上的“恒等映射”层。
    • 阶段 4:高级替换 (High-Level Replacement): replace_convolution_with_innerproduct...。此 Pass 在图结构基本清理干净后执行,将 GAP -> ConvIP -> Conv 这种“大材小用”的模式替换为更轻量的 IP
    • 阶段 5:最终清理 (Final Cleanup): eliminate_flatten_after_innerproduct, eliminate_orphaned_memorydata。此阶段负责**“收尾”,清理掉因前序 Pass(如阶段 4)而新产生**的冗余(如 IP 后的 Flatten)或“孤儿”节点。
    • 阶段 6:终结 (Finalization): shape_inference() 必须在所有图结构修改之后、save() 之前运行。它通过一次“沙盘推演”来重新计算所有 blob 的形状,更新 layerbottom/top_shapes,确保 .param 文件的正确性。
  4. main 函数就是这个依赖图的“拓扑排序”:它确保了每一项优化都能在其依赖项已完成后执行,从而实现最大化的优化效果。


1. 优化流水线:一个精心编排的剧本

ncnnoptimizemain 函数(精简后)的执行顺序如下:

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
int main(...)
{
// ... 1. 加载模型 ...
NetOptimize optimizer;
optimizer.load_param(inparam);
optimizer.load_model(inbin);
optimizer.set_cutparam(...);

// --- 阶段 1: 基础代数融合 ---
optimizer.fuse_batchnorm_scale();
optimizer.fuse_convolution_batchnorm();
optimizer.fuse_convolution_mul();
optimizer.fuse_convolution_add();
// ... (所有 ConvDW, Deconv, DeconvDW, IP 的 BN/Mul/Add 融合) ...
optimizer.fuse_innerproduct_dropout(); // (Dropout 也是一种线性缩放)

// --- 阶段 2: 语义替换与非线性融合 ---
optimizer.replace_reduction_with_global_pooling();
optimizer.replace_prelu_with_leaky_relu();
optimizer.fuse_convolution_activation();
// ... (所有 ConvDW, Deconv, DeconvDW, IP 的 Activation 融合) ...
optimizer.fuse_memorydata_binaryop(); // (常量折叠)
optimizer.fuse_binaryop_eltwise(); // (模式替换)

// --- 阶段 3: 冗余消除 ---
optimizer.eliminate_dropout();
optimizer.eliminate_pooling1x1();
optimizer.eliminate_noop();
optimizer.eliminate_split();
optimizer.eliminate_flatten_after_global_pooling();
optimizer.eliminate_reshape_after_global_pooling();
optimizer.eliminate_reshape_before_binaryop();

// --- 阶段 4: 高级语义替换 ---
optimizer.replace_convolution_with_innerproduct_after_global_pooling();
optimizer.replace_convolution_with_innerproduct_after_innerproduct();

// --- 阶段 5: 最终清理 ---
optimizer.eliminate_flatten_after_innerproduct();
optimizer.eliminate_orphaned_memorydata();

// --- 阶段 6: 终结与保存 ---
optimizer.shape_inference();
optimizer.estimate_memory_footprint();
optimizer.save(outparam, outbin);
}

2. 依赖解析:为什么必须是这个顺序?

阶段 1 (代数融合) 必须在 阶段 2 (激活融合) 之前

  • 依赖关系: fuse_convolution_activation 依赖于 fuse_convolution_batchnorm

  • 原因: Conv -> BN -> ReLU 是最常见的模式。

    1. fuse_convolution_batchnorm (阶段 1) 先执行,将 Conv -> BN 融合成一个新的 Conv'
    2. 此时计算图变为 Conv' -> ReLU
    3. fuse_convolution_activation (阶段 2) 接着执行,才能匹配到 Conv' -> ReLU 模式,并将其融合为 Conv'(with_activation)
    • 如果顺序颠倒,fuse_convolution_activation 会因为 Conv 后面跟的是 BN 而不是 ReLU 而匹配失败,导致融合失效。

阶段 3 (消除) 必须在 阶段 1 & 2 (融合) 之后

  • 依赖关系: eliminate_split / eliminate_orphaned_memorydata 依赖于 fuse_..._activation / fuse_memorydata_binaryop
  • 原因 (Dead Code Elimination):
    • eliminate_split:考虑一个 Split 层,其两个输出分别送往 ReLU_AReLU_B。在阶段 3 开始时,Split 有两个有效消费者,不能被消除。
    • fuse_..._activation (阶段 2) 执行,将 ReLU_AReLU_B 都标记为 "ncnnfused"
    • eliminate_split (阶段 3) 再次执行时,它检查 Split 的两个输出 blob,发现它们的消费者(ReLU_AReLU_B)都已经是 "ncnnfused"。它判定 Split 不再有任何有效消费者,因此可以安全地将其消除。
    • eliminate_orphaned_memorydata 同理,它必须在 fuse_memorydata_binaryopMemoryData “孤立”之后运行,才能将其“回收”。

阶段 4 (高级替换) 必须在 阶段 1 & 2 之后

  • 依赖关系: replace_convolution_with_innerproduct... 依赖于 fuse_convolution_activation
  • 原因 (继承优化): Conv 层和 IP 层内部都有 activation_typeactivation_params 成员。
    1. 图优化首先处理 GAP -> Conv -> ReLU
    2. fuse_convolution_activation (阶段 2) 将 ReLU 融合进 ConvConv 层的 activation_type 被设为 1。
    3. replace_convolution_with_innerproduct_... (阶段 4) 执行,匹配到 GAP -> Conv(with_activation) 模式。
    4. 在执行替换时,它会创建一个新的 IP 层,并将 Conv 层的所有参数(包括 activation_type=1完整地迁移IP 层。
    5. 最终,图被优化为 GAP -> IP(with_activation),实现了优化的传递和最大化。

阶段 5 (最终清理) 必须在 阶段 4 之后

  • 依赖关系: eliminate_flatten_after_innerproduct 依赖于 replace_convolution_with_innerproduct...
  • 原因 (清理新产生的冗余):
    1. 原始图可能是 GAP -> Conv -> Flatten
    2. replace_..._after_global_pooling (阶段 4) 执行,将 Conv 替换为 IP,图变为 GAP -> IP -> Flatten
    3. 此时,eliminate_flatten_after_innerproduct (阶段 5) 才能匹配到 IP -> Flatten 这一新产生的冗余模式,并将其消除。

阶段 6 (终结) 必须在所有图修改之后

  • 依赖关系: shape_inference / save 依赖于 之前所有的图优化 Pass。
  • 原因: 所有的融合、消除、替换操作都严重破坏Net 内部 blobslayers 中存储的形状信息 (shape, bottom_shapes, top_shapes) 和连接关系 (tops, bottoms) 的一致性。
  • shape_inference (如第 41 篇所述) 是必不可少的“状态重整”步骤。它通过一次“沙盘推演”重新计算所有 blob 的形状,并回填到 layer 中。
  • save 必须在 shape_inference 之后,才能将这个最终的、一致的、优化后的图结构正确地写入新的 .param.bin 文件。

5. 结语 (ncnnoptimize 篇章完结)

ncnnoptimizemain 函数不仅是一个简单的脚本,它是一个精心设计的图优化流水线。这个流水线通过依赖排序,确保了优化 Pass 能够以最高效的方式协同工作:从底层的代数合并(Conv+BN)开始,到中层的语义融合(Conv+ReLU),再到高层的模式替换(Conv->IP),最后通过多轮的清理(eliminate...)和终结(shape_inference)来确保图的简洁性与正确性。

通过对这一系列 Pass 的深入分析,我们不仅理解了 ncnn 如何实现单一的优化技巧,更重要的是,我们窥见了 ncnn 如何将这些技巧“编排”成一个鲁棒、高效的自动化优化流程。这种分阶段、有依赖、层层递进的优化思想,是所有现代深度学习编译器(如 TVM, MLIR)的核心,也是 ncnnoptimize 能够化腐朽为神奇,为端侧推理带来显著性能提升的根本原因。