Ch11: ncnn——零依赖的手工匠人
Part 3: 六大框架架构精讲 前置章节:Ch10: 量化入门 后续章节:Ch12: MNN——端侧瑞士军刀
一句话定位
ncnn 是移动端推理引擎中最”纯粹”的一个:零第三方依赖、零自动生成代码、每一行 SIMD 内联汇编都是手写的。它就像一个只用手工工具的木匠——不用电动工具(不依赖 protobuf、不依赖 Eigen),每一个榫卯都是自己量尺寸、自己凿的。这种极致的手工哲学,让它在 ARM 手机上跑出了令人惊讶的性能,也让它成为了学习推理引擎源码的最佳入门材料。
GitHub 22K star,腾讯开源,约 500 个源文件——这个体量意味着你真的可以一个人把它读完。
类比:手工木匠的工作方式
想象一个传统的日本木匠。他的工作台上没有电锯、没有数控机床,只有刨子、锯子、凿子这些基本工具。他做一把椅子,每一根榫头的尺寸都是自己用手量的,每一个卯眼都是自己一刀一刀凿的。你问他:”为什么不用电锯?快多了。”他说:”电锯确实快,但它需要电源(依赖),需要维护(版本兼容),做不了最精细的活(性能不可控),而且换个工地可能没有电源(跨平台问题)。”
ncnn 就是这样一个手工木匠。它不依赖任何第三方库——连 C++ 标准库的 STL 容器都尽量回避(自己实现了 Mat 等核心数据结构)。它不用 C++11 及以上的特性(没有 std::shared_ptr,没有 lambda,没有 auto)。它的每一个 NEON 内核都是手写的内联汇编,不是用编译器 intrinsics,而是真正的汇编指令。
这种做法的代价是:代码量大、维护负担重、新算子开发慢。但收益也很明显:编译快、二进制体积小(arm64 下约 700KB)、性能可预测、可以在任何有 C++ 编译器的平台上编译。
本章的类比边界:手工木匠的类比强调的是”零依赖”和”手写优化”这两个特征。但木匠是顺序工作的(先切木头、再打磨、再拼装),ncnn 的推理调度其实是按需递归的(Lazy Eval),这一点不像木匠,后面会专门讲。
设计哲学精讲
哲学一:零依赖——编译即是部署
ncnn 的第一条设计原则是:编译它只需要一个 C++ 编译器和 CMake,不需要任何第三方库。
这意味着什么?意味着你在 Android NDK 下交叉编译 ncnn,只需要:
mkdir build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
..
make -j$(nproc)
没有 protobuf 要编译,没有 Eigen 头文件要下载,没有 ONNX 依赖要装。编译出来的 libncnn.a 是一个完全自包含的静态库。
对比之下,编译 ONNX Runtime 需要先编译 protobuf(ONNX 模型格式依赖),编译 MNN 需要先生成 flatbuffers schema 代码,编译 Paddle-Lite 需要处理各种后端的 SDK 依赖。ncnn 的零依赖让它的编译过程像呼吸一样自然——这在嵌入式开发和 CI/CD 环境中是一个巨大的优势。
代价是:ncnn 自己实现了模型格式的解析(纯文本参数文件 .param + 二进制权重文件 .bin),自己实现了所有的数学运算(没有 BLAS 库),自己实现了所有的内存管理。这些”自己实现”的东西,在工程质量上并不比第三方库差——ncnn 的 NEON 卷积内核甚至比很多 BLAS 库更快——但它确实增加了维护成本。
哲学二:elempack 动态打包——数据布局的精髓
ncnn 最具创新性的设计之一是 elempack(element packing)机制。这个概念贯穿整个引擎的数据结构和计算内核,是理解 ncnn 性能的关键。
先回忆 Ch06 讲的 SIMD 概念:一条 NEON 指令可以同时处理 128-bit 寄存器中的 4 个 float32。如果你一次只处理 1 个 float,那 SIMD 寄存器有 75% 的空间被浪费了。
ncnn 的做法是:把相邻通道的同一空间位置的元素打包到一起,让 SIMD 寄存器被填满。具体来说:
elempack=1: 数据按原始布局存储,每个元素是一个 float
Channel 0: [a0, a1, a2, ...]
Channel 1: [b0, b1, b2, ...]
Channel 2: [c0, c1, c2, ...]
Channel 3: [d0, d1, d2, ...]
elempack=4: 把 4 个通道的同一位置打包在一起
Pack 0: [(a0,b0,c0,d0), (a1,b1,c1,d1), (a2,b2,c2,d2), ...]
每个 (a_i, b_i, c_i, d_i) 正好填满一个 128-bit NEON 寄存器
这个打包数量不是固定的——它根据硬件的 SIMD 宽度动态决定:
| 硬件 | SIMD 寄存器宽度 | elempack (fp32) | elempack (fp16) |
|---|---|---|---|
| ARM NEON | 128-bit | 4 | 8 |
| x86 SSE | 128-bit | 4 | - |
| x86 AVX | 256-bit | 8 | - |
| x86 AVX-512 | 512-bit | 16 | - |
核心代码体现在 mat.h 中:
// ncnn/src/mat.h (简化)
class Mat {
public:
// 核心成员
void* data; // 数据指针
int dims; // 维度数 (1, 2, 3, 4)
int w; // 宽
int h; // 高
int d; // 深度(3D卷积用)
int c; // 通道数(打包后的通道数)
size_t cstep; // 通道步长(单通道内元素总数)
// 打包相关
size_t elemsize; // 每个"逻辑元素"的字节数
int elempack; // 每个"逻辑元素"包含几个原始元素
// 内存管理
Allocator* allocator;
int* refcount; // 引用计数
};
这里有一个关键的理解:当 elempack=4 时,c 字段存的不是原始通道数,而是 原始通道数 / 4。也就是说,如果原始 tensor 有 64 个通道,elempack=4 后 c=16,elemsize=16(4 个 float 各 4 字节)。ncnn 把 4 个通道”折叠”成了 1 个逻辑通道,每个逻辑元素就是一个 SIMD 向量。
这样设计的好处是:上层代码遍历 w*h*c 时,每次 load 一个”元素”就自动是一个 SIMD 向量,不需要在每个算子里手动做打包/解包。整个计算路径天然就是 SIMD 友好的。
// elempack=4 的卷积内核(伪代码)
void conv3x3_pack4(const Mat& bottom_blob, Mat& top_blob, const Mat& weight) {
// bottom_blob.elempack == 4, 每次 load 就是 4 个通道的值
for (int p = 0; p < top_blob.c; p++) { // 遍历输出逻辑通道
float* outptr = top_blob.channel(p);
for (int i = 0; i < outh; i++) {
for (int j = 0; j < outw; j++) {
float32x4_t sum = vdupq_n_f32(0.f); // 4 路累加器
for (int k = 0; k < 9; k++) { // 3x3 卷积核
float32x4_t val = vld1q_f32(inptr); // 一次 load 4 通道
float32x4_t wt = vld1q_f32(wtptr); // 对应 4 个权重
sum = vmlaq_f32(sum, val, wt); // 4 路乘加
}
vst1q_f32(outptr, sum); // 一次 store 4 通道
}
}
}
}
这就是 elempack 的精髓:不是在算子内部做 SIMD 优化,而是在数据布局层面就让数据”预对齐”到 SIMD 宽度。算子只需要 load、compute、store,不需要额外的 shuffle 或 gather 指令。
哲学三:Layer 单类继承——极简的算子抽象
ncnn 的算子(Layer)设计是整个引擎中最简洁的部分。和 MNN 的 Op-Execution 分离、ORT 的 OpKernel-KernelRegistry 体系相比,ncnn 的做法朴素到令人感动:一个 Layer 类包含一切。
// ncnn/src/layer.h (简化)
class Layer {
public:
// 加载参数和权重
virtual int load_param(const ParamDict& pd);
virtual int load_model(const ModelBin& mb);
// 推理(一个输入一个输出)
virtual int forward(const Mat& bottom_blob, Mat& top_blob,
const Option& opt) const;
// 推理(多输入多输出)
virtual int forward(const std::vector<Mat>& bottom_blobs,
std::vector<Mat>& top_blobs,
const Option& opt) const;
// 原地推理(输入输出共享同一块内存)
virtual int forward_inplace(Mat& bottom_top_blob,
const Option& opt) const;
// 属性
bool one_blob_only; // 是否只有一个输入一个输出
bool support_inplace; // 是否支持原地计算
bool support_vulkan; // 是否支持 Vulkan
bool support_packing; // 是否支持 elempack
};
每个具体的算子(如 Convolution、ReLU、Pooling)都直接继承这个 Layer 类,在自己的 .cpp 文件中实现 forward 方法。没有”描述/执行分离”,没有”形状推断和计算分离”,所有逻辑都在 forward 里。
这种设计的优势是认知负担极低:你想看 ncnn 怎么做卷积,只需要打开 convolution.cpp,找到 forward 函数,从头读到尾。不需要在 5 个文件之间跳来跳去理解”Op 描述在哪里、Execution 在哪里、形状推断在哪里”。
代价是:所有硬件相关的优化代码都堆在同一个 forward 函数里。一个 Convolution::forward 可能有几千行,里面用 #ifdef 区分 NEON、SSE、AVX、Vulkan 等不同后端。阅读单个算子没问题,但维护和扩展就不太方便了——这也是后来 TNN 为什么要做 BaseLayer/LayerAcc 分离的原因。
哲学四:Lazy Eval + Light Mode——按需递归调度
ncnn 的推理调度不像 TNN 和 Paddle-Lite 那样”先编译整个图,再按拓扑序执行”。它采用了一种更像函数式编程的方式:Lazy Eval(按需计算)。
具体来说,当你调用 extractor.extract("output", output_mat) 时,ncnn 从输出层开始,递归地找它的输入层需要什么数据,再找输入层的输入层需要什么数据,一直回溯到模型的输入层。然后从输入层开始”回弹”,逐层计算。
extract("output")
→ 需要 layer_N 的输出
→ 需要 layer_N-1 的输出
→ ...
→ 需要 layer_0 (输入层) 的输出
→ 直接返回用户设置的 input_mat
← layer_1.forward(input_mat)
← layer_2.forward(layer_1_output)
← ...
← layer_N.forward(...)
← 得到 output_mat
这种调度方式的关键优势是不需要计算整个图。如果一个模型有 3 个输出头(比如目标检测的 bbox、score、class),你只 extract 其中一个,那和另外两个输出头无关的分支就不会被计算。在服务端框架里,这不算什么——图已经编译了反正都要跑。但在移动端,省下 30% 的无用计算可能意味着省下 30% 的电量。
配合 Lazy Eval 的还有 Light Mode(轻量模式):
// ncnn/src/option.h
struct Option {
bool lightmode; // 默认开启
// ...
};
Light Mode 开启后,ncnn 会在一个中间 tensor 不再被任何后续层使用时,立即释放它的内存。这是一种最激进的内存复用策略——不是”预分配内存池然后复用”,而是”用完立刻释放,需要时再分配”。在内存极度紧张的老设备上,这种策略可以把峰值内存压到最低。
这种 Lazy Eval 的调度方式也带来了一个限制:它不太容易做跨层优化。因为每次 forward 调用时只看到当前层和它的输入,看不到整个计算图的全局信息。ncnn 做算子融合(如 Conv+BN+ReLU)是在模型转换阶段(离线工具 ncnnoptimize)完成的,而不是在运行时通过图优化 pass 完成的。
哲学五:Vulkan Compute Shader——唯一的 GPU 路线
在六大框架中,ncnn 是唯一选择 Vulkan Compute Shader 作为移动 GPU 后端的。其他框架的选择是 OpenCL(MNN、TNN、MACE)或 Metal(MNN、TNN 的 iOS 后端)。
为什么选 Vulkan 而不是 OpenCL?nihui(ncnn 作者)在多个场合解释过:Vulkan 是 Khronos 的下一代图形/计算 API,Android 从 7.0 开始原生支持,长期来看会取代 OpenCL(Android 上 OpenCL 支持不稳定,很多厂商不提供或提供的版本不一致)。Vulkan 的优势在于驱动质量更稳定(因为游戏产业推动了 Vulkan 驱动的完善)。
ncnn 的 Vulkan 后端实现了完整的 Compute Shader 管线:
// ncnn/src/layer/vulkan/convolution_vulkan.cpp (概念简化)
class Convolution_vulkan : public Convolution {
VkMat weight_data_gpu; // 权重在 GPU 上的副本
Pipeline* pipeline_convolution; // Compute Shader 管线
int upload_model(VkTransfer& cmd, const Option& opt) {
// 把权重从 CPU 传到 GPU
cmd.record_upload(weight_data, weight_data_gpu, opt);
// 编译 Compute Shader
pipeline_convolution = new Pipeline(vkdev);
pipeline_convolution->create(shader_convolution_spv_data, ...);
return 0;
}
int forward(const VkMat& bottom_blob, VkMat& top_blob,
VkCompute& cmd, const Option& opt) const {
// 绑定参数,提交 Compute Shader
cmd.record_pipeline(pipeline_convolution, bindings, constants, ...);
return 0;
}
};
Vulkan 后端的结构和 CPU 后端完全对称:同一个 Convolution 类里,CPU 版本的 forward 用 NEON 内联汇编,Vulkan 版本的 forward 提交 Compute Shader。这种”一个算子类覆盖所有后端”的设计,和 TNN 的 “BaseLayer + LayerAcc” 分离形成鲜明对比。
哲学六:编译时分层注册表——Layer 工厂的实现
ncnn 怎么知道模型文件里的 “Convolution” 字符串对应哪个 C++ 类?答案是一个编译时构建的分层注册表。
// 自动生成的 layer/layer_registry.h(由 CMake 脚本生成)
static const layer_registry_entry layer_registry[] = {
{"AbsVal", AbsVal_layer_creator, 0},
{"ArgMax", 0, 0}, // 未实现
{"BatchNorm", BatchNorm_layer_creator, 0},
{"Convolution", Convolution_layer_creator, 0},
// ...
};
这个注册表是一个静态数组,在编译时就确定了。每个条目是一个 {名称, 构造函数指针, 析构函数指针} 三元组。模型加载时,ncnn 通过层名字在这个数组里查找,找到对应的构造函数,创建 Layer 实例。
“分层”的含义是:ncnn 可以通过 CMake 选项裁剪不需要的层。如果你的模型只用到了 Convolution、ReLU、Pooling,可以在编译时把其他几百个层全部排除,注册表中只保留这三个。这让最终的二进制体积可以从 700KB 进一步压缩到几百 KB 甚至更小。
# 按需裁剪示例
cmake -DNCNN_VULKAN=OFF \
-DWITH_LAYER_absval=OFF \
-DWITH_LAYER_argmax=OFF \
-DWITH_LAYER_convolution=ON \
-DWITH_LAYER_relu=ON \
-DWITH_LAYER_pooling=ON \
..
核心数据结构:Mat
Mat 是 ncnn 唯一的 Tensor 类型,所有数据(输入图像、中间激活值、模型权重)都用 Mat 表示。它的设计哲学是”够用就好”——没有 dtype 枚举(通过 elemsize 和 elempack 间接推断),没有 device 标记(CPU 就是 Mat,Vulkan 用另一个类 VkMat),没有稀疏支持。
Mat 的内存布局
dims=3, w=4, h=4, c=2, elempack=4 的 Mat:
逻辑上有 8 个原始通道 (c*elempack = 2*4 = 8)
通道组 0 (c=0): 包含原始通道 0,1,2,3
行 0: [(a0,b0,c0,d0), (a1,b1,c1,d1), (a2,b2,c2,d2), (a3,b3,c3,d3)]
行 1: [(a4,b4,c4,d4), (a5,b5,c5,d5), (a6,b6,c6,d6), (a7,b7,c7,d7)]
行 2: ...
行 3: ...
通道组 1 (c=1): 包含原始通道 4,5,6,7
行 0: [(e0,f0,g0,h0), (e1,f1,g1,h1), ...]
...
每个通道组内的元素数 = w * h = 16
cstep = 对齐后的通道步长 (>= w * h)
总内存 = c * cstep * elemsize
cstep 可能比 w*h 大,因为 ncnn 会对通道步长做对齐(通常 16 字节对齐),确保每个通道组的起始地址都是 SIMD 友好的。
关键操作
// 创建 Mat
Mat blob(256, 256, 3); // w=256, h=256, c=3, elempack=1, elemsize=4
// 此时 data 指向一块 256*256*3*4 = 786432 字节的内存
// 访问通道
float* channel0 = blob.channel(0); // 返回第 0 个通道组的首地址
// channel0[0..w*h-1] 就是第 0 通道的所有像素
// 打包转换
Mat blob_pack4;
convert_packing(blob, blob_pack4, 4);
// blob_pack4: w=256, h=256, c=1(因为 3 不能被 4 整除会补零到 4)
// elempack=4,每个"元素"现在是 4 个 float 紧邻存储
// 引用计数:Mat 赋值是浅拷贝(引用计数 +1)
Mat alias = blob; // 不复制数据,refcount 从 1 变 2
// alias 和 blob 指向同一块内存
Mat 的引用计数机制配合 Light Mode 实现了高效的内存管理:当一个 Mat 的引用计数降到 0(没有任何后续层需要它),内存立即被释放。
推理流程
ncnn 的推理流程可以分为三个阶段:模型加载、推理准备、推理执行。
阶段一:模型加载
ncnn::Net net;
net.load_param("model.param"); // 加载网络结构
net.load_model("model.bin"); // 加载权重
model.param 是纯文本文件,长这样:
7767517 <- magic number
3 3 <- layer_count=3, blob_count=3
Input input 0 1 data
Convolution conv 1 1 data conv_out 0=64 1=3 2=1 3=1 ...
ReLU relu 1 1 conv_out relu_out
每行描述一个层:层类型、层名字、输入 blob 数、输出 blob 数、输入 blob 名、输出 blob 名、参数键值对。这种纯文本格式的好处是可以用文本编辑器直接查看和修改模型结构——调试时非常方便。
加载过程:
- 解析每一行,在注册表中查找层类型,创建 Layer 实例
- 调用
Layer::load_param()填充层参数 - 调用
Layer::load_model()从.bin文件读取权重 - 根据
Option中的设置(如use_packing_layout),对权重做 elempack 打包
阶段二:推理准备
ncnn::Extractor ex = net.create_extractor();
ex.set_num_threads(4);
ex.input("data", input_mat);
Extractor 是一次推理的会话对象。它持有对 Net 的引用(Net 是只读的,可以被多个 Extractor 共享),以及本次推理的中间 blob 数据。创建 Extractor 不涉及任何计算——它只是分配了一个 blob 数组,用于缓存计算结果。
input() 调用把用户提供的输入数据存入对应的 blob 槽位。
阶段三:推理执行(Lazy Eval)
ncnn::Mat output;
ex.extract("relu_out", output);
extract() 是真正触发计算的入口。它的内部逻辑如下:
int Extractor::extract(int blob_index, Mat& feat) {
// 如果这个 blob 已经计算过了,直接返回缓存
if (blob_mats[blob_index].dims != 0) {
feat = blob_mats[blob_index];
return 0;
}
// 找到产生这个 blob 的层
int layer_index = net->blobs[blob_index].producer;
// 递归:先确保该层的所有输入 blob 都已经计算好
for (each input_blob of layer) {
extract(input_blob_index, ...); // 递归调用
}
// 所有输入准备好了,执行当前层
Layer* layer = net->layers[layer_index];
layer->forward(bottom_blobs, top_blobs, opt);
// 缓存结果
blob_mats[blob_index] = top_blobs[0];
// Light Mode: 如果某个输入 blob 不再被后续层需要,释放它
if (opt.lightmode) {
for (each input_blob) {
if (refcount[input_blob] == 0) {
blob_mats[input_blob] = Mat(); // 释放内存
}
}
}
feat = blob_mats[blob_index];
return 0;
}
这个递归调度的流程清晰地展示了 ncnn 的 Lazy Eval 策略:不到需要时不计算,计算完后立即缓存,缓存用完立即释放。
关键差异:ncnn 与其他框架
vs MNN:手工 vs 编译器
ncnn 依赖手写 SIMD 汇编获得性能;MNN 依赖 Geometry Compiler 在运行时动态生成计算方案。ncnn 像手工木匠,MNN 像带自动编程数控机床的工厂。ncnn 的手写内核在特定模型上可能更快(因为每一行汇编都经过人工调优),但 MNN 在新算子支持速度上更快(不需要手写汇编,编译器自动处理)。
vs ORT:私有格式 vs 标准格式
ncnn 用自己的 .param/.bin 格式,模型必须经过 onnx2ncnn 或 caffe2ncnn 转换;ORT 直接消费 ONNX 模型,不需要额外转换。ncnn 的做法更轻量(解析纯文本比解析 protobuf 快得多),但生态壁垒更高(只有 ncnn 能用 ncnn 格式的模型)。
vs TNN:单类 vs 双层
ncnn 的一个 Layer 类包含形状推断和计算逻辑;TNN 把形状推断(BaseLayer)和硬件计算(LayerAcc)分到两个类。TNN 的做法更适合多后端扩展(加一个新后端只需要写新的 LayerAcc,不需要改 BaseLayer),但增加了代码复杂度。
vs Paddle-Lite:编译时裁剪 vs 运行时适配
ncnn 在编译时决定支持哪些算子和后端(通过 CMake 选项);Paddle-Lite 在运行时通过 static_kernel_pick_pass 动态选择最优内核。ncnn 的方式产出更小的二进制,Paddle-Lite 的方式更灵活。
适用场景与局限
最适合 ncnn 的场景
二进制体积极度敏感的嵌入式设备。ncnn 编译出的静态库在 ARM64 上约 700KB,通过裁剪可以进一步压缩到几百 KB。在智能手表、IoT 设备这种只有几 MB 存储空间的场景下,ncnn 几乎是唯一的选择。
需要极致 CPU 推理性能的场景。在纯 CPU 推理(ARM NEON)的基准测试中,ncnn 在很多经典模型上都能跑出最快或接近最快的速度。手写汇编的优势在这里体现得淋漓尽致。
学习和教学场景。约 500 个文件、不用 C++11、每个算子都是自包含的——这让 ncnn 成为了学习推理引擎源码的最佳材料。你可以花一个周末读完整个卷积算子的实现,而不会在 template 元编程的汪洋中迷失方向。
ncnn 的局限
不支持 NPU。ncnn 没有 NPU 后端(没有 Android NNAPI 集成、没有华为 HiAI 集成)。如果你的目标设备有 NPU 且需要用 NPU 加速,ncnn 帮不了你——这时候应该看 MNN、TNN 或 Paddle-Lite。
不支持 LLM 推理。ncnn 的设计面向传统 CV 模型(CNN),不支持 KV Cache、Rotary Position Embedding 等 LLM 需要的算子和优化。MNN 在这方面走得更远(内置了 LLM 推理引擎)。
不支持训练。ncnn 是纯推理引擎,没有自动微分、没有反向传播。MNN 支持端侧训练(finetune),ncnn 不行。
新算子开发慢。每个新算子都需要手写多套 SIMD 内核(NEON、SSE、AVX、Vulkan Shader),工作量巨大。MNN 的 Geometry Compiler 可以让新算子自动获得一个基线性能,然后再逐步手动优化。
不用 C++11 的代价。虽然这让编译兼容性更好,但也意味着代码中没有 std::unique_ptr(内存管理全靠手动 new/delete 和引用计数),没有 std::function(回调用函数指针),没有 range-based for(循环写起来更冗长)。对现代 C++ 开发者来说,阅读和贡献 ncnn 代码需要适应这种”复古”风格。
代码结构
ncnn 的源码组织非常直观,约 500 个文件分布在几个清晰的目录中:
ncnn/src/
├── mat.h / mat.cpp # Mat 数据结构(核心)
├── net.h / net.cpp # Net 网络容器
├── extractor.h # Extractor 推理会话
├── layer.h / layer.cpp # Layer 基类
├── option.h # 全局选项
├── allocator.h / .cpp # 内存分配器
├── blob.h # Blob 描述(名字 + 生产者/消费者)
├── modelbin.h / .cpp # 模型权重读取
├── paramdict.h / .cpp # 参数字典
│
├── layer/ # 所有算子的实现
│ ├── convolution.h / .cpp # 卷积(通用 + NEON)
│ ├── convolution.h # 头文件(参数定义)
│ ├── relu.cpp # ReLU
│ ├── pooling.cpp # 池化
│ ├── x86/ # x86 特定实现
│ │ ├── convolution_x86.cpp # SSE/AVX 卷积
│ │ └── ...
│ ├── arm/ # ARM 特定实现
│ │ ├── convolution_arm.cpp # NEON 卷积
│ │ ├── convolution_3x3.h # 3x3 卷积内核
│ │ ├── convolution_3x3_pack4.h # elempack=4 版本
│ │ └── ...
│ ├── vulkan/ # Vulkan 实现
│ │ ├── convolution_vulkan.cpp
│ │ └── ...
│ └── ...
│
├── gpu.h / gpu.cpp # Vulkan 设备管理
├── command.h / .cpp # Vulkan 命令提交
├── pipeline.h / .cpp # Vulkan 管线
│
└── layer/vulkan/shaders/ # GLSL Compute Shader 源码
├── convolution.comp
├── convolution_pack4.comp
└── ...
关键阅读路径:
- 入门:
mat.h→layer.h→net.cpp(load_param函数)→extractor.cpp(extract函数) - 理解 elempack:
mat.h(elempack/elemsize字段)→layer/arm/convolution_3x3_pack4.h - 理解 Vulkan:
gpu.h→pipeline.h→layer/vulkan/convolution_vulkan.cpp→layer/vulkan/shaders/convolution.comp - 理解模型格式:
tools/onnx/onnx2ncnn.cpp(ONNX 转 ncnn 格式的转换器)
小结
ncnn 的设计哲学可以用一个词概括:极简主义。零依赖、单文件算子、手写汇编、按需计算、编译时裁剪——每一个设计决策都指向同一个目标:用最少的代码、最少的资源、最少的抽象层次,在移动端跑出最快的推理速度。
它的核心创新 elempack 动态打包机制,巧妙地把 SIMD 优化从”算子级别的代码技巧”提升为”数据布局级别的架构设计”,让整个计算管线天然适配不同硬件的 SIMD 宽度。
它的局限也同样清晰:不支持 NPU、不支持 LLM、不支持训练、新算子开发慢。这些局限不是设计缺陷,而是设计取舍——ncnn 选择做一件事并做到极致,而不是做一个面面俱到的万能框架。
对于犀牛鸟竞赛的参赛者来说,ncnn 是最值得深入阅读的框架。它的代码量可控、设计清晰、社区活跃,而且它的 elempack 机制和 Vulkan 后端都是竞赛可能涉及的优化方向。建议从 mat.h 开始,花一个下午理解 elempack,然后读一个你熟悉的算子(比如 ReLU 或 Pooling)的完整 forward 实现,建立对 ncnn 代码风格的感性认识。
下一章,我们将看到一个完全不同的设计哲学:MNN 的 Geometry Compiler 如何用”编译器思维”取代”手工优化”。
导读目录:README.md 上一章:Ch10: 量化入门——用精度换速度的艺术 下一章:Ch12: MNN——端侧瑞士军刀