读 ncnn 源码(XXXVII):eliminate_orphaned_memorydata——图优化的“垃圾回收”

ncnnoptimize 的图优化篇章中,我们已经见证了多种“算子融合”和“算子消除” Pass。这些优化操作(如 fuse_memorydata_binaryop)在将计算合并、烘焙参数的同时,往往会使原图中的某些层(如 MemoryData)的输出不再被任何其他层所需要,从而使其成为“孤儿”节点。

本篇,我们将剖析 eliminate_orphaned_memorydata 这一优化 Pass,它扮演着图优化流水线中**“垃圾回收器”(Garbage Collector)**的角色,专门负责清理这些因上游优化而产生的冗余数据层。

TL;DR

  1. 目标: 识别并消除那些输出不再被任何有效层(non-fused)所消费MemoryData 层。
  2. 动机: 这是一个清理(Cleanup)Pass。在 fuse_memorydata_binaryop, fuse_convolution_add 等 Pass 中,MemoryData 层(作为常量/偏置的提供者)的数据被“烘焙”到了消费者层(如 BinaryOp, Convolution)的参数中。这导致原有的 MemoryData -> Consumer 连接断开,如果该 MemoryData 没有其他消费者,它就变成了图中的“孤儿”,在推理时空跑一次毫无意义。
  3. 机制: 遍历所有 MemoryData 层(索引 i)。对每一个 MemoryData 层,向前扫描其后的所有层(索引 j > i),检查是否有任何一个有效层type != "ncnnfused")在其 bottoms 列表中引用了该 MemoryData 层的 top_blob
  4. 行动: 如果遍历完所有后续层j == layer_count)都没有找到任何一个消费者,则证明该 MemoryData 层是“孤儿”,将其 type 标记为 "ncnnfused" 以便在推理时跳过。
  5. 效果: 确保了优化后的计算图不会遗留任何无用的 MemoryData 节点,使得最终生成的 .param 文件更小、更干净,网络结构更简洁,减少了不必要的层调度和内存占用。

1. 优化的“副作用”:孤儿节点的产生

ncnnoptimize 执行图优化的过程中,许多融合 Pass 的核心是将一个“数据层”(如 MemoryData)或“计算层”(如 BatchNorm)的参数合并到另一个“计算层”(如 Convolution)中。

fuse_memorydata_binaryop 为例:

  • 优化前: MemoryData -> BinaryOp(Add)
  • 优化中: BinaryOpop_type 变为 Add with ScalarMemoryData 的标量值被“烘焙”进 BinaryOpb 参数中。BinaryOpMemoryData 的连接被断开。
  • 优化后: MemoryData 层依然存在于 layers 列表中,但它的输出 blob 已经没有任何消费者了。它成了一个“孤儿” (Orphaned) 节点。

这些孤儿节点虽然不影响计算结果,但它们仍然是计算图的一部分,会在推理时被调度执行(加载数据到 Mat),造成不必要的开销。


2. eliminate_orphaned_memorydata 的职责:清理“垃圾”

eliminate_orphaned_memorydata Pass 的职责就是遍历整个计算图,找出所有这些“只生产、不消费”的 MemoryData 层,并将它们标记为无效,从而在推理时彻底跳过它们。


3. 源码解析:一次前向扫描 (Forward Scan)

该函数的实现逻辑非常直接:

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
int NetOptimize::eliminate_orphaned_memorydata()
{
const size_t layer_count = layers.size();
for (size_t i = 0; i < layer_count; i++) // 遍历所有层
{
// 1. 找到一个 MemoryData 层
if (layers[i]->type != "MemoryData")
continue;

// 2. 获取其输出 blob
int top_blob_index = layers[i]->tops[0];

// 3. 向前扫描所有后续层 (j > i),查找消费者
size_t j = i + 1;
for (; j < layer_count; j++)
{
if (layers[j]->type == "ncnnfused") // 跳过已被融合的无效层
continue;

bool orphaned = true;
// 4. 检查当前层 j 是否消费了该 blob
for (size_t k = 0; k < layers[j]->bottoms.size(); k++)
{
if (layers[j]->bottoms[k] == top_blob_index)
{
orphaned = false; // 找到了消费者
break;
}
}

if (!orphaned)
break; // 停止内层循环,此 MemoryData 非孤儿
}

// 5. 判断扫描结果:
// 如果 j < layer_count,说明内层循环被 break,找到了消费者
if (j < layer_count)
continue; // 继续外层循环,检查下一个 MemoryData

// 6. 如果 j == layer_count,说明扫描到最后也未找到消费者
// 确认是孤儿,执行消除
fprintf(stderr, "eliminate_orphaned_memorydata %s\n", layers[i]->name.c_str());
layers[i]->type = "ncnnfused"; // 标记为无效
}

return 0;
}

关键点:

  • 前向扫描 (j = i + 1): 为什么只向前扫描?因为 ncnn 的计算图(在 load_param 后)是拓扑排序的,一个层的消费者必定位于其后。
  • 检查 "ncnnfused": 必须跳过已经被融合的层。否则,一个 MemoryData 的消费者 BinaryOp 就算被融合了 (type == "ncnnfused"),它在 bottoms 列表中依然保留着对 top_blob_index 的引用,这会造成误判。eliminate_orphaned_memorydata 只关心仍然有效的层是否在消费它。
  • 标记清除: 简单地将 type 设为 "ncnnfused",与所有其他消除/融合 Pass 保持一致。

4. 结语

eliminate_orphaned_memorydata 是一个关键的清理(Cleanup)Pass。它本身并不直接创造性能收益,而是作为其他优化 Pass(如 fuse_memorydata_binaryop)的“搭档”出现,负责“打扫战场”,清除优化后遗留的冗余数据节点。

这体现了 ncnnoptimize 作为一个鲁棒的图优化工具,不仅要“能融合、会替换”,还要“善清理”。通过这种方式,ncnn 确保了图优化的流水线是完整的,最终输出的 .param 文件是最小、最干净的,网络结构是简洁高效的。