[NCNN] 源码学习笔记
NCNN推理流程概览
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include "net.h"
int main()
{
cv::Mat img = cv::imread("image.ppm", CV_LOAD_IMAGE_GRAYSCALE);
int w = img.cols;
int h = img.rows;
// subtract 128, norm to -1 ~ 1
ncnn::Mat in = ncnn::Mat::from_pixels_resize(img.data, ncnn::Mat::PIXEL_GRAY, w, h, 60, 60);
float mean[1] = { 128.f };
float norm[1] = { 1/128.f };
in.substract_mean_normalize(mean, norm);
ncnn::Net net;
net.load_param("model.param");
net.load_model("model.bin");
ncnn::Extractor ex = net.create_extractor();
ex.set_light_mode(true);
ex.set_num_threads(4);
ex.input("data", in);
ncnn::Mat feat;
ex.extract("output", feat);
return 0;
}
Step1 load_param-网络参数解析
- the comprehensive model loading api tablex
| load from | alexnet.param | alexnet.param.bin | alexnet.bin |
|---|---|---|---|
| file path | load_param(const char*) | load_param_bin(const char*) | load_model(const char*) |
| file descriptor | load_param(FILE*) | load_param_bin(FILE*) | load_model(FILE*) |
| file memory | load_param_mem(const char*) | load_param(const unsigned char*) | load_model(const unsigned char*) |
| android asset | load_param(AAsset*) | load_param_bin(AAsset*) | load_model(AAsset*) |
| android asset path | load_param(AAssetManager*, const char*) | load_param_bin(AAssetManager*, const char*) | load_model(AAssetManager*, const char*) |
| custom IO reader | load_param(const DataReader&) | load_param_bin(const DataReader&) | load_model(const DataReader&) |
- 使用函数重载支持从不同的方式载入模型,比如从protopath fopen获取fp文件指针,给到FILE* fp参数版本,fp给到构造注入到DataReader,创建得到dr实例,最后传递给const DataReader& dr参数版本。
int Net::load_param(const char* protopath);
int Net::load_param(FILE* fp);
int Net::load_param(const DataReader& dr);
param and model file structure · Tencent/ncnn Wiki (github.com)
7767517 # 文件头 魔数
75 83 # 层数量 输入输出blob数量
# 下面有75行
Input data 0 1 data 0=227 1=227 2=3
Convolution conv1 1 1 data conv1 0=64 1=3 2=1 3=2 4=0 5=1 6=1728
ReLU relu_conv1 1 1 conv1 conv1_relu_conv1 0=0.000000
Pooling pool1 1 1 conv1_relu_conv1 pool1 0=0 1=3 2=2 3=0 4=0
Convolution fire2/squeeze1x1 1 1 pool1 fire2/squeeze1x1 0=16 1=1 2=1 3=1 4=0 5=1 6=1024
...
层类型 层名字 输入blob数量 输出blob数量 输入blob名字 输出blob名字 参数字典
参数字典,每一层的意义不一样:
数据输入层 Input data 0 1 data 0=227 1=227 2=3 图像宽度×图像高度×通道数量
卷积层 Convolution ... 0=64 1=3 2=1 3=2 4=0 5=1 6=1728
0输出通道数 num_output() ; 1卷积核尺寸 kernel_size(); 2空洞卷积参数 dilation(); 3卷积步长 stride();
4卷积填充pad_size(); 5卷积偏置有无bias_term(); 6卷积核参数数量 weight_blob.data_size();
C_OUT * C_in * W_h * W_w = 64*3*3*3 = 1728
池化层 Pooling 0=0 1=3 2=2 3=0 4=0
0池化方式:最大值、均值、随机 1池化核大小 kernel_size(); 2池化核步长 stride();
3池化核填充 pad(); 4是否为全局池化 global_pooling();
激活层 ReLU 0=0.000000 下限阈值 negative_slope();
ReLU6 0=0.000000 1=6.000000 上下限
综合示例:
0=1 1=2.5 -23303=2,2.0,3.0
数组关键字 : -23300
-(-23303) - 23300 = 3 表示该参数在参数数组中的index
后面的第一个参数表示数组元素数量,2表示包含两个元素
Step2 load_model-加载模型
- weight buffer,权重buffer件所有权重连接起来,长度为32bit。
+---------+---------+---------+---------+---------+---------+
| weight1 | weight2 | weight3 | weight4 | ....... | weightN |
+---------+---------+---------+---------+---------+---------+
^ ^ ^ ^
0x0 0x80 0x140 0x1C0
- weight buffer
[flag] (optional)
[raw data]
[padding] (optional)
* flag : unsigned int, little-endian, indicating the weight storage type, 0 => float32, 0x01306B47 => float16, otherwise => quantized int8, may be omitted if the layer implementation forced the storage type explicitly
* raw data : raw weight data, little-endian, float32 data or float16 data or quantized table and indexes depending on the storage type flag
* padding : padding space for 32bit alignment, may be omitted if already aligned
- 与解析param类似,提供从不同格式加载模型数据。阅读
Net::load_model(const DataReader&)并根据网络结构,可以理解NCNN如何加载模型数据。
int Net::load_model(const char* modelpath);
int Net::load_model(FILE* fp);
int Net::load_model(const DataReader& dr);
Step3 create_extractor-网络执行
- Extractor UML Class Diragram [[UMLClassDiagram-Extractor.png]]
- Net创建一个Extractor实例
Extractor Net::create_extractor() const
{
return Extractor(this, d->blobs.size());
}
- Mat输入到底层的
std::vector中,通过名称映射到索引,作为vector的下标O(1)时间复杂度访存。- NCNN特别喜欢使用函数重载和PIMPL-idom,重载为了实现多种输入灵活的目的,PIMPL为了编译加速。
- 比如下面的函数,通过根据名称输入Mat,本质上会调用Class中的重载函数,保证最本质的功能是一致的,这是OOP中常用的手法。时序图为[[extractor_input_sq.png]]。
int Extractor::input(const char* blob_name, const Mat& in)
{
// 名称 -> 索引
int blob_index = d->net->find_blob_index_by_name(blob_name);
if (blob_index == -1) // -1是非法的索引,若非法则打印日志
{
const std::vector<const char*>& input_names = d->net->input_names();
for (size_t i = 0; i < input_names.size(); i++)
{
NCNN_LOGE(" ex.input(\"%s\", in%d);", input_names[i], (int)i);
}
return -1;
}
// 从名称转换为整型索引,调用重载方法
return input(blob_index, in);
}
int Extractor::input(int blob_index, const Mat& in)
{
// 边界条件校验-非法索引返回-1
if (blob_index < 0 || blob_index >= (int)d->blob_mats.size())
return -1;
d->blob_mats[blob_index] = in; // calls Mat operator=
return 0;
}
- PIMPL-idom,[[extractor_pimpl.png]],这种手法作者广泛使用(比如获取分析输出的
Extractor::extract()方法),一方面是编译加速,另外一方面数据和方法分层管理,逻辑逻辑清晰。
class ExtractorPrivate
{
public:
// 构造注入Net实例,维护所属网络实例的地址(关联关系)
ExtractorPrivate(const Net* _net)
: net(_net) {}
const Net* net; // 网络地址
std::vector<Mat> blob_mats; // Mat数组
Option opt; // 选项用于配置网络参数
// VULKAN加速配置
#if NCNN_VULKAN
VkAllocator* local_blob_vkallocator;
VkAllocator* local_staging_vkallocator;
std::vector<VkMat> blob_mats_gpu;
std::vector<VkImageMat> blob_mats_gpu_image;
#endif // NCNN_VULKAN
};
- 获取推理输出,调用前向推理方法
d->net->d->forward_layer(),流程图为[[ClusterControlFlow-extract.png]]。
int extract(const char* blob_name, Mat& feat, int type = 0);
int extract(int blob_index, Mat& feat, int type = 0)
{
// 处理输入...
ret = d->net->d->forward_layer(layer_index, d->blob_mats, d->opt);
// 处理输出...
return ret;
}
NCNN源码剖析(简单版)
推理调用关系图
- 调用简要流程:
Extractor::extract->NetPrivate::forward_layer->Layer::forward/forward_inplace - 调用关系图:
NetPrivate::forward_layer
- 转换输入:
- [[ClusterControlFlow-forward_layer.png]]
- [[ClusterControlFlow-do_forward_layer.png]]
Layer::forward_inplace
Layer基类设计
- 作为基类,层的加载参数
load_param、加载模型load_model、create_pipeline/destroy_pipeline、由其子类根据需要实现。 - Layer提供输入单张图像和N张图像(批量模式)的模板方法,
forward本质上调用forward_inplace,而forward_inplace由其子类实现,这里使用封装变化性思想。 - 类比人脸引擎:人脸算法也提供单张和批量分析的接口,但本质上批量可退化为单张,编码上可以设计通用型批处理算法。其中对于模板方法,引擎模型加载类AlgModelLoader使用了模板方法的思想,业务流程一致,不同智能模型加载由子类实现。
// 批量接口
int Layer::forward(const std::vector<Mat>& bottom_blobs, std::vector<Mat>& top_blobs, const Option& opt) const
{
if (!support_inplace)
return -1;
top_blobs = bottom_blobs;
for (int i = 0; i < (int)top_blobs.size(); i++)
{
top_blobs[i] = bottom_blobs[i].clone(opt.blob_allocator);
if (top_blobs[i].empty())
return -100;
}
// 由Layer子类实现 - 设计模式中的模板方法
return forward_inplace(top_blobs, opt);
}
// 单张接口
int Layer::forward(const Mat& bottom_blob, Mat& top_blob, const Option& opt) const
{
if (!support_inplace)
return -1;
top_blob = bottom_blob.clone(opt.blob_allocator);
if (top_blob.empty())
return -100;
// 由Layer子类实现 - 设计模式中的模板方法
return forward_inplace(top_blob, opt);
}
创建Layer实例的原理
- 每一层均是一个Layer实例,深度学习层的类型繁多,手动创建十分麻烦,而且这种硬编码的方式不可取。如何采用软件上的技术,可以自动化的创建各子类实例?可以采用存C-style的方式(注册表+函数指针),也可以采用OOP的方式。
- NCNN采用C-style方式实现创建Layer子类实例的方案。
采用C-style实现
- 剖析1. 以下代码声明了创建Layer所需要的的数据类型,保存层的名字+创建/销毁Layer实例的函数指针。
// layer factory function
typedef Layer* (*layer_creator_func)(void*);
typedef void (*layer_destroyer_func)(Layer*, void*);
struct layer_registry_entry
{
#if NCNN_STRING
// layer type name
const char* name;
#endif // NCNN_STRING
// layer factory entry
layer_creator_func creator;
};
struct custom_layer_registry_entry
{
#if NCNN_STRING
// layer type name
const char* name;
#endif // NCNN_STRING
// layer factory entry
layer_creator_func creator;
layer_destroyer_func destroyer;
void* userdata;
};
struct overwrite_builtin_layer_registry_entry
{
// layer type index
int typeindex;
// layer factory entry
layer_creator_func creator;
layer_destroyer_func destroyer;
void* userdata;
};
工厂方法的实现
- NCNN提供了以下工厂方法(C不支持重载,这里是CPP环境,支持函数重载)创建Layer实例。其中,通过字符型名字查询到整型层索引,然后调用
Layer* create_layer(int index)参数版本实现实例的创建->[[UMLSequenceDiagram-create_layer.png]]:
#if NCNN_STRING
// get layer type from type name
NCNN_EXPORT int layer_to_index(const char* type);
// create layer from type name
NCNN_EXPORT Layer* create_layer(const char* type);
#endif // NCNN_STRING
// create layer from layer type
NCNN_EXPORT Layer* create_layer(int index);
- 剖析2. 通过预编译宏和CPU支持的指令集宏,控制创建哪种类型的Layer平台实现。为了方便理解,简化的流程为[[ClusterControlFlow-create_layer.png]]
,完整的工厂方法实现如下:
// 计算注册的Layer实例的个数
#include "layer_registry.h"
static const int layer_registry_entry_count = sizeof(layer_registry) / sizeof(layer_registry_entry);
// 根据索引创建具体的Layer实例
Layer* create_layer(int index)
{
if (index < 0 || index >= layer_registry_entry_count)
return 0;
// clang-format off
// *INDENT-OFF*
layer_creator_func layer_creator = 0;
#if NCNN_RUNTIME_CPU && NCNN_AVX512
if (ncnn::cpu_support_x86_avx512())
{
layer_creator = layer_registry_avx512[index].creator;
}
else
#endif// NCNN_RUNTIME_CPU && NCNN_AVX512
#if NCNN_RUNTIME_CPU && NCNN_FMA
if (ncnn::cpu_support_x86_fma())
{
layer_creator = layer_registry_fma[index].creator;
}
else
#endif// NCNN_RUNTIME_CPU && NCNN_FMA
#if NCNN_RUNTIME_CPU && NCNN_AVX
if (ncnn::cpu_support_x86_avx())
{
layer_creator = layer_registry_avx[index].creator;
}
else
#endif // NCNN_RUNTIME_CPU && NCNN_AVX
#if NCNN_RUNTIME_CPU && NCNN_LASX
if (ncnn::cpu_support_loongarch_lasx())
{
layer_creator = layer_registry_lasx[index].creator;
}
else
#endif // NCNN_RUNTIME_CPU && NCNN_LASX
#if NCNN_RUNTIME_CPU && NCNN_LSX
if (ncnn::cpu_support_loongarch_lsx())
{
layer_creator = layer_registry_lsx[index].creator;
}
else
#endif // NCNN_RUNTIME_CPU && NCNN_LSX
#if NCNN_RUNTIME_CPU && NCNN_MSA
if (ncnn::cpu_support_mips_msa())
{
layer_creator = layer_registry_msa[index].creator;
}
else
#endif // NCNN_RUNTIME_CPU && NCNN_MSA
#if NCNN_RUNTIME_CPU && NCNN_RVV
if (ncnn::cpu_support_riscv_v())
{
layer_creator = layer_registry_rvv[index].creator;
}
else
#endif // NCNN_RUNTIME_CPU && NCNN_RVV
{
layer_creator = layer_registry[index].creator;
}
// *INDENT-ON*
// clang-format on
if (!layer_creator)
return 0;
Layer* layer = layer_creator(0);
layer->typeindex = index;
return layer;
}
谈一谈注册-layer_registry
- 谈一谈注册
- 注册表巧用:注册表是软件设计中常用的技巧,方便实现行为注册,动态的调用行为执行,达到解耦的目的,比较灵活。
- 举一反三:NCNN中Layer就是典型的设计(注册表+函数指针),MediaPipe 中Node也是采用该设计,该实现也常用在插件的管理上(比如人脸解析引擎的算子插件-C-style),本质上就是把Callable Object保存起来(即注册),在需要使用时查找获取可调用对象,然后调用。
- 注册的实现:配置文件注册-读取配置文件、代码实现-使用字典等容器注册直接使用。可以根据实际场景,选择配置文件注册,还是编码实现注册。NCNN采用编码实现注册方式。
- 剖析3. NCNN在编译期,借助CMake
configure_file()+编译宏,动态生成创建Layer实例的函数名注册表。对于configure_file()的使用以及CMakeLiists.txt如何实现不再赘述。
///---------- layer_registry.h.in
static const layer_registry_entry layer_registry[] = {
@layer_registry@
};
#if NCNN_RUNTIME_CPU && NCNN_AVX512
static const layer_registry_entry layer_registry_avx512[] = {
@layer_registry_avx512@
};
#endif // NCNN_RUNTIME_CPU && NCNN_AVX512
#if NCNN_RUNTIME_CPU && NCNN_FMA
static const layer_registry_entry layer_registry_fma[] = {
@layer_registry_fma@
};
#endif // NCNN_RUNTIME_CPU && NCNN_FMA
... // 省略剩下的,更多参考源码 src/layer_registry.h.in
///---------- configure_file()生成的代码如下
static const layer_registry_entry layer_registry[] = {
#if NCNN_STRING
{"AbsVal", AbsVal_final_layer_creator}, //------- 思考:这个如何与具体的Layer关联起来?
#else
{AbsVal_final_layer_creator},
#endif
#if NCNN_STRING
{"ArgMax", 0},
#else
{0},
#endif
... // 省略剩下的,更多参考源码 build/src/layer_registry.h
layer_declaration/_registry的自动化生成
-
剖析4. xxx_layer_creator是如何与子类匹配起来的?其实采用了MediaPipe中也使用的这个方法(可以在许多开源代码里见到)。
-
步骤1. 定义宏
// Layer.h 定义创建/销毁的宏,这里很简单,使用new/delete构造和析构类实例,这里还是采用默认构造函数
#define DEFINE_LAYER_CREATOR(name) \
::ncnn::Layer* name##_layer_creator(void* /*userdata*/) \
{ \
return new name; \
}
#define DEFINE_LAYER_DESTROYER(name) \
void name##_layer_destroyer(::ncnn::Layer* layer, void* /*userdata*/) \
{ \
delete layer; \
})
- 步骤2. 使用宏。NCNN在src/layer中实现了各式各样的Layer层的子类算子实现,但是如何编排这些算子,与前面章节的Net/Extractor协作起来呢?NCNN采用CMake在
cmake/ncnn_add_layer.cmake中定义自动化生成class的模板函数ncnn_add_layer(class),并在src/CMakeLists.txt调用以注册不同的Layer层,从而实现layer_declaration.h和layer_registry.h的自动化编码实现。
///---------- build/src/layer_declaration.h
// Layer Declaration header
//
// This file is auto-generated by cmake, don't edit it.
#include "layer/absval.h"
#include "layer/arm/absval_arm.h"
namespace ncnn {
class AbsVal_final : virtual public AbsVal, virtual public AbsVal_arm
{
public:
virtual int create_pipeline(const Option& opt) {
{ int ret = AbsVal::create_pipeline(opt); if (ret) return ret; }
{ int ret = AbsVal_arm::create_pipeline(opt); if (ret) return ret; }
return 0;
}
virtual int destroy_pipeline(const Option& opt) {
{ int ret = AbsVal_arm::destroy_pipeline(opt); if (ret) return ret; }
{ int ret = AbsVal::destroy_pipeline(opt); if (ret) return ret; }
return 0;
}
};
DEFINE_LAYER_CREATOR(AbsVal_final) ///---------- 在此处使用宏,这里是cmake自动生成,mediapipe是手动编码+prototxt注册算子
} // namespace ncnn
#include "layer/batchnorm.h"
#include "layer/arm/batchnorm_arm.h"
namespace ncnn {
class BatchNorm_final : virtual public BatchNorm, virtual public BatchNorm_arm
{
public:
virtual int create_pipeline(const Option& opt) {
{ int ret = BatchNorm::create_pipeline(opt); if (ret) return ret; }
{ int ret = BatchNorm_arm::create_pipeline(opt); if (ret) return ret; }
return 0;
}
virtual int destroy_pipeline(const Option& opt) {
{ int ret = BatchNorm_arm::destroy_pipeline(opt); if (ret) return ret; }
{ int ret = BatchNorm::destroy_pipeline(opt); if (ret) return ret; }
return 0;
}
};
DEFINE_LAYER_CREATOR(BatchNorm_final)
} // namespace ncnn
... // 省略剩下的,更多可参考build/src/layer_declaration.h
内置Layer与自定义用户Layer
内置Layer继承示例
- NCNN提供很多深度学习中的算子纯C版本实现与平台优化版本实现。比如对于Relu激活函数,
src/layer/relu.h是C实现版本,srs/layer/arm/relu_arm.h是ARM上NEON优化版本实现,其中纯C版本可以采用OpenMP优化,以此类推。- [[UMLClassDiagram-Convolution.png]]
* \[\[UMLClassDiagram-Pooling.png]]
* \[\[UMLClassDiagram-Softmax.png]]
* \[\[UMLClassDiagram-ReLU.png]]
* \[\[UMLClassDiagram-Bias.png]]
* \[\[UMLClassDiagram-Sigmoid.png]]
* \[\[UMLClassDiagram-Gemm.png]]
- Operator列表和权重权重矩阵参考官方文档:operators,operation-param-weight-table
自定义Layer
Allocator
UML类图
- UML类图:定义基类接口,派生类处理不同业务场景实现,分别为::线程安全与非线程安全::版本。
- 数据结构:budgets记录空闲内存信息链表,payouts记录已占用内存信息链表,内存信息为::内存大小+内存地址::,也可采用字典存储。
class PoolAllocatorPrivate {
public:
Mutex budgets_lock;
Mutex payouts_lock;
unsigned int size_compare_ratio; // 0~256
size_t size_drop_threshold;
std::list<std::pair<size_t, void*> > budgets;
std::list<std::pair<size_t, void*> > payouts;
};
分配流程与原理
- 申请函数流程图
- 释放函数流程图
- 申请(fastMalloc)原理:该函数根据不同的平台和编译器,选择合适的内存分配方式来快速分配内存,并确保内存地址按照指定的对齐方式对齐。
static NCNN_FORCEINLINE void* fastMalloc(size_t size)
{
#if _MSC_VER
return _aligned_malloc(size, NCNN_MALLOC_ALIGN);
#elif (defined(__unix__) || defined(__APPLE__)) && _POSIX_C_SOURCE >= 200112L || (__ANDROID__ && __ANDROID_API__ >= 17)
void* ptr = 0;
if (posix_memalign(&ptr, NCNN_MALLOC_ALIGN, size + NCNN_MALLOC_OVERREAD))
ptr = 0;
return ptr;
#elif __ANDROID__ && __ANDROID_API__ < 17
return memalign(NCNN_MALLOC_ALIGN, size + NCNN_MALLOC_OVERREAD);
#else
unsigned char* udata = (unsigned char*)malloc(size + sizeof(void*) + NCNN_MALLOC_ALIGN);
if (!udata)
return 0;
unsigned char** adata = alignPtr((unsigned char**)udata + 1, NCNN_MALLOC_ALIGN);
adata[-1] = udata;
return adata;
#endif
}
- 释放(fastFree)原理:释放函数与申请函数配。
static NCNN_FORCEINLINE void fastFree(void* ptr)
{
if (ptr)
{
#if _MSC_VER
_aligned_free(ptr);
#elif (defined(__unix__) || defined(__APPLE__)) && _POSIX_C_SOURCE >= 200112L || (__ANDROID__ && __ANDROID_API__ >= 17)
free(ptr);
#elif __ANDROID__ && __ANDROID_API__ < 17
free(ptr);
#else
unsigned char* udata = ((unsigned char**)ptr)[-1];
free(udata);
#endif
}
}
为什么内存对齐
unsigned char* udata = (unsigned char*)malloc(size + sizeof(void*) + NCNN_MALLOC_ALIGN);
unsigned char** adata = alignPtr((unsigned char**)udata + 1, NCNN_MALLOC_ALIGN);
adata[-1] = udata;
unsigned char* udata = ((unsigned char**)ptr)[-1];
DataReader
UML类图
- UML类图:定义基类接口,派生类处理不同业务场景实现,分别为从::内存和stdio::输入数据。
- 数据结构:DataReader将不同形式输入,采用差异化方式读入至内存,以便后续::统一使用::。
- Stdio:维护FILE指针fp,通过fscanf与fread方法,将文件内容读入内存;
- Memroy:维护buffer地址,通过sscanf和memcpy方法,将buffer内容读入内存。
class DataReaderFromStdioPrivate
{
public:
DataReaderFromStdioPrivate(FILE* _fp)
: fp(_fp)
{}
FILE* fp;
};
class DataReaderFromMemoryPrivate
{
public:
DataReaderFromMemoryPrivate(const unsigned char*& _mem)
: mem(_mem)
{}
const unsigned char*& mem;
};
Why DataReader
- 在模型管理、网络参数管理、参数管理等中,均需要处理数据的输入与内存存储。这样,有多个场景使用相同的功能模块,因此NCNN::封装DataReader,使得代码方便复用::。
Net
网络结构与权重参数
- network structure,存储格式为plain param file或binary param file
- network weight data,存储格式为model file或external memory
- 如何处理不同输入?NCNN采用成员::函数重载::手段,将不不同输入场景(如文件路径、文件指针、文件内存等),转换为DataReader形参,::使得核心流程统一,保证复用性::。
class NCNN_EXPORT Net
{
public:
// ! 加载网络结构
// - 方式1:from plain param file
int load_param(FILE* fp); // 文件指针
int load_param(const char* protopath); // 文件路径
int load_param_mem(const char* mem); // 文件内存buffer
// - 方式2:from binary param file
int load_param_bin(FILE* fp);
int load_param_bin(const char* protopath);
// - 方式3:核心实现,方式1与方式2函数均调用方式3
int load_param(const DataReader& dr);
int load_param_bin(const DataReader& dr);
// ! 加载权重参数
// - 方式1:from model file
// return 0 if success
int load_model(FILE* fp);
int load_model(const char* modelpath);
// - 方式2:from external memory
int load_model(const unsigned char* mem);
// - 方式3:核心实现,方式1与方式2函数均调用方式3
int load_model(const DataReader& dr);
};
- 以加载网络结构为例:文件路径➡️文件指针➡️DataReader对象,调用流程如下
网络结构定义与解析
权重参数定义与解析
如何管理网络中的层(Layer)
- 一个网络有N层,每层有一个唯一的名字和ID,所有的层保存在数组中(std::vector);
- 名字的类型为字符串类型(char*),ID类型为int整型,::遍历数组找到指定名字对应的数组索引,即为ID::。
class NetPrivate
{
public:
NetPrivate(Option& _opt);
Option& opt;
int forward_layer(int layer_index, std::vector<Mat>& blob_mats, const Option& opt) const;
int convert_layout(Mat& bottom_blob, const Layer* layer, const Option& opt) const;
int do_forward_layer(const Layer* layer, std::vector<Mat>& blob_mats, const Option& opt) const;
void update_input_output_indexes();
void update_input_output_names();
std::vector<Blob> blobs;
std::vector<Layer*> layers;
std::vector<int> input_blob_indexes;
std::vector<int> output_blob_indexes;
std::vector<const char*> input_blob_names;
std::vector<const char*> output_blob_names;
std::vector<custom_layer_registry_entry> custom_layer_registry;
std::vector<overwrite_builtin_layer_registry_entry> overwrite_builtin_layer_registry;
PoolAllocator* local_blob_allocator;
PoolAllocator* local_workspace_allocator;
};
注册与创建自定义层
- 关键函数
// layer factory function
typedef Layer* (*layer_creator_func)(void*);
typedef void (*layer_destroyer_func)(Layer*, void*);
// NetPrivate 成员变量
std::vector<custom_layer_registry_entry> custom_layer_registry;
std::vector<overwrite_builtin_layer_registry_entry> overwrite_builtin_layer_registry;
// 注册自定义层成员函数
// register custom layer or overwrite built-in layer by layer type
int Net::register_custom_layer(int index, layer_creator_func creator, layer_destroyer_func destroyer = 0, void* userdata = 0);
// 创建自定义层成员函数 - 查找层类型得到层索引(ID),重载函数,typeindex为核心函数
virtual Layer* Net::create_custom_layer(const char* type); // #1
virtual Layer* Net::create_overwrite_builtin_layer(const char* type);
virtual Layer* Net::create_custom_layer(int index); // 被#1调用
virtual Layer* Net::create_overwrite_builtin_layer(int typeindex);
- ::注册自定义层流程图:::自定义层可能作为新层加入custom_layer_registry,或者覆盖内置层加入overwrite_builtin_layer_registry。
- int custom_index = index & ~LayerType::CustomBit;
- 若index == custom_index,则该层属于内置层,否则属于用户自定义层。
- ::创建自定义层流程图:::根据层索引(数组索引),以O(1)时间得到创建该层的**::函数指针::**。这里采用函数指针,不关注具体什么层,抽象程度高,实现通用。
创建层与生成网络
网络的友元类(Extrctor)
class NCNN_EXPORT Net
{
public:
// construct an Extractor from network
Extractor create_extractor() const;
protected:
friend class Extractor;
};
Extractor Net::create_extractor() const
{
return Extractor(this, d->blobs.size());
}
Extractor
Extractor *--> Net:聚合关系。
class NCNN_EXPORT Extractor
{
public:
virtual ~Extractor();
// copy and assign
Extractor(const Extractor&);
Extractor& operator=(const Extractor&);
// 配置light mode和线程数
void set_light_mode(bool enable);
void set_num_threads(int num_threads);
// set memory allocator and clear mats && allocators
void set_blob_allocator(Allocator* allocator);
void set_workspace_allocator(Allocator* allocator);
void clear();
// type = 0, default
// type = 1, do not convert fp16/bf16 or / and packing
// NCNN_STRING 模式
int input(const char* blob_name, const Mat& in);
int extract(const char* blob_name, Mat& feat, int type = 0);
// blob index 模式 - 重载函数,被const char*形参版本调用
int input(int blob_index, const Mat& in);
int extract(int blob_index, Mat& feat, int type = 0);
protected:
friend Extractor Net::create_extractor() const;
Extractor(const Net* net, size_t blob_count);
private:
ExtractorPrivate* const d;
};
class ExtractorPrivate
{
public:
// 构造注入Net实例,聚合关系
ExtractorPrivate(const Net* _net) : net(_net) {}
const Net* net;
std::vector<Mat> blob_mats;
Option opt;
};
输入与输出流程
- Extractor输入数据:传入目标blob名称与Mat数据。
- Extractor获取输出:通过调用NetPrivate::forward_layer函数获取网络输出,核心调用为
- ret = d->net->d->forward_layer(layer_index, d->blob_mats, d->opt);
NetPrivate::forward_layer
- 层推理函数:根据层索引获取Layer实例,最终调用::layer->forward_inplace::或::layer->forward::进行推理。
int NetPrivate::forward_layer(int layer_index, std::vector<Mat>& blob_mats, const Option& opt) const
{
const Layer* layer = layers[layer_index];
for (size_t i = 0; i < layer->bottoms.size(); i++)
{
int bottom_blob_index = layer->bottoms[i];
if (blob_mats[bottom_blob_index].dims == 0)
{
int ret = forward_layer(blobs[bottom_blob_index].producer, blob_mats, opt);
if (ret != 0) return ret;
}
}
if (layer->featmask)
{
return do_forward_layer(layer, blob_mats, get_masked_option(opt, layer->featmask));
}
else
{
return do_forward_layer(layer, blob_mats, opt);
}
}
- NetPrivate::do_forward_layer核心函数流程图
Layer
深度学习CNN中有很多层,每层手动创建十分麻烦,而且这种硬件编码方式不可取。我们能否采用自动化的方式创建各层的子类实例呢?下面从软件的方式介绍NCNN如何创建子类实例。
给每个层起一个::名字::,并定义**::创建和销毁::Layer实例的函数指针,且允许携带一些user data**。
// layer factory function
typedef Layer* (*layer_creator_func)(void*);
typedef void (*layer_destroyer_func)(Layer*, void*);
struct layer_registry_entry {
#if NCNN_STRING
// layer type name
const char* name;
#endif // NCNN_STRING
// layer factory entry
layer_creator_func creator;
};
struct custom_layer_registry_entry {
#if NCNN_STRING
// layer type name
const char* name;
#endif // NCNN_STRING
// layer factory entry
layer_creator_func creator;
layer_destroyer_func destroyer;
void* userdata;
};
struct overwrite_builtin_layer_registry_entry {
// layer type index
int typeindex;
// layer factory entry
layer_creator_func creator;
layer_destroyer_func destroyer;
void* userdata;
};
创建层的通用性设计
NCNN将网络中的不同层抽象为Layer基类,每个层都索引index或名字name,通过名字可查询到索引。
#if NCNN_STRING
int layer_to_index(const char* type) {
for (int i = 0; i < layer_registry_entry_count; i++) {
if (strcmp(type, layer_registry[i].name) == 0)
return i;
}
return -1;
}
Layer* create_layer(const char* type) {
int index = layer_to_index(type);
if (index == -1)
return 0;
return create_layer(index);
}
#endif // NCNN_STRING
// create layer from layer type
NCNN_EXPORT Layer* create_layer(int index);
网络层分类众多,如何区分不同层呢?新的层如何扩展呢?我们带着这些疑问剖析源码。
下面是纯C实现的创建Layer的调用流程,NCNN定义layer_registry_entry_count,由此可知NCNN将采用::注册与查询的::方式管理Layer,这在深度学习引擎中是一种流程设计的模式,可以去除大量if...else,提升扩展性与可测试性。
另一方面,NCNN在多种CPU实施了加速,如何创建不同CPU加速方法的层实例呢?这里NCNN采用C风格::编译预处理宏::处理不同平台通用。
// 计算注册的Layer实例的个数
#include "layer_registry.h"
static const int layer_registry_entry_count = sizeof(layer_registry) / sizeof(layer_registry_entry);
Layer* create_layer(int index) {
...
#if NCNN_RUNTIME_CPU && NCNN_AVX512
if (ncnn::cpu_support_x86_avx512())
layer_creator = layer_registry_avx512[index].creator;
else
#endif// NCNN_RUNTIME_CPU && NCNN_AVX512
#if NCNN_RUNTIME_CPU && NCNN_FMA
if (ncnn::cpu_support_x86_fma())
layer_creator = layer_registry_fma[index].creator;
else
#endif// NCNN_RUNTIME_CPU && NCNN_FMA
#if NCNN_RUNTIME_CPU && NCNN_AVX
if (ncnn::cpu_support_x86_avx())
layer_creator = layer_registry_avx[index].creator;
else
#endif // NCNN_RUNTIME_CPU && NCNN_AVX
#if NCNN_RUNTIME_CPU && NCNN_LASX
if (ncnn::cpu_support_loongarch_lasx())
layer_creator = layer_registry_lasx[index].creator;
else
#endif // NCNN_RUNTIME_CPU && NCNN_LASX
#if NCNN_RUNTIME_CPU && NCNN_LSX
if (ncnn::cpu_support_loongarch_lsx())
layer_creator = layer_registry_lsx[index].creator;
else
#endif // NCNN_RUNTIME_CPU && NCNN_LSX
#if NCNN_RUNTIME_CPU && NCNN_MSA
if (ncnn::cpu_support_mips_msa())
layer_creator = layer_registry_msa[index].creator;
else
#endif // NCNN_RUNTIME_CPU && NCNN_MSA
#if NCNN_RUNTIME_CPU && NCNN_RVV
if (ncnn::cpu_support_riscv_v())
layer_creator = layer_registry_rvv[index].creator;
else
#endif // NCNN_RUNTIME_CPU && NCNN_RVV
layer_creator = layer_registry[index].creator;
...
return layer;
}
伪代码中layer_registry在layer_registry.h.in中定义,通过CMake config函数自动化生成(configure_file(layer_registry.h.in ${CMAKE_CURRENT_BINARY_DIR}/layer_registry.h)),生成示例如下:
layer_registry注册机制
借助CMake实现
NCNN Layer采用注册方式管理层,在编译期利用CMake configure_file()与预编译宏,动态生成创建Layer实例的函数名注册表。CMake configure_file()在编译期间控制编译行为的方法较为实用,使用方法不再赘述。
///---------- layer_registry.h.in
static const layer_registry_entry layer_registry[] = {
@layer_registry@
};
#if NCNN_RUNTIME_CPU && NCNN_AVX512
static const layer_registry_entry layer_registry_avx512[] = {
@layer_registry_avx512@
};
#endif // NCNN_RUNTIME_CPU && NCNN_AVX512
#if NCNN_RUNTIME_CPU && NCNN_FMA
static const layer_registry_entry layer_registry_fma[] = {
@layer_registry_fma@
};
#endif // NCNN_RUNTIME_CPU && NCNN_FMA
... // 省略剩下的,更多参考源码 src/layer_registry.h.in
///---------- configure_file()生成的代码如下
static const layer_registry_entry layer_registry[] = {
#if NCNN_STRING
{"AbsVal", AbsVal_final_layer_creator}, //------- 思考:这个如何与具体的Layer关联起来?
#else
{AbsVal_final_layer_creator},
#endif
#if NCNN_STRING
{"ArgMax", 0},
#else
{0},
#endif
... // 省略剩下的,更多参考源码 build/src/layer_registry.h
Layer创建与销毁(注册自动化)
xxx_layer_creator是如何与子类关联起来呢?NCNN通过定义创建Layer宏的方式,在编写算子时注册(::Mediapipe::深度学习应用框架也采用该方法)。
- ::Step1:::定义宏 - 创建和销毁Layer函数
// Layer.h 定义创建/销毁的宏,这里很简单,使用new/delete构造和析构类实例,这里还是采用默认构造函数
#define DEFINE_LAYER_CREATOR(name) \
::ncnn::Layer* name##_layer_creator(void* /*userdata*/) \
{ \
return new name; \
}
#define DEFINE_LAYER_DESTROYER(name) \
void name##_layer_destroyer(::ncnn::Layer* layer, void* /*userdata*/) \
{ \
delete layer; \
})
- ::Step2:::使用宏 - NCNN src/layer中实现了各式各样的Layer层子类实现,用于构建Net,以及与Extractor协作。NCNN 在cmake/ncnn_add_layer.cmake中定义自动化生成class的模板函数
ncnn_add_layer(class),在src/CMakeLists.txt调用以便注册不同Layer,从而实现layer_decalaration.h和layer_registry.h的自动化编码。
///---------- build/src/layer_declaration.h
// Layer Declaration header
//
// This file is auto-generated by cmake, don't edit it.
#include "layer/absval.h"
#include "layer/arm/absval_arm.h"
namespace ncnn {
class AbsVal_final : virtual public AbsVal, virtual public AbsVal_arm {
public:
virtual int create_pipeline(const Option& opt) {
{ int ret = AbsVal::create_pipeline(opt); if (ret) return ret; }
{ int ret = AbsVal_arm::create_pipeline(opt); if (ret) return ret; }
return 0;
}
virtual int destroy_pipeline(const Option& opt) {
{ int ret = AbsVal_arm::destroy_pipeline(opt); if (ret) return ret; }
{ int ret = AbsVal::destroy_pipeline(opt); if (ret) return ret; }
return 0;
}
};
DEFINE_LAYER_CREATOR(AbsVal_final) ///---------- 在此处使用宏,这里是cmake自动生成,mediapipe是手动编码+prototxt注册算子
} // namespace ncnn
#include "layer/batchnorm.h"
#include "layer/arm/batchnorm_arm.h"
namespace ncnn {
class BatchNorm_final : virtual public BatchNorm, virtual public BatchNorm_arm {
public:
virtual int create_pipeline(const Option& opt) {
{ int ret = BatchNorm::create_pipeline(opt); if (ret) return ret; }
{ int ret = BatchNorm_arm::create_pipeline(opt); if (ret) return ret; }
return 0;
}
virtual int destroy_pipeline(const Option& opt) {
{ int ret = BatchNorm_arm::destroy_pipeline(opt); if (ret) return ret; }
{ int ret = BatchNorm::destroy_pipeline(opt); if (ret) return ret; }
return 0;
}
};
DEFINE_LAYER_CREATOR(BatchNorm_final)
} // namespace ncnn
... // 省略剩下的,更多可参考build/src/layer_declaration.h
::知识点:::
JAVA语言中有反射机制,一般地,通过定义基类接口,子类继承基类并实现接口,结合设计模式实现软件框架的高可用与高扩展性。C++无反射支持,从思想上我们可采用C/C++模拟实现。这里介绍两种常见的方法:注册机制与插件技术。
- NCNN:在编译期,采用CMake configure_file()动态生成代码的方式注册Layer层,Layer子类配置创建子类工厂方法宏;
- Mediapipe:在运行期,采用prototxt注册Node名称的方式配置pipeline,Node子类配置创建子类工厂方法宏(与NCNN类似);
- 高性能计算引擎算子:在运行期,采用动态库作为插件,实现NodeGraph计算图所需算子协议接口,通过json或其他格式配置文件,配置pipeline以及各节点的动态库路径、动态库接口名称;
NCNN与Mediapipe采用注册机制,通过class名称创建节点实例,是C++风格实现。高性能异构计算引擎算子通过算子插件动态装载的形式创建,是传统C风格实现。根据业务场景,选择合适的实现方案。
前向推理
- 批处理 [模板方法]
// 批量处理接口
int Layer::forward(const std::vector<Mat>& bottom_blobs, std::vector<Mat>& top_blobs, const Option& opt) const;
// 单张处理接口
int Layer::forward(const Mat& bottom_blob, Mat& top_blob, const Option& opt) const;
单张与批量处理,在接口上形参输入向量或单个输入输出,内部均适配至(调用)forward_inplace核心接口,而该接口为虚方法,由其子类派生实现,从而实现高扩展。
::知识点:::
- 这里使用封装变化新的实现,设计模式为模板方法,是OOP中常用的继承机制;
- 函数级别适配,这在实际项目中常用,比如为了兼容老接口,采用C++重载或者具有相同功能的C方法(不支持重载),兼容用户老版本逻辑,平滑升级;
- 类似地,在人脸算法引擎中,支持单张与批量分析接口,本质上批量可退化为单张,从而设计为通用批处理算法。