读 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
2
3
4
5
6
ncnn::Net net;
net.load_param("squeezenet_v1.1.param"); // 解析结构与超参数
net.load_model("squeezenet_v1.1.bin"); // 读取权重
auto ex = net.create_extractor();
ex.input("data", in);
ex.extract("prob", out);

load_param(const char* path) 只是包装 FILE* 的便捷入口:

1
2
3
4
5
6
7
int Net::load_param(const char* protopath) {
FILE* fp = fopen(protopath, "rb");
if (!fp) { NCNN_LOGE("fopen %s failed", protopath); return -1; }
int ret = load_param(fp);
fclose(fp);
return ret;
}

继续往下,FILE* 又被封装为 DataReaderFromStdio

1
2
3
4
int Net::load_param(FILE* fp) {
DataReaderFromStdio dr(fp);
return load_param(dr);
}

最终落到通用入口

1
int Net::load_param(const DataReader& dr) { /* 真正解析 */ }

二、DataReader 适配层:为何而设、如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DataReader {
public:
virtual int scan(const char* fmt, void* p) const; // 文本 param(NCNN_STRING)
virtual size_t read(void* buf, size_t size) const; // 二进制 param / model
virtual size_t reference(size_t size, const void** buf) const; // 尽量零拷贝
};

class DataReaderFromStdio : public DataReader {
public:
explicit DataReaderFromStdio(FILE* fp);
int scan(const char* fmt, void* p) const override; // 基于 stdio 的 token 读取
size_t read(void* buf, size_t size) const override; // fread
// reference 通常返回 0:文件流难以提供稳定的连续内存窗口
};

要点:

  • 解析逻辑与“数据来源”解耦:同一套 load_param(DataReader&) 能跑在文件、内存、打包资源、甚至自定义加密流上。
  • 文本 .param 依赖 scan()(受 NCNN_STRING 控制);二进制 .param.bin 与权重 .bin 则用 read()/reference()

三、深入 Net::load_param(DataReader&):逐段拆解

1. 头部校验与容器分配

1
2
3
4
5
6
7
8
9
10
#define SCAN_VALUE(fmt, v) if (dr.scan(fmt, &v) != 1) { NCNN_LOGE("parse " #v " failed"); return -1; }

int magic = 0; SCAN_VALUE("%d", magic);
if (magic != 7767517) { NCNN_LOGE("param is too old, please regenerate"); return -1; }

int layer_count = 0, blob_count = 0;
SCAN_VALUE("%d", layer_count);
SCAN_VALUE("%d", blob_count);
d->layers.resize(layer_count);
d->blobs.resize(blob_count);
  • 魔数 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
char layer_type[256], layer_name[256];
int bottom_count=0, top_count=0;
SCAN_VALUE("%255s", layer_type);
SCAN_VALUE("%255s", layer_name);
SCAN_VALUE("%d", bottom_count);
SCAN_VALUE("%d", top_count);

Layer* layer = create_overwrite_builtin_layer(layer_type);
#if NCNN_VULKAN
if (!layer && opt.use_vulkan_compute && d->vkdev) layer = create_layer_vulkan(layer_type);
#endif
if (!layer) layer = create_layer_cpu(layer_type);
if (!layer) layer = create_custom_layer(layer_type);
if (!layer) { NCNN_LOGE("layer %s not exists or registered", layer_type); clear(); return -1; }

layer->type = layer_type; layer->name = layer_name;
#if NCNN_VULKAN
if (opt.use_vulkan_compute) layer->vkdev = d->vkdev;
#endif

创建顺序(优先级):覆盖内置 → 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
2
3
4
5
6
7
8
9
10
11
12
13
14
ParamDict pd;
int pdlr = pd.load_param(dr);
if (pdlr != 0) { NCNN_LOGE("ParamDict load_param %d %s failed", i, layer_name); continue; }

// 键 30:为每个 top 提供 (dims, w, h, c) 形式的形状提示
Mat shape_hints = pd.get(30, Mat());
if (!shape_hints.empty()) { /* 逐 top 填充 blob.shape */ }

// 把 shape 提示同步到 layer
layer->bottom_shapes = blobs[bottoms[*]].shape;
layer->top_shapes = blobs[tops[*]].shape;

// 键 31:特性禁用掩码(如禁 fp16 / int8 / subgroup)
layer->featmask = pd.get(31, 0);
  • 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
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
int lr = layer->load_param(pd);
if (lr != 0) { NCNN_LOGE("layer load_param %d %s failed", i, layer_name); continue; }

if (layer->support_int8_storage) { opt.use_vulkan_compute = false; } // 暂无 int8 gpu

Option opt1 = get_masked_option(opt, layer->featmask);

// 若最初拿到的是 vulkan 实现,但本层参数/设备不满足,则“同款算子”回退为 cpu 实现
if (layer_support_vulkan && (!layer->support_vulkan || !opt1.use_vulkan_compute)) {
Layer* cpu = create_overwrite_builtin_layer(layer_type);
if (!cpu) cpu = create_layer_cpu(layer_type);
if (!cpu) cpu = create_custom_layer(layer_type);
if (!cpu) { NCNN_LOGE("layer %s not exists or registered", layer_type); clear(); return -1; }

// 迁移基础字段,复用 pd 再一次 load_param
cpu->type = layer->type; cpu->name = layer->name;
cpu->bottoms = layer->bottoms; cpu->tops = layer->tops;
cpu->bottom_shapes = layer->bottom_shapes; cpu->top_shapes = layer->top_shapes;
cpu->featmask = layer->featmask;

if (cpu->load_param(pd) != 0) { NCNN_LOGE("layer load_param %d %s failed", i, layer_name); continue; }

delete layer; layer = cpu;
}

d->layers[i] = layer;

风格差异(很重要):

  • 结构性错误(没有对应 layer 类型)→ 直接失败返回
  • 数据性错误(参数解析失败)→ 记录日志并跳过该层,尽量保证网络能加载,其它层可继续调试。

4. 末尾:更新输入输出索引与名字

1
2
d->update_input_output_indexes();
d->update_input_output_names();

最终把“无生产者的 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(...)


五、调试与实战建议

  1. 定位坏行/坏键
    • 临时修改 SCAN_VALUE 宏或给 DataReaderFromStdio::scan 打印“当前层/当前 token”,快速定位解析失败的位置。
    • 遇到 ParamDict load_param failedlayer load_param failed,第一时间去看该算子的 load_param(pd) 键位定义。
  2. 梳理键位表
    • 拿一个典型层(如 Convolution)通读:load_param(pd) 中各键(0=num_output, 1=kernel, 2=dilation, 3=stride, 4=pad, 5=bias_term, ...)与 .param 行的 k=v 对应关系。
  3. Vulkan 的“按层回退”心智模型
    • 全局 opt 是“初始愿望”,featmask 可以按层禁用部分特性;若不满足,ncnn 会单层改用 CPU 实现,最大化网络可运行性。
    • 注意当前源码里:一旦发现 support_int8_storage 的层,整个网络会关掉 use_vulkan_compute(避免走不存在的 int8 gpu 路)。
  4. 自定义数据来源
    • 若部署在资源受限环境(移动端、无文件系统),可用 DataReaderFromMemory 或自定义 DataReader,并实现 reference()零拷贝读取大段权重,降低内存峰值与 IO。
  5. 工具链
    • 想“看清图”:用 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
2
3
4
5
6
7
8
9
10
11
12
int Net::load_param(const char* protopath){
FILE* fp = fopen(protopath, "rb");
if (!fp) { NCNN_LOGE("fopen %s failed", protopath); return -1; }
int ret = load_param(fp);
fclose(fp);
return ret;
}

int Net::load_param(FILE* fp){
DataReaderFromStdio dr(fp);
return load_param(dr);
}

DataReader 抽象:

1
2
3
4
5
6
class DataReader {
public:
virtual int scan(const char* fmt, void* p) const; // 文本
virtual size_t read(void* buf, size_t size) const; // 二进制
virtual size_t reference(size_t size, const void** buf) const; // 零拷贝
};

Net::load_param(DataReader&) 主干(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int magic=0; SCAN_VALUE("%d", magic); if (magic!=7767517) return -1;
int layer_count=0, blob_count=0; SCAN_VALUE("%d", layer_count); SCAN_VALUE("%d", blob_count);
d->layers.resize(layer_count); d->blobs.resize(blob_count);

for (int i=0;i<layer_count;i++){
// 1) 层头
// 2) 创建 Layer(覆盖内置→vulkan→cpu→自定义)
// 3) 解析 bottoms / tops → 维护 blob producer/consumer
// 4) pd.load_param(dr) 读 k=v;键30/31:形状提示与 featmask
// 5) layer->load_param(pd);必要时 vulkan→cpu 回退
// 6) 保存到 d->layers[i]
}

d->update_input_output_indexes();
d->update_input_output_names();
return 0;

结语

这次阅读把 “数据来源 → 文本解析 → 层与 Blob 建图 → 参数派发 → Vulkan 能力裁剪/回退 → I/O 名称整理” 这条链路串起来了。下一步建议你挑一个常用算子(Convolution / Pooling / ReLU)逐个看 load_param(pd)load_model(mb)forward(...),再配合 ncnnoptimize 的前后对照,基本就能把 ncnn 的“加载 + 执行”闭环吃透。

该封面图片由CouleurPixabay上发布