读 ncnn 源码(Ⅲ):ParamDict 解析、featmask 按层屏蔽、词法器与 blob 索引(含解析实录)
读 ncnn 源码(Ⅲ):ParamDict 解析、featmask 按层屏蔽、词法器与 blob 索引(含解析实录)
承接前两篇:我们已经理清了
Net::load_param的主链路与层工厂/覆盖/CPU 指令集优选。本篇把镜头拉近到每一层行尾那串k=v参数,以及与之相关的三个关键点:
①ParamDict::load_param的语法与类型系统;
②featmask如何把全局Option变成按层屏蔽的局部选项;
③find_blob_index_by_name/vstr_*这些“小工具”在建图与解析中的位置。最后用一段真实.param(SqueezeNet 的前两层)做逐 token 解析实录。
TL;DR
ParamDict::load_param(dr)循环读取id=value/id=v1,v2,...,支持 int / float / string / int[] / float[] 五种类型;同时兼容“旧式数组语法”(负 id 前缀 + 显式长度)。- 解析结果落在
d->params[id],type编码:2=int、3=float、5=int[]、6=float[]、7=string。 - 保留键:
30为 top shape hint(每个 top 4 个 int:dims,w,h,c);31为 featmask。 featmask通过get_masked_option(opt, featmask)按位关闭 fp16/bf16/int8/Vulkan/Winograd/线程数等能力,实现按层回退与裁剪。find_blob_index_by_name把 blob 名映射成索引,配合“先占位、后生产”的策略,把计算图连起来。- 一段实录展示:从文件头到
Input与Convolution两层,逐 token 解析、连线、校验权重大小、辅助计算空间尺寸。

1) ParamDict::load_param:一行 k=v 是怎样被“吃”掉的
核心循环的思路很简单:不停尝试 scan("%d=",&id),能读到就进入一次“读值”的流程,直到读不到下一个 id= 为止(意味着到达该层行尾或下一层行首)。
要点分解(伪代码 + 规则):
1 | while (dr.scan("%d=", &id) == 1) { |
类型系统一览:
- 标量:
2=int、3=float - 数组:
5=int[]、6=float[](承载在Mat v中) - 字符串:
7=string - 取值:例如
pd.get(30, Mat())(数组/Mat 类参数),或pd.get(0, 0)/pd.get(1, 1.f)(标量)。
旧式数组(负 id)主要是历史兼容;新项目建议使用新式数组(正常 id + 逗号分隔到行尾),更直观。
2) 小词法器:vstr_is_float / vstr_is_string / vstr_to_float
这些轻量词法函数是 ParamDict 的底层积木,完全不依赖标准库的解析器,确保行为可控、跨平台一致:
1 | static bool vstr_is_float(const char vstr[16]) { |
- 为什么自己写:限制 token 长度(
vstr[16])、更强的错误控制、更小的依赖面。 - 判定策略:简化而实用,例如
"1e0"归为 float;"123"归为 int;"abc"或"\"hello"走字符串路径。
3) featmask:把全局 Option 变成“按层局部选项”
读取 pd 后,框架会取 featmask = pd.get(31, 0),然后做一轮“按位屏蔽”:
1 | static Option get_masked_option(const Option& opt, int featmask) |
理解要点:
- 这是按层的能力裁剪:哪怕全局允许 fp16/int8/Vulkan,只要该层设了对应位,就局部禁用。
- 有些位是成组的:比如
use_fp16_storage和use_fp16_packed同时受bit1控制;int8 的三项一起关。 bit4同时关掉use_vulkan_compute与use_tensor_storage,保证回退到 CPU 路径。bit7让本层强制单线程,有时用于规避数据竞争或便于调试。
随后,如果该层最初拿到的是 Vulkan 实现,但 opt1.use_vulkan_compute 被关或层本身 support_vulkan=false,就地回退为 CPU 实现(保持连接、重走一次 load_param(pd))。这与我们在第Ⅰ篇看到的“按层回退”呼应。
4) find_blob_index_by_name:图连线的“路由表”
在 Net::load_param 的主循环里,遇到 bottom 名会调用它来找索引:
1 | int Net::find_blob_index_by_name(const char* name) const |
配套策略:*如果找不到(返回 -1),load_param 会*新建一个占位 blob(用当前 blob_index,并命名为该 bottom 的名字),以支持“先引用,后定义”的写法。
当某层随后把这个名字作为 top 产出时,就会正好“对上先前的占位槽”,从而把producer/consumer** 链接完整。
心智图:Layer 是节点,Blob 是边;这里就是把“边名 → 边号”的路由找出来(必要时先建一个空边位)。
5) 解析实录:SqueezeNet 的前两层(逐 token)
给定 .param 片段:
1 | 7767517 |
(1) 文件头
magic=7767517✅;layer_count=48,blob_count=56;分配d->layers与d->blobs;blob_index=0。
(2) 第 1 层:Input
- 头部:
type="Input",name="data",bottom_count=0,top_count=1。 - tops:读到 top 名
"data"→ 使用当前blob_index=0新建 blob:name="data",producer=layer0,并layer->tops[0]=0;blob_index++→ 1。 ParamDict:依次扫描0=227、1=227、2=3:- 都是 int 标量(没有
.或e/E); - 语义(Input 层):
w=227, h=227, c=3;类型码type=2。
- 都是 int 标量(没有
layer->load_param(pd):落入成员,结束该层。
此时:blob #0 =
"data",由Input产出,是网络输入。
(3) 第 2 层:Convolution
- 头部:
type="Convolution",name="conv1",bottom_count=1,top_count=1。 - bottoms:读
"data"→find_blob_index_by_name("data")命中 0;标记consumer=layer1,layer->bottoms[0]=0。 - tops:读
"conv1_relu_conv1"→ 使用当前blob_index=1新建 blob:name=...,producer=layer1,layer->tops[0]=1;blob_index++→ 2。 ParamDict:扫描一串键值0=64→num_output=64(int)1=3→kernel=3(int)3=2→stride=2(int)5=1→bias_term=1(int)6=1728→weight_data_size=1728(int)9=1→activation_type=1(fused ReLU,卷积内置激活)
- 快速校验:
weight_data_size = kw*kh*in_c*out_c- 此时
in_c=3(来自上一层),kw=kh=3,out_c=64→3×3×3×64 = 1728✅
- 此时
- 空间尺寸(辅助理解):
227×227×3 --(3×3,s=2,pad=0)--> floor((227-3)/2)+1 = 113→113×113×64。
此时:
- blob #0
"data":被conv1消费;- blob #1
"conv1_relu_conv1":将被下一层(比如pool1)作为 bottom 消费。
6) 速查小卡
- ParamDict 类型码:
2=int、3=float、5=int[]、6=float[]、7=string - 保留键:
30=top shape hints(每个 top:dims,w,h,c),31=featmask - 数组写法:倾向使用
id=v0,v1,...的新式数组;旧式-233xx=LEN,v0,...仅用于兼容 - layer/Blob 关系:Layer 是节点,Blob 是边;
find_blob_index_by_name负责把“边名 → 边号”对上,必要时先占位 - 按层回退:
featmask→get_masked_option→若 Vulkan 不可/禁用则重建 CPU 版本层;CPU 路径内部再按 AVX512/FMA/AVX… 逐层回退
结语
至此,*“从一行 .param 到层参数落地、到图上 blob 的连接与索引、到按层屏蔽与回退的决策”*这条微观路径就完整了。





