读 ncnn 源码(Ⅴ):Param 读取闭环——从 token 到图,再到 I/O 名单
读 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 | struct { |
关键点:
- 定长、按 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/f或v或s),并保留type。 - clear():把所有
type置 0,i=0、v=Mat()、s.clear(),保证下一层解析是干净的。 - union:这里的
union设计可以节省内存。
2) load_param() 与小词法器:把文本吃干抹净
读取流程(简述):
- 反复
scan("%d=", &id)取得下一个键 id; - 读取第一个 token 到
vstr[16]; - 断型:
vstr_is_string(vstr)→ 走字符串路径,拼接剩余字符到s,type=7;- 否则看
vstr_is_float(vstr)(出现.或e/E就认为是浮点);
- 看紧跟是否有逗号:若有,则进入 新式数组 模式(直到行尾),累计到
Mat v,type=6/5; - 若无逗号:按 标量 读,填
f或i,type=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 | class Blob { |
在 Net::load_param() 的主循环里:
- 读 layer 头(type/name/bottom_count/top_count);
- bottom 处理:逐个名字找已有 blob,找不到则新建占位 blob(支持“先引用、后定义”);把
consumer=当前层,layer 记录bottoms[j]=该 blob 的索引; - top 处理:每个 top 都新建一个 blob 槽位(索引自增),填
name、producer=当前层,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 | input_blob_indexes.clear(); |
- 这与我们的直觉一致:
Input层的产出就是模型输入;没有下游使用者的 blob 就是最终输出。 - 在 SqueezeNet 里,输入是
"data",输出是"prob"(softmax 的 top)。
4.2 update_input_output_names()
把索引转成人类可读的 name 列表:
1 | for (each index in input_blob_indexes) |
有了这两份名单,
Extractor::input("<name>", ...)与Extractor::extract("<name>", ...)就都可以“按名索引”。
5) 解析实录:两层 SqueezeNet 片段
1 | 7767517 |
第 1 层 Input:
- top
"data"→ 新建 blob#0:name="data",producer=layer0; ParamDict:0=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 Anh在Pixabay上发布
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 James的成长之路!
评论





