0 NCNN 介绍
ncnn(Nebula Convolutional Neural Network)是一个高效、轻量级的深度学习框架,支持各种神经网络模型,如pytorch、tensorflow、onnx等,以及多种硬件后端,如x86、arm、riscv、mips、vulkan等。由 Tencent 开发,具有以下特性:
-
支持卷积神经网络,支持多输入和多分支结构,可计算部分分支
ncnn 支持卷积神经网络结构,以及多分支多输入的复杂网络结构,如主流的 VGG、GoogLeNet、ResNet、SqueezeNet 等。
-
无任何第三方库依赖,不依赖 BLAS/NNPACK 等计算框架
ncnn 不依赖任何第三方库,完全独立实现所有计算过程
-
纯 C++ 实现,跨平台,支持 Android、 iOS 等
ncnn 代码全部使用 C/C++ 实现,跨平台的 cmake 编译系统,可在已知的绝大多数平台编译运行,如 Linux、Windows、Mac OS、Android、iOS 等。采用 C++ 03 标准实现,只用到了 std::vector 和 std::string 两个 STL 模板
-
ARM NEON Instrinsic 优化,计算速度快
ncnn 为手机端 CPU 运行做了深度细致的优化,使用 ARM NEON 指令集实现卷积层、全连接层、池化层等大部分 CNN 关键层。对于寄存器压力较大的 armv7 架构,手工编写 neon 汇编,内存预对齐,cache 预缓存,排列流水线,充分利用一切硬件资源,防止编译器意外负优化。
这个就很奈斯了,大有可用,本来之前还想着自己实现的,找到了这个就可以直接用了。
-
精细的内存管理和数据结构设计,内存占用低
,在卷积层、全连接层等计算量较大的层实现中,没有采用通常框架中的 im2col + 矩阵乘法,因为这种方式会构造出非常大的矩阵,消耗大量内存。因此,ncnn 采用原始的滑动窗口卷积实现,并在此基础上进行优化,大幅节省了内存。在前向网络计算过程中,ncnn 可自动释放中间结果所占用的内存,进一步减少内存占用。
-
支持多核并行计算加速,ARM big.LITTLE CPU 调度优化
ncnn 提供了基于 OpenMP 的多核心并行计算加速,在多核心 CPU 上启用后能够获得很高的加速收益。ncnn 提供线程数控制接口,可以针对每个运行实例分别调控,满足不同场景的需求。
-
可扩展的模型设计,支持 8bit 量化和半精度浮点存储
-
支持直接内存零拷贝引用加载网络模型
-
可注册自定义层实现并扩展
ncnn 提供了注册自定义层实现的扩展方式,可以将自己实现的特殊层内嵌到 ncnn 的前向计算过程中
1 NCNN 编译与安装
最直接的编译方式就是使用 cmake:
bash
# 在 ncnn 项目目录下
mkdir build && cd build
cmake ..
make -j
如果在编译的时候需要设置一些 cmake 变量等操作来控制编译过程,可以使用 toolchains 目录下面的 cmake 脚本,自己编写编译脚本,来对多种不同的处理器和编译器提供支持:
build_custom.sh
bash
set -e
set -x
if [ "${1}" = "x86" ]; then
TOOLCHAIN_FILE=./toolchains/host.gcc.toolchain.cmake
elif [ "${1}" = "arm" ]; then
TOOLCHAIN_FILE=./toolchains/aarch64-linux-gnu.toolchain.cmake
elif [ "${1}" = "cross" ]; then
TOOLCHAIN_FILE=./toolchains/aarch64-linux-gnu.toolchain.cmake
elif [ "${1}" = "pi" ]; then
TOOLCHAIN_FILE=./toolchains/arm-linux-gnueabihf.toolchain.cmake
else
echo "Usage: ./compile.sh <x86|arm|cross|pi>"
exit 1
fi
OS_TYPE=`uname -s`
if [ "${OS_TYPE}" == "Linux" ]; then
CPU_NUM=`cat /proc/cpuinfo | grep "processor" | wc -l`
elif [ "${OS_TYPE}" = "Darwin" ]; then
CPU_NUM=`sysctl -n machdep.cpu.core_count`
else
echo "Unknown OS type: ${OS_TYPE}, use cpu number 4 in default"
CPU_NUM=2
fi
SOURCE_DIR=./
BUILD_DIR=${SOURCE_DIR}/build/
INSTALL_DIR=${BUILD_DIR}/install/
cmake \
-S ${SOURCE_DIR} \
-B ${BUILD_DIR} \
-G "Unix Makefiles" \
-DNCNN_SHARED_LIB=ON \
-DENABLE_RTTI=ON \
-DNCNN_DISABLE_RTTI=OFF \
-DNCNN_BUILD_BENCHMARK=OFF \
-DNCNN_BUILD_EXAMPLES=OFF \
-DNCNN_BUILD_TOOLS=ON \
-DNCNN_BUILD_TESTS=OFF \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_INSTALL_PREFIX=${INSTALL_DIR} \
-DCMAKE_EXPORT_COMPILE_COMMANDS=1 \
-DCMAKE_TOOLCHAIN_FILE=${TOOLCHAIN_FILE}
cmake --build ${BUILD_DIR} -j${CPU_NUM}
上面的编译脚本提供了这样的功能:
- 通过脚本参数的不同,而选择使用 toolchains 中提供的不同的编译脚本、
- 通过获取 cpu number,作为最后编译时候使用的最大 job 数,来尽可能的优化编译时间
- 通过使用 cmake 的
-S
、-B
、-G
选项,来支持指定代码目录、编译目录以及指定生成器 - 通过使用
-D
来选择编译过程中打开、关闭哪些开关,ncnn 支持的开关位于项目根目录的CMakeLists.txt
中,例如可以控制是否生成动态库、是否编译 tests、是否编译 tools、指定安装目录等
上面的编译脚本使用:
- 给脚本添加执行权限
bash
chmod +x ./build_custom.sh
- 根据不同平台执行编译
bash
# x86
./build_custom.sh x86
# arm
./build_custom.sh arm
- 安装,编译完成后,在编译目录执行 make install 就可以完成到指定目录的安装
bash
cd build
make install
2 算子注册
2.1 DEFINE_LAYER_CREATOR
这个宏创建了一个 layer 的对象,与之对应的还有一个DEFINE_LAYER_DESTROYER
宏。这个的实现位于 https://github.com/Tencent/ncnn/blob/20220701/src/layer.h#L201:
cpp
#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.2 layer_registry_entry
算子的注册过程中,注册的是用于创建指定 layer 的函数对象,需要一个统一的数据结构用来存储这个函数对象,这就要用到layer_registry_entry
,定义于 https://github.com/Tencent/ncnn/blob/20220701/src/layer.h#L170,除了这个数据结构,ncnn 还支持用户自定义算子注册和释放,需要用到custom_layer_registry_entry
,这两个数据结构如下:
cpp
// 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;
};
2.3 ncnn_add_layer
这是一个 cmake 的宏,定义于 https://github.com/Tencent/ncnn/blob/20220701/cmake/ncnn_add_layer.cmake#L79,总体来讲,所有 layer 的注册过程就是通过这个 cmake 宏完成的:
cpp
macro(ncnn_add_layer class)
string(TOLOWER ${class} name)
# WITH_LAYER_xxx option
if(${ARGC} EQUAL 2)
option(WITH_LAYER_${name} "build with layer ${name}" ${ARGV1})
else()
option(WITH_LAYER_${name} "build with layer ${name}" ON)
endif()
if(NCNN_CMAKE_VERBOSE)
message(STATUS "WITH_LAYER_${name} = ${WITH_LAYER_${name}}")
endif()
if(WITH_LAYER_${name})
list(APPEND ncnn_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/layer/${name}.cpp)
# look for arch specific implementation and append source
# optimized implementation for armv7, aarch64 or x86
set(LAYER_ARCH_SRC ${CMAKE_CURRENT_SOURCE_DIR}/layer/${NCNN_TARGET_ARCH}/${name}_${NCNN_TARGET_ARCH}.cpp)
if(EXISTS ${LAYER_ARCH_SRC})
set(WITH_LAYER_${name}_${NCNN_TARGET_ARCH} 1)
list(APPEND ncnn_SRCS ${LAYER_ARCH_SRC})
endif()
set(LAYER_VULKAN_SRC ${CMAKE_CURRENT_SOURCE_DIR}/layer/vulkan/${name}_vulkan.cpp)
if(NCNN_VULKAN AND EXISTS ${LAYER_VULKAN_SRC})
set(WITH_LAYER_${name}_vulkan 1)
list(APPEND ncnn_SRCS ${LAYER_VULKAN_SRC})
endif()
endif()
下面是几个调用这个宏来注册 layer 的代码片段,位于 src/CMakeLists.txt:
cpp
...
ncnn_add_layer(Convolution1D)
ncnn_add_layer(Pooling1D)
ncnn_add_layer(ConvolutionDepthWise1D)
ncnn_add_layer(Pooling3D)
ncnn_add_layer(MatMul)
ncnn_add_layer(Deconvolution1D)
ncnn_add_layer(DeconvolutionDepthWise1D)
ncnn_add_layer(Einsum)
...
configure_file(layer_declaration.h.in ${CMAKE_CURRENT_BINARY_DIR}/layer_declaration.h)
configure_file(layer_registry.h.in ${CMAKE_CURRENT_BINARY_DIR}/layer_registry.h)
configure_file(layer_type_enum.h.in ${CMAKE_CURRENT_BINARY_DIR}/layer_type_enum.h)
这里的 configure_file 很重要,它把 cmake 代码和 c++ 代码连接起来,它可以把 cmake 中定义的变量的内容,替换到指定的目标文件中,比如上面的layer_declaration.h.in
,它的内容如下:
cpp
@layer_declaration@
这个文件只有一行内容,layer_declaration
是 cmake 中的变量,编译过后,就会生成build/src//layer_declaration.h
这个文件,这个文件的内容就是layer_declaration
变量的值,在 ncnn 中,它长下面这个样子:
cpp
#include "layer/absval.h"
namespace ncnn {
class AbsVal_final : virtual public AbsVal
{
public:
virtual int create_pipeline(const Option& opt) {
{ int ret = AbsVal::create_pipeline(opt); if (ret) return ret; }
return 0;
}
virtual int destroy_pipeline(const Option& opt) {
{ int ret = AbsVal::destroy_pipeline(opt); if (ret) return ret; }
return 0;
}
};
DEFINE_LAYER_CREATOR(AbsVal_final)
} // namespace ncnn
...
这里调用了前面提到的DEFINE_LAYER_CREATOR
这个宏,也就是定义了AbsVal
这个 layer 的创建函数,这里只列了AbsVal
这一个 layer,实际上这个自动生成的文件内容包含后面有所有的 layer 的创建函数。这个 layer 的创建函数的注册涉及到layer_registry.h.in
这个文件,它的内容如下:
cpp
static const layer_registry_entry layer_registry[] = {
@layer_registry@
};
...
这里面的layer_registry
,也是一个 cmake 变量,在编译之后这个文件会被自动生成·build/src/layer_registry.h·这个文件,内容如下:
cpp
static const layer_registry_entry layer_registry[] = {
#if NCNN_STRING
{"AbsVal", AbsVal_final_layer_creator},
#else
{AbsVal_final_layer_creator},
#endif
...
...
};
这就是 layer 注册的过程,layer 创建函数被维护到layer_registry
这个数组中,数组的元素类型就是前面提到的layer_registry_entry
。
前面提到 cmake 会给上面的这些变量赋值,具体细节就在前面给出来的ncnn_add_layer
这个宏里面,实际上就是写入hard code:
cpp
if(WITH_LAYER_${name})
set(layer_declaration "${layer_declaration}namespace ncnn {\n${layer_declaration_class}\n{\n")
set(layer_declaration "${layer_declaration}public:\n")
set(layer_declaration "${layer_declaration} virtual int create_pipeline(const Option& opt) {\n${create_pipeline_content} return 0;\n }\n")
set(layer_declaration "${layer_declaration} virtual int destroy_pipeline(const Option& opt) {\n${destroy_pipeline_content} return 0;\n }\n")
set(layer_declaration "${layer_declaration}};\n")
set(layer_declaration "${layer_declaration}DEFINE_LAYER_CREATOR(${class}_final)\n} // namespace ncnn\n\n")
endif()
layer_registry
的赋值也是一样的:
cpp
if(WITH_LAYER_${name})
set(layer_registry "${layer_registry}#if NCNN_STRING\n{\"${class}\", ${class}_final_layer_creator},\n#else\n{${class}_final_layer_creator},\n#endif\n")
else()
set(layer_registry "${layer_registry}#if NCNN_STRING\n{\"${class}\", 0},\n#else\n{0},\n#endif\n")
endif()
2.4 create_layer
layer 的注册过程明了之后,layer 的创建就很清晰了:
cpp
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_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 创建是有优先级的,特定后端的加速实现如果存在,就创建特定实现的 layer,如果没有特定实现,就创建最 naive 的 layer 实现。这里特定后端的加速实现指的是例如在 x86 机器上基于 avx512/avx2/avx/fma 等矢量加速指令的实现,在 arm 机器上基于 ARM Neon 指令的加速实现等。