犀牛鸟 2026 研究笔记 estelledc.github.io

Ch24: ncnn 实战——编译、推理、性能测试

Part 5: 实战与竞赛 前置章节:Ch23: 端到端部署流水线 后续章节:Ch25: MNN 实战——转换、推理、量化一条龙


本章目标

Ch11 讲了 ncnn 的设计哲学和源码结构,Ch23 讲了部署流水线的全景。这一章把两者连起来:从零开始编译 ncnn,写一个完整的 C++ 推理程序,跑一遍 benchmark,然后在 Android 设备上交叉编译部署。

读完这一章,你应该能:


前置准备


Mac/Linux 源码编译

第一步:安装依赖

Mac 上只需要 CMake:

# Mac
brew install cmake

# 确认版本
cmake --version
# cmake version 3.28.1 (或更高)

Linux(Ubuntu/Debian):

# Linux
sudo apt update
sudo apt install -y cmake build-essential git

# 如果需要 Python 绑定,还需要 pybind11
pip install pybind11

第二步:克隆仓库

git clone https://github.com/Tencent/ncnn.git
cd ncnn

# 重要:初始化子模块(glslang 用于 Vulkan shader 编译)
git submodule update --init

git submodule update --init 这一步很关键——ncnn 的 Vulkan 支持依赖 glslang 子模块。即使你暂时不用 Vulkan,也建议初始化,否则后续如果开启 Vulkan 编译选项会报找不到 glslang 的错误。

第三步:配置和编译

mkdir -p build && cd build

# 基础编译(不含 Vulkan、不含 Python 绑定)
cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -DNCNN_BUILD_EXAMPLES=ON \
    -DNCNN_BUILD_TOOLS=ON \
    ..

make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)

编译完成后,关键产物在这些位置:

build/
├── src/
│   └── libncnn.a              # ncnn 静态库(这是最终要链接的东西)
├── tools/
│   ├── onnx/
│   │   └── onnx2ncnn          # ONNX → ncnn 转换工具
│   ├── ncnnoptimize           # 模型优化工具
│   └── quantize/
│       ├── ncnn2table          # 量化校准表生成
│       └── ncnn2int8           # INT8 模型生成
├── benchmark/
│   └── benchncnn              # 性能测试工具
└── examples/
    ├── squeezenet              # 示例程序
    └── ...

编译选项详解

ncnn 的 CMake 有大量编译选项,这里列出最常用的:

# 完整编译(含 Vulkan GPU 支持 + Python 绑定)
cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -DNCNN_VULKAN=ON \
    -DNCNN_PYTHON=ON \
    -DNCNN_BUILD_EXAMPLES=ON \
    -DNCNN_BUILD_TOOLS=ON \
    -DNCNN_BUILD_BENCHMARK=ON \
    ..
选项 默认值 含义
NCNN_VULKAN OFF 开启 Vulkan GPU 推理支持
NCNN_PYTHON OFF 编译 Python 绑定(需要 pybind11)
NCNN_BUILD_TOOLS ON 编译工具集(onnx2ncnn 等)
NCNN_BUILD_EXAMPLES ON 编译示例程序
NCNN_BUILD_BENCHMARK ON 编译 benchncnn
NCNN_OPENMP ON 开启 OpenMP 多线程
NCNN_STDIO ON 支持从文件路径加载模型
NCNN_STRING ON 支持层名字字符串(调试用)

Apple Silicon 用户的特别注意: 在 M1/M2/M3 Mac 上编译,CMake 通常能正确识别 ARM64 架构。但如果你在 Rosetta 2 下运行(比如用的是 x86 版本的 Terminal),可能会编译出 x86 版本。确认方式:

# 确认编译出来的是 arm64 还是 x86
file build/src/libncnn.a
# 应该看到 arm64 字样,而不是 x86_64

开启 Vulkan 支持

如果你的设备有 Vulkan 兼容的 GPU(大多数现代 Android 设备都有,Mac 通过 MoltenVK 支持),可以开启 Vulkan 加速:

# Mac: 安装 Vulkan SDK
# 从 https://vulkan.lunarg.com/sdk/home 下载 macOS SDK
# 安装后设置环境变量
export VULKAN_SDK=/path/to/vulkansdk/macOS

# 重新配置编译
cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -DNCNN_VULKAN=ON \
    ..

make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)

Android NDK 交叉编译

在手机上部署 ncnn,需要用 Android NDK 交叉编译出 ARM 版本的静态库。

第一步:安装 Android NDK

# 推荐通过 Android Studio 的 SDK Manager 安装
# 或者手动下载:
# https://developer.android.com/ndk/downloads

# 设置环境变量
export ANDROID_NDK=/path/to/android-ndk-r26b
# 例如 Mac 上通过 Android Studio 安装的路径:
# export ANDROID_NDK=$HOME/Library/Android/sdk/ndk/26.1.10909125

第二步:交叉编译

cd ncnn
mkdir -p build-android-arm64 && cd build-android-arm64

cmake \
    -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
    -DANDROID_ABI=arm64-v8a \
    -DANDROID_PLATFORM=android-24 \
    -DANDROID_ARM_NEON=ON \
    -DNCNN_VULKAN=ON \
    ..

make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)

编译完成后,build-android-arm64/src/libncnn.a 就是可以在 Android arm64 设备上使用的静态库。

如果还需要 32 位 ARM 支持(越来越少见了):

mkdir -p build-android-armv7 && cd build-android-armv7

cmake \
    -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
    -DANDROID_ABI=armeabi-v7a \
    -DANDROID_PLATFORM=android-24 \
    -DANDROID_ARM_NEON=ON \
    ..

make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)

第三步:推送到设备测试

# 把 benchncnn 和模型文件推送到 Android 设备
adb push build-android-arm64/benchmark/benchncnn /data/local/tmp/
adb push build-android-arm64/benchmark/*.param /data/local/tmp/

# 在设备上运行
adb shell "cd /data/local/tmp && chmod +x benchncnn && ./benchncnn 4 1 0 0 0"

benchncnn 的五个参数含义:

./benchncnn [loop_count] [num_threads] [powersave] [gpu_device] [cooling_down]
参数 说明 常用值
loop_count 推理次数(取平均) 4-8
num_threads 使用的 CPU 线程数 1/2/4
powersave 大小核策略(0=全部, 1=小核, 2=大核) 0 或 2
gpu_device GPU 设备索引(-1=不用 GPU) 0 或 -1
cooling_down 每次推理后冷却时间(秒) 0 或 1

典型的 benchmark 输出:

$ ./benchncnn 4 4 2 -1 0
loop_count = 4
num_threads = 4
powersave = 2
gpu_device = -1
cooling_down = 0
          squeezenet  min =    3.21  max =    3.45  avg =    3.31
     squeezenet_int8  min =    2.89  max =    3.12  avg =    2.98
           mobilenet  min =    4.56  max =    4.78  avg =    4.63
      mobilenet_int8  min =    3.34  max =    3.56  avg =    3.42
        mobilenet_v2  min =    3.89  max =    4.12  avg =    3.97
        mobilenet_v3  min =    3.12  max =    3.34  avg =    3.21
          shufflenet  min =    2.45  max =    2.67  avg =    2.53
       shufflenet_v2  min =    2.12  max =    2.34  avg =    2.21
             mnasnet  min =    3.56  max =    3.78  avg =    3.65
     proxylessnasnet  min =    4.23  max =    4.45  avg =    4.31
        efficientnet  min =    5.67  max =    5.89  avg =    5.76
          regnety400  min =    5.12  max =    5.34  avg =    5.21
...

Python 绑定——快速验证

ncnn 提供了 Python 绑定,可以在 Python 中直接调用 ncnn 推理,非常适合快速验证模型转换是否正确。

安装方式

# 方式 1: pip 安装预编译包(x86_64 Linux/Windows)
pip install ncnn

# 方式 2: 源码编译(Apple Silicon Mac 必须用这种方式)
cd ncnn
mkdir -p build && cd build
cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -DNCNN_PYTHON=ON \
    ..
make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)

# 安装 Python 包
cd ../python
pip install -e .

Apple Silicon Mac 上 pip install ncnn 会失败,因为 PyPI 上没有 arm64 的预编译 wheel。必须从源码编译。编译前确保 Python 和 CMake 都是 arm64 原生版本(不是 Rosetta 模拟的 x86 版本)。

Python 推理示例

import ncnn
import numpy as np
from PIL import Image

# 加载模型
net = ncnn.Net()
net.load_param("mobilenet_v2.ncnn.param")
net.load_model("mobilenet_v2.ncnn.bin")

# 准备输入
img = Image.open("test.jpg").resize((224, 224))
img_array = np.array(img).astype(np.float32)

# BGR 转换(如果模型需要 BGR 输入)
img_array = img_array[:, :, ::-1].copy()  # RGB → BGR

# 创建 ncnn Mat
mat_in = ncnn.Mat.from_pixels(
    img_array.tobytes(),
    ncnn.Mat.PixelType.PIXEL_BGR,
    224, 224
)

# 预处理:减均值、归一化
mean_vals = [104.0, 117.0, 123.0]
norm_vals = [0.017, 0.017, 0.017]
mat_in.substract_mean_normalize(mean_vals, norm_vals)

# 推理
extractor = net.create_extractor()
extractor.input("input0", mat_in)
ret, mat_out = extractor.extract("output0")

# 解析结果
output = np.array(mat_out)
top5_indices = output.argsort()[-5:][::-1]
print("Top-5 predictions:")
for idx in top5_indices:
    print(f"  class {idx}: score {output[idx]:.4f}")

Python 绑定的 API 和 C++ 几乎一一对应——ncnn.Net 对应 ncnn::Netcreate_extractor 对应 create_extractorinput/extract 对应 input/extract。这意味着你在 Python 中验证通过的推理逻辑,可以几乎直接翻译成 C++ 代码。

Python 逐层调试

Python 绑定还有一个 C++ 不太方便做的事:逐层提取中间结果,检查模型在哪一层开始出现精度问题。

# 提取中间层的输出
extractor = net.create_extractor()
extractor.input("input0", mat_in)

# 逐层提取(需要知道层名,可以从 .param 文件中读取)
ret, conv1_out = extractor.extract("conv1_output")
ret, bn1_out = extractor.extract("bn1_output")
ret, relu1_out = extractor.extract("relu1_output")

# 和 PyTorch 的中间结果对比
print(f"conv1 output shape: {np.array(conv1_out).shape}")
print(f"conv1 output mean: {np.array(conv1_out).mean():.6f}")
print(f"conv1 output std: {np.array(conv1_out).std():.6f}")

C++ 完整推理程序

Python 适合快速验证,但实际部署(尤其是 Android 端)需要 C++。下面是一个完整的、可编译运行的图片分类程序。

代码

// classify.cpp
#include <stdio.h>
#include <algorithm>
#include <vector>
#include "net.h"

// 如果需要读取图片,可以用 stb_image(单头文件,零依赖)
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

struct ClassificationResult {
    int class_id;
    float score;
};

bool compare_score(const ClassificationResult& a, const ClassificationResult& b) {
    return a.score > b.score;
}

int classify(const char* model_param, const char* model_bin,
             const unsigned char* image_data, int img_w, int img_h) {
    // 1. 创建网络
    ncnn::Net net;
    // 可选:开启 Vulkan GPU 推理
    // net.opt.use_vulkan_compute = true;

    // 2. 加载模型
    if (net.load_param(model_param)) {
        fprintf(stderr, "load_param failed\n");
        return -1;
    }
    if (net.load_model(model_bin)) {
        fprintf(stderr, "load_model failed\n");
        return -1;
    }

    // 3. 准备输入
    // from_pixels_resize: 自动缩放到目标尺寸
    ncnn::Mat input = ncnn::Mat::from_pixels_resize(
        image_data,
        ncnn::Mat::PIXEL_RGB2BGR,  // 输入是 RGB,转为 BGR
        img_w, img_h,              // 原始图片尺寸
        224, 224                    // 目标尺寸
    );

    // 预处理参数(必须和训练时一致)
    const float mean_vals[3] = {104.f, 117.f, 123.f};
    const float norm_vals[3] = {1.f / 58.395f, 1.f / 57.12f, 1.f / 57.375f};
    input.substract_mean_normalize(mean_vals, norm_vals);

    // 4. 推理
    ncnn::Extractor ex = net.create_extractor();
    ex.set_num_threads(4);  // 设置推理线程数

    ex.input("input0", input);

    ncnn::Mat output;
    ex.extract("output0", output);

    // 5. 解析结果
    std::vector<ClassificationResult> results;
    for (int i = 0; i < output.w; i++) {
        ClassificationResult r;
        r.class_id = i;
        r.score = output[i];
        results.push_back(r);
    }

    // 按分数排序
    std::sort(results.begin(), results.end(), compare_score);

    // 输出 Top-5
    printf("Top-5 results:\n");
    for (int i = 0; i < 5 && i < (int)results.size(); i++) {
        printf("  class %d: %.4f\n", results[i].class_id, results[i].score);
    }

    return results[0].class_id;
}

int main(int argc, char** argv) {
    if (argc != 4) {
        fprintf(stderr, "Usage: %s <param> <bin> <image>\n", argv[0]);
        return -1;
    }

    // 读取图片
    int w, h, c;
    unsigned char* data = stbi_load(argv[3], &w, &h, &c, 3);
    if (!data) {
        fprintf(stderr, "Failed to load image: %s\n", argv[3]);
        return -1;
    }

    int result = classify(argv[1], argv[2], data, w, h);
    printf("Predicted class: %d\n", result);

    stbi_image_free(data);
    return 0;
}

编译

# 假设 ncnn 已经编译好,在 /path/to/ncnn/build

# 下载 stb_image.h(单文件图片读取库)
wget https://raw.githubusercontent.com/nothings/stb/master/stb_image.h

# 编译
g++ -O2 classify.cpp \
    -I/path/to/ncnn/src \
    -I/path/to/ncnn/build/src \
    -I. \
    -L/path/to/ncnn/build/src \
    -lncnn \
    -lpthread \
    -o classify

# 运行
./classify mobilenet_v2.ncnn.param mobilenet_v2.ncnn.bin test.jpg

如果用 CMake 构建(推荐用于正式项目):

# CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(ncnn_classify)

# 找到 ncnn
set(ncnn_DIR "/path/to/ncnn/build/src" CACHE PATH "ncnn build dir")
find_package(ncnn REQUIRED)

add_executable(classify classify.cpp)
target_link_libraries(classify ncnn)
mkdir build && cd build
cmake -Dncnn_DIR=/path/to/ncnn/build/src ..
make

Extractor 的关键设计

Ch11 讲过 ncnn 的 Extractor 是线程安全的推理实例。这里从实操角度补充几个关键点。

一个 Net 可以创建多个 Extractor

ncnn::Net net;
net.load_param("model.param");
net.load_model("model.bin");

// 多线程场景:每个线程用自己的 Extractor
// Net 本身是只读的,可以安全地在多个线程间共享
#pragma omp parallel for
for (int i = 0; i < batch_size; i++) {
    ncnn::Extractor ex = net.create_extractor();
    ex.input("input0", inputs[i]);
    ncnn::Mat output;
    ex.extract("output0", output);
    // 处理 output...
}

Net 对象持有模型的参数和权重(只读数据),Extractor 持有推理过程中的中间状态(各层的输出 blob)。所以同一个 Net 可以同时被多个 Extractor 使用,互不干扰。

多输出模型的按需计算

ncnn 的一个独特能力是按需计算——你只 extract 哪些输出,就只计算哪些路径上的层。

// 假设模型有两个输出头:classification 和 detection
ncnn::Extractor ex = net.create_extractor();
ex.input("input0", input);

// 只需要 classification 结果——detection 分支不会被执行
ncnn::Mat cls_output;
ex.extract("classification_output", cls_output);

// 如果两个都需要:
ncnn::Mat det_output;
ex.extract("detection_output", det_output);
// 此时 detection 分支才会被计算(但 shared backbone 不会重复计算)

内存优化选项

ncnn::Extractor ex = net.create_extractor();

// 开启轻量模式:推理完成后释放中间层的内存
// 代价是不能再次 extract 中间层的结果
ex.set_light_mode(true);

// 设置 blob 内存池分配器(减少内存碎片)
ncnn::PoolAllocator pool_allocator;
ncnn::UnlockedPoolAllocator unlocked_pool_allocator;
ex.set_blob_allocator(&pool_allocator);
ex.set_workspace_allocator(&unlocked_pool_allocator);

性能测试实操

在本机运行 benchncnn

cd ncnn/build/benchmark

# 基础测试:4 次循环,4 线程,使用全部核心,CPU 模式
./benchncnn 4 4 0 -1 0

# 单线程测试(衡量单核性能)
./benchncnn 4 1 0 -1 0

# 大核测试(2=只用大核)
./benchncnn 4 4 2 -1 0

# GPU 测试(如果编译了 Vulkan)
./benchncnn 4 1 0 0 0

自定义模型的 benchmark

benchncnn 默认测试的是内置的标准模型(squeezenet、mobilenet 等)。如果你要测试自己的模型:

# 方法 1:把模型的 .param 文件放到 benchmark 目录下
cp my_model.ncnn.param ncnn/benchmark/
cd ncnn/build/benchmark

# 重新编译 benchmark(需要修改 benchncnn.cpp 添加模型条目)
# 这比较麻烦,推荐用方法 2

# 方法 2:写一个简单的计时程序
// bench_my_model.cpp
#include <stdio.h>
#include <chrono>
#include "net.h"

int main() {
    ncnn::Net net;
    net.load_param("my_model.ncnn.param");
    net.load_model("my_model.ncnn.bin");

    // 创建随机输入
    ncnn::Mat input(224, 224, 3);
    input.fill(0.5f);

    // 预热
    for (int i = 0; i < 3; i++) {
        ncnn::Extractor ex = net.create_extractor();
        ex.input("input0", input);
        ncnn::Mat output;
        ex.extract("output0", output);
    }

    // 正式测试
    int loop_count = 10;
    double total_ms = 0;

    for (int i = 0; i < loop_count; i++) {
        auto start = std::chrono::high_resolution_clock::now();

        ncnn::Extractor ex = net.create_extractor();
        ex.set_num_threads(4);
        ex.input("input0", input);
        ncnn::Mat output;
        ex.extract("output0", output);

        auto end = std::chrono::high_resolution_clock::now();
        double ms = std::chrono::duration<double, std::milli>(end - start).count();
        total_ms += ms;
        printf("Run %d: %.2f ms\n", i + 1, ms);
    }

    printf("Average: %.2f ms\n", total_ms / loop_count);
    return 0;
}

性能数据解读

benchmark 输出中的三个数值(min/max/avg)的解读:

关注 min 和 avg 之间的差距:如果差距很大(比如 min=5ms, max=15ms),说明性能波动很大,可能是因为 CPU 频率不稳定或者有其他进程在竞争资源。在 Android 上用 powersave=2(只用大核)通常能减少波动。


模型优化和精简

在部署前,用 ncnnoptimize 工具做一遍优化,可以显著减少模型大小和推理时间。

# 基本优化(算子融合 + 去除无用层)
./ncnnoptimize model.ncnn.param model.ncnn.bin \
    model_opt.param model_opt.bin 0

# 最后的参数:0 = fp32 存储,65536 = fp16 存储
# fp16 存储可以把模型文件大小减半,推理时自动转回 fp32 计算
./ncnnoptimize model.ncnn.param model.ncnn.bin \
    model_opt.param model_opt.bin 65536

ncnnoptimize 做的事情包括:

# 对比优化前后的模型大小
ls -lh model.ncnn.bin model_opt.bin
# model.ncnn.bin    14M
# model_opt.bin     7.0M  (fp16 存储后)

常见问题排查

Q: 编译时报 “pybind11 not found”?

A: 安装 pybind11:pip install pybind11,然后确保 CMake 能找到它。如果用 conda 环境,需要指定 pybind11 的路径:

cmake \
    -DNCNN_PYTHON=ON \
    -Dpybind11_DIR=$(python -c "import pybind11; print(pybind11.get_cmake_dir())") \
    ..

Q: Apple Silicon Mac 上 pip install ncnn 报错?

A: PyPI 上目前没有 Apple Silicon 的预编译 wheel。必须从源码编译:

cd ncnn
mkdir build && cd build
cmake -DNCNN_PYTHON=ON -DCMAKE_BUILD_TYPE=Release ..
make -j$(sysctl -n hw.ncpu)
cd ../python && pip install -e .

Q: 推理结果全是零或 NaN?

A: 最常见的三个原因:(1)输入数据没有正确传入——检查 from_pixels 的 PixelType 参数是否和实际图片格式匹配;(2)预处理参数错误——mean/norm 的值或顺序和训练时不一致;(3)模型转换有问题——用 Python 绑定逐层提取中间结果,找到第一个输出异常的层。

Q: Android 上运行 benchncnn 报 “permission denied”?

A: push 到 /data/local/tmp/ 后需要设置执行权限:

adb shell chmod +x /data/local/tmp/benchncnn

如果还是不行,检查 SELinux 是否阻止了执行——某些设备需要先 adb root

Q: Vulkan GPU 推理比 CPU 还慢?

A: 在小模型上很正常。Vulkan GPU 推理有固定的启动开销(shader 编译、内存分配、CPU-GPU 数据传输),对于小模型(如 MobileNet),这些开销可能比 CPU 推理本身还大。Vulkan 的优势在大模型上才能体现(如 ResNet-50、YOLO 等计算密集型模型)。另外,第一次推理会触发 shader 编译,特别慢,后续推理才是正常速度。


本章小结

ncnn 的实操流程可以归纳为四步:编译(CMake + make)→ 转换(onnx2ncnn / PNNX)→ 优化(ncnnoptimize)→ 推理(C++ 或 Python)。

编译 ncnn 只需要 CMake 和 C++ 编译器,零第三方依赖——这是 ncnn 设计哲学的直接体现。Android 交叉编译需要额外安装 NDK,但流程同样简洁。

Python 绑定是快速验证的利器——在 Python 中确认模型转换正确、预处理参数正确之后,再写 C++ 部署代码会少走很多弯路。

benchncnn 是衡量推理性能的标准工具。在做模型选择或优化决策时,先用 benchncnn 建立性能基线,然后每做一步优化(量化、剪枝、换模型结构)都跑一遍 benchmark 对比效果。


下一步建议


导读目录:README.md 上一章:Ch23: 端到端部署流水线——从训练到手机推理的完整路径 下一章:Ch25: MNN 实战——转换、推理、量化一条龙