读 ncnn 源码(Ⅰ):从 sample 到 `Net::load_param` 的完整链路
读 ncnn 源码(Ⅰ):从 sample 到 Net::load_param
的完整链路
目标:弄清 ncnn 是如何从
.param
/.bin
文件构建计算图、如何适配不同输入来源(文件/内存)、以及在 Vulkan 设备存在时如何裁剪与回退。
TL;DR
squeezenet
样例的主流程:load_param
(解析网络结构与超参数)→load_model
(载入权重)→create_extractor / input / extract
(执行推理)。- I/O 入口通过
DataReader
抽象统一,DataReaderFromStdio(FILE*)
只是其中一种实现。文本解析走scan()
,二进制走read()
/reference()
。 Net::load_param(DataReader&)
是文本 .param 的总入口:读魔数与计数 → 逐层解析(类型、名字、bottom/top、ParamDict
)→ 处理 shape hint(键30) 与 featmask(键31) → 按需 Vulkan→CPU 回退 → 更新网络输入输出。- 错误策略:结构性错误(无该 layer 实现)直接失败;数据性错误(参数解析失败)记录日志并跳过该层,尽量让网络加载下去。
一、从 sample 开始:最小加载路径
示例(如 squeezenet
)调用顺序:
1 | ncnn::Net net; |
load_param(const char* path)
只是包装 FILE*
的便捷入口:
1 | int Net::load_param(const char* protopath) { |
继续往下,FILE*
又被封装为 DataReaderFromStdio
:
1 | int Net::load_param(FILE* fp) { |
最终落到通用入口:
1 | int Net::load_param(const DataReader& dr) { /* 真正解析 */ } |
二、DataReader
适配层:为何而设、如何使用
1 | class DataReader { |
要点:
- 解析逻辑与“数据来源”解耦:同一套
load_param(DataReader&)
能跑在文件、内存、打包资源、甚至自定义加密流上。 - 文本
.param
依赖scan()
(受NCNN_STRING
控制);二进制.param.bin
与权重.bin
则用read()/reference()
。
三、深入 Net::load_param(DataReader&)
:逐段拆解
1. 头部校验与容器分配
1 | #define SCAN_VALUE(fmt, v) if (dr.scan(fmt, &v) != 1) { NCNN_LOGE("parse " #v " failed"); return -1; } |
- 魔数
7767517
:文本.param
版本标识;不匹配通常是老格式,需要重导出。 - 失败立即返回,便于定位坏头。
2.(可选)Vulkan 设备选择与选项“清洗”
- 若启用
opt.use_vulkan_compute
,选择vkdev
,再根据设备能力(support_fp16_* / int8_* / subgroup_ops / cooperative_matrix
等)逐项下掉不支持的开关。 - 规则一致性:
- 无 fp16 packed/storage → 关 fp16 arithmetic;
- 无 int8 storage → 关 int8 arithmetic;
- 非离散 GPU → 关本地共享内存优化;
- 某层声明
support_int8_storage
时,会把use_vulkan_compute=false
(目前没有 int8 的 GPU 实现)。
3. 主循环:逐层解析
3.1 读层头并构建 Layer
1 | char layer_type[256], layer_name[256]; |
创建顺序(优先级):覆盖内置 → Vulkan → CPU → 自定义;一个都拿不到就致命失败。
3.2 绑定 bottoms / tops 与 blob 映射
- bottoms:遇到未知的 bottom 名,立刻分配一个新的 blob 槽并命名,占位等待后续生产;同时标记
consumer = 当前层 i
。 - tops:使用当前
blob_index
作为产出槽,赋名并标记producer = 当前层 i
,然后blob_index++
。
这个策略支持 .param
中“先引用,后定义”的写法;blob 索引是全局递增的,避免重名冲突。
3.3 读取 ParamDict
、shape hints 与 featmask
1 | ParamDict pd; |
ParamDict
是稀疏小整数键值表,如0=64 1=3 2=1 3=1 4=0 5=1 ...
,各键的语义由具体算子的load_param(pd)
定义。- 保留键:
30
→ top 形状提示(每个 top 4 个 int:dims, w, h, c
)。31
→ featmask(与全局Option
做与运算得到本层opt1
)。
3.4 让算子吃掉参数 & Vulkan→CPU 回退
1 | int lr = layer->load_param(pd); |
风格差异(很重要):
- 结构性错误(没有对应 layer 类型)→ 直接失败返回。
- 数据性错误(参数解析失败)→ 记录日志并跳过该层,尽量保证网络能加载,其它层可继续调试。
4. 末尾:更新输入输出索引与名字
1 | d->update_input_output_indexes(); |
最终把“无生产者的 blob”识别为网络输入,“无消费者的 blob”识别为网络输出,并提供索引-名称映射,方便 input(name)
/ extract(name)
使用。
四、文本 .param
vs 二进制 .param.bin
- 文本
.param
(需NCNN_STRING
):人类可读,层/Blob 用名字,调试友好;体积稍大,解析略慢。 - 二进制
.param.bin
:索引化 + 紧凑编码;无字符串,解析快且更小;常与ncnnoptimize
的产物搭配;适合关闭NCNN_STRING
的瘦身部署。
权重 .bin
的读取独立于上面两者,通过 load_model(...)
和 ModelBin
分发到各 Layer::load_model(...)
。
五、调试与实战建议
- 定位坏行/坏键
- 临时修改
SCAN_VALUE
宏或给DataReaderFromStdio::scan
打印“当前层/当前 token”,快速定位解析失败的位置。 - 遇到
ParamDict load_param failed
或layer load_param failed
,第一时间去看该算子的load_param(pd)
键位定义。
- 临时修改
- 梳理键位表
- 拿一个典型层(如
Convolution
)通读:load_param(pd)
中各键(0=num_output, 1=kernel, 2=dilation, 3=stride, 4=pad, 5=bias_term, ...
)与.param
行的k=v
对应关系。
- 拿一个典型层(如
- Vulkan 的“按层回退”心智模型
- 全局
opt
是“初始愿望”,featmask
可以按层禁用部分特性;若不满足,ncnn 会单层改用 CPU 实现,最大化网络可运行性。 - 注意当前源码里:一旦发现
support_int8_storage
的层,整个网络会关掉use_vulkan_compute
(避免走不存在的 int8 gpu 路)。
- 全局
- 自定义数据来源
- 若部署在资源受限环境(移动端、无文件系统),可用
DataReaderFromMemory
或自定义DataReader
,并实现reference()
以零拷贝读取大段权重,降低内存峰值与 IO。
- 若部署在资源受限环境(移动端、无文件系统),可用
- 工具链
- 想“看清图”:用
ncnnoptimize
生成优化后的.param/.bin
,融合算子后结构更紧凑,便于从宏观上理解网络。
- 想“看清图”:用
六、一个最小 .param
行的读法“模板”
以伪代码概括每一层的解析步骤:
1 | LayerType LayerName bottom_count top_count [bottom_names...] [top_names...] [ParamDict k=v ...] |
-
例(示意):
1
Convolution conv1 1 1 data conv1 0=64 1=3 2=1 3=1 4=1 5=1
- 类型/名字:
Convolution conv1
- 1 个输入
data
,1 个输出conv1
- 参数字典:输出通道=64、kernel=3、dilation=1、stride=1、pad=1、bias_term=1
- 类型/名字:
七、附:关键代码片段留档
load_param(const char\*)
→ load_param(FILE\*)
:
1 | int Net::load_param(const char* protopath){ |
DataReader
抽象:
1 | class DataReader { |
Net::load_param(DataReader&)
主干(节选):
1 | int magic=0; SCAN_VALUE("%d", magic); if (magic!=7767517) return -1; |
结语
这次阅读把 “数据来源 → 文本解析 → 层与 Blob 建图 → 参数派发 → Vulkan 能力裁剪/回退 → I/O 名称整理” 这条链路串起来了。下一步建议你挑一个常用算子(Convolution
/ Pooling
/ ReLU
)逐个看 load_param(pd)
、load_model(mb)
与 forward(...)
,再配合 ncnnoptimize
的前后对照,基本就能把 ncnn 的“加载 + 执行”闭环吃透。