NCNN推理框架学习笔记(手稿版)

593 阅读25分钟

[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 fromalexnet.paramalexnet.param.binalexnet.bin
file pathload_param(const char*)load_param_bin(const char*)load_model(const char*)
file descriptorload_param(FILE*)load_param_bin(FILE*)load_model(FILE*)
file memoryload_param_mem(const char*)load_param(const unsigned char*)load_model(const unsigned char*)
android assetload_param(AAsset*)load_param_bin(AAsset*)load_model(AAsset*)
android asset pathload_param(AAssetManager*, const char*)load_param_bin(AAssetManager*, const char*)load_model(AAssetManager*, const char*)
custom IO readerload_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]]

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]]。

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()方法),一方面是编译加速,另外一方面数据和方法分层管理,逻辑逻辑清晰。

extractor_pimpl.png

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]]。

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
  • 调用关系图:Calls-extract.png

NetPrivate::forward_layer

  • 转换输入:
  • [[ClusterControlFlow-forward_layer.png]]

ClusterControlFlow-forward_layer.png

  • [[ClusterControlFlow-do_forward_layer.png]]

ClusterControlFlow-do_forward_layer.png

Layer::forward_inplace

Layer基类设计
  • 作为基类,层的加载参数load_param、加载模型load_modelcreate_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]]:

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]]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.hlayer_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-Convolution.png

*   \[\[UMLClassDiagram-Pooling.png]]

UMLClassDiagram-Pooling.png

*   \[\[UMLClassDiagram-Softmax.png]]

UMLClassDiagram-Softmax.png

*   \[\[UMLClassDiagram-ReLU.png]]

UMLClassDiagram-ReLU.png

*   \[\[UMLClassDiagram-Bias.png]]

UMLClassDiagram-Bias.png

*   \[\[UMLClassDiagram-Sigmoid.png]]

UMLClassDiagram-Sigmoid.png

*   \[\[UMLClassDiagram-Gemm.png]]

UMLClassDiagram-Gemm.png

自定义Layer

Allocator

UML类图

  • UML类图:定义基类接口,派生类处理不同业务场景实现,分别为::线程安全与非线程安全::版本。

Image.tiff

  • 数据结构: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;
};

分配流程与原理

  • 申请函数流程图

Image.tiff

  • 释放函数流程图

Image.tiff

  • 申请(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::输入数据。

Image.png

  • 数据结构: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,使得代码方便复用::。

Image.tiff


Net

网络结构与权重参数

  • network structure,存储格式为plain param file或binary param file
  • network weight data,存储格式为model fileexternal 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对象,调用流程如下

Image.png

网络结构定义与解析
权重参数定义与解析

如何管理网络中的层(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,则该层属于内置层,否则属于用户自定义层。

Image.tiff

  • ::创建自定义层流程图:::根据层索引(数组索引),以O(1)时间得到创建该层的**::函数指针::**。这里采用函数指针,不关注具体什么层,抽象程度高,实现通用

Image.tiff

创建层与生成网络

网络的友元类(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数据。

Image.png

  • Extractor获取输出:通过调用NetPrivate::forward_layer函数获取网络输出,核心调用为
    • ret = d->net->d->forward_layer(layer_index, d->blob_mats, d->opt);

Image.tiff

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核心函数流程图

Image.png


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,提升扩展性与可测试性。

Image.tiff

另一方面,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)),生成示例如下:

Image.png

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核心接口,而该接口为虚方法,由其子类派生实现,从而实现高扩展。

Image.tiff

::知识点::

  • 这里使用封装变化新的实现,设计模式为模板方法,是OOP中常用的继承机制;
  • 函数级别适配,这在实际项目中常用,比如为了兼容老接口,采用C++重载或者具有相同功能的C方法(不支持重载),兼容用户老版本逻辑,平滑升级;
  • 类似地,在人脸算法引擎中,支持单张与批量分析接口,本质上批量可退化为单张,从而设计为通用批处理算法。