读 ncnn 源码(Ⅴ):Param 读取闭环——从 token 到图,再到 I/O 名单

这一篇把 “ncnn 如何读取 .param 并把它变成一张可执行的计算图” 的最后一段细节补齐:
ParamDict 的数据布局 / 类型系统 / 读写语义;
② 迷你词法器 vstr_* 如何把字符串切成标量/数组;
Blob 的生产者/消费者是怎样被填充;
update_input_output_indexes/names 如何自动识别模型输入输出。
我们再用一段简化的 SqueezeNet 片段做“逐 token 实录”。


TL;DR

  • ParamDict 内部是一块 定长槽位数组NCNN_MAX_PARAM_COUNT):每个 id 对应一个 type + (i/f/v/s),类型码包括标量/数组/字符串。
  • load_param() 扫描 id=value / id=v1,v2,...,用 vstr_is_string/float 断型,vstr_to_float 手写解析。
  • 建图时,每个 layer 读完头(type/name/bottom/top)和 ParamDict 后,记录 各 blob 的 producer/consumer/name/shape
  • 最后 update_input_output_indexes()
    • 输入 = Input 层的 top;
    • 输出 = “有 producer 且无 consumer 的 blob”。
      update_input_output_names() 再把它们转成可读名字数组。


1) ParamDict:定长槽位与类型系统

ParamDict 外观是简洁的 get/set,内部是 ParamDictPrivate::params[NCNN_MAX_PARAM_COUNT] 的定长数组。每个槽位结构:

1
2
3
4
5
6
7
8
struct {
// 0=null; 1=int/float(兼容位); 2=int; 3=float;
// 4=array of int/float; 5=array of int; 6=array of float; 7=string
int type;
union { int i; float f; };
Mat v; // 存数组(int[]/float[])
std::string s; // 存字符串
} params[NCNN_MAX_PARAM_COUNT];

关键点:

  • 定长、按 id 定位:任何 id 的值都放在固定槽位(O(1) 存取),避免哈希开销。
  • get/set 的类型码
    • get(int id, int def) → 若 type!=0 返回 i
    • get(int id, float def) → 返回 f
    • get(int id, Mat def) → 返回 v
    • get(int id, std::string def) → 返回 s
    • set(...) 会把 type 改成对应类型码(2/3/4/7)。
  • 拷贝/赋值:只按类型复制相应字段(i/fvs),并保留 type
  • clear():把所有 type 置 0,i=0v=Mat()s.clear(),保证下一层解析是干净的。
  • union:这里的union设计可以节省内存。

2) load_param() 与小词法器:把文本吃干抹净

读取流程(简述):

  1. 反复 scan("%d=", &id) 取得下一个键 id;
  2. 读取第一个 token 到 vstr[16]
  3. 断型:
    • vstr_is_string(vstr) → 走字符串路径,拼接剩余字符到 stype=7
    • 否则看 vstr_is_float(vstr)(出现 .e/E 就认为是浮点);
  4. 看紧跟是否有逗号:若有,则进入 新式数组 模式(直到行尾),累计到 Mat vtype=6/5
  5. 若无逗号:按 标量 读,填 fitype=3/2

旧式数组旧式数组旧式数组 仍被兼容:负 id(id <= -23300)表示“数组”,接一个长度,再依次读 ,elem

手写词法器的好处:

  • vstr_to_float+/-、整数/小数、指数 e±n 都有明确且不依赖 locale 的解析;
  • token 长度受限(vstr[16]),字符串还有 vstr2[256] 的哨兵,避免意外读穿;
  • 在出错点(读不到值/数组元素)能清晰报错。

3) Blob 与图连线:producer / consumer / name / shape

Blob 的几个字段:

1
2
3
4
5
6
7
8
9
class Blob {
public:
#if NCNN_STRING
std::string name;
#endif
int producer; // 产出它的 layer 索引
int consumer; // 使用它的 layer 索引(单消费者场景;多消费者由 Split 显式拆分)
Mat shape; // 可选:形状提示(来自 ParamDict 30)
};

Net::load_param() 的主循环里:

  • 读 layer 头(type/name/bottom_count/top_count);
  • bottom 处理:逐个名字找已有 blob,找不到则新建占位 blob(支持“先引用、后定义”);把 consumer=当前层,layer 记录 bottoms[j]=该 blob 的索引
  • top 处理:每个 top 都新建一个 blob 槽位(索引自增),填 nameproducer=当前层,layer 记录 tops[j]
  • ParamDict(含 30=shape hints / 31=featmask),把 形状提示写进对应 blob 的 shape,并同步到 layer->bottom_shapes/top_shapes
  • layer->load_param(pd) 各算子把自己关心的键拷进去。

多消费者的情况,ncnn 会在图里显式插入 Split 层,所以 Blob::consumer 仍然是单值:分叉后每条输出都是新的 blob。


4) 自动识别输入/输出:update_input_output_*

当所有 layer 都创建完,框架在末尾调用两步:

4.1 update_input_output_indexes()

1
2
3
4
5
6
7
8
9
10
11
12
input_blob_indexes.clear();
output_blob_indexes.clear();

// 输入:所有 Input 层的第 0 个 top
for (i : layers)
if (layers[i]->typeindex == LayerType::Input)
input_blob_indexes.push_back(layers[i]->tops[0]);

// 输出:有 producer、无 consumer 的 blob
for (i : blobs)
if (blobs[i].producer != -1 && blobs[i].consumer == -1)
output_blob_indexes.push_back(i);
  • 这与我们的直觉一致:Input 层的产出就是模型输入;没有下游使用者的 blob 就是最终输出。
  • 在 SqueezeNet 里,输入是 "data",输出是 "prob"(softmax 的 top)。

4.2 update_input_output_names()

把索引转成人类可读的 name 列表

1
2
3
4
5
for (each index in input_blob_indexes)
input_blob_names.push_back(blobs[index].name.c_str());

for (each index in output_blob_indexes)
output_blob_names.push_back(blobs[index].name.c_str());

有了这两份名单,Extractor::input("<name>", ...)Extractor::extract("<name>", ...) 就都可以“按名索引”。


5) 解析实录:两层 SqueezeNet 片段

1
2
3
4
7767517
48 56
Input data 0 1 data 0=227 1=227 2=3
Convolution conv1 1 1 data conv1_relu_conv1 0=64 1=3 3=2 5=1 6=1728 9=1

第 1 层 Input

  • top "data" → 新建 blob#0:name="data", producer=layer0
  • ParamDict0=227,1=227,2=3(int 标量),输入形状 227×227×3。

第 2 层 Convolution

  • bottom "data" → 找到 blob#0,标记 consumer=layer1
  • top "conv1_relu_conv1" → 新建 blob#1:producer=layer1
  • ParamDict
    • 0=64(outc)、1=3(kernel_w=3,默认 kernel_h=3)、3=2(stride=2)、5=1(bias term)、
    • 6=1728(权重大小,3×3×3×64)、9=1(ReLU 融合)。
  • 尺寸直觉:227→(3×3, s=2)→113 → conv1_relu_conv1 ~ 113×113×64

输入/输出名单(全网完成后):

  • input_blob_names = ["data"]
  • output_blob_names 将包含 prob(全局平均池化 + softmax 之后),及其它可能的无消费者 blob(若有)。

6) 常见坑与对策

  • id 越界:自定义层用了超范围的 id → 触发 id < NCNN_MAX_PARAM_COUNT failed。应复用空闲键或扩增常量(不推荐)。
  • 字符串过长/引号不匹配vstr2[256] 的哨兵能抓住这个问题;检查该行是否把下一层 token“粘”进来了。
  • 数组混写:旧式(负 id + 显式长度)与新式(逗号到行尾)不要混在一个键上;建议统一使用新式。
  • 找不到 bottom 名find_blob_index_by_name 会报一条日志,但加载会“先占位后对齐”。如果最终没有生产者,执行/优化阶段才会暴露。
  • 输出名不对Extractor::extract("<name>") 失败时,先打印 output_blob_names,确认最终输出的实际名字。

7) 小结

  • ParamDict 的定长槽位 + 轻量词法器,让 .param 文本解析 简单、可控、跨平台;
  • layer–blob 图的构建逻辑(先占位、后对齐),让 .param 的书写顺序不那么“挑剔”;
  • update_input_output_* 的两步扫描,给出稳定的 输入/输出名单,让推理 API 能“按名使用”。
  • 至此,param 读取的所有关键环节已经串成闭环:token → ParamDict → layer/blobs → 输入/输出名单

该封面图片由Trương Đình AnhPixabay上发布