跳至主要内容
返回文章列表
约 27 分钟阅读

从训练模型到端侧部署:.ckpt、.pt、ONNX、PNNX、NCNN 与 2026 年的移动端推理全景

把模型格式、转换工具、推理运行时和硬件后端这四层彻底拆开,配一个能真正跑通的 ResNet-18 端到端例子,并覆盖量化、数值验证、Android 工程细节、端侧大模型与 NNAPI 落幕后的新格局。

博客目录 →

很多人第一次做模型部署,看到的是这样一条流水线:

.ckpt → .pt → PNNX → .param + .bin → NCNN → Android 推理

文件后缀在变,工具名字在变,于是问题堆了一串:训练好的模型为什么不能直接放进手机?PNNX 和 NCNN 是不是一个东西?ONNX 和 PNNX 名字这么像,什么关系?转换脚本是 Python 写的,手机上却没有 Python,那它到底转了什么?

这些问题的根源都是同一个:把四种不同层次的东西混成了一坨。这篇文章先把这四层拆干净,再给一个能复制粘贴跑通的完整例子,然后覆盖真实项目里绕不开的量化、验证、Android 工程细节和端侧大模型。

一、部署到底在解决什么问题

一个神经网络可以写成一个函数:

y=fθ(x)y = f_\theta(x)

其中 xx 是输入,θ\theta 是训练得到的参数,ff 是结构与计算过程,yy 是输出。

训练和部署服务于完全不同的目标:

训练阶段部署阶段
目标找到合适的 θ\theta用最少资源稳定快速地执行已固定的 fθf_\theta
设备服务器 / 桌面 GPUARM CPU、手机 GPU、NPU
语言PythonKotlin / Java / C++ / Swift
栈PyTorch、JAXNCNN、ONNX Runtime、LiteRT、ExecuTorch
模型反复修改固定不变,重复推理
保存内容完整训练状态只保留推理必需的部分
关心的指标收敛速度、准确率延迟、内存、包体积、功耗、兼容性

一句话概括部署阶段的目标:

在目标设备上,用尽可能少的资源,稳定、快速地执行一个已经训练好的模型。

二、部署对象不是一个文件,而是一整套系统

很多人以为”模型推理”就是把图片喂给模型拿结果。实际链路至少五段:

原始数据 → 预处理 → 模型推理 → 后处理 → 业务结果

以图像分类为例:

Android Bitmap
   ↓  缩放、裁剪、转 RGB、归一化
模型输入张量
   ↓  NCNN 执行神经网络
输出 logits
   ↓  Softmax、取 argmax、映射标签
"金毛寻回犬 · 92.3%"

模型转换只负责中间那一段——结构和权重。它不会替你决定:

  • 图片是 RGB 还是 BGR
  • 布局是 NCHW 还是 NHWC
  • 像素要不要除以 255
  • 减哪个均值、除哪个标准差
  • 输出要不要再过一次 Softmax
  • 类别编号怎么映射到标签
  • 检测框怎么解码、NMS 阈值多少

所以真正要交付的是:

预处理 + 模型文件 + 推理运行时 + 后处理 + 业务代码

记住这一条,后面故障排查那一章会反复用到:大多数”模型转换错误”其实是前后处理不一致。

三、核心心智模型:把四层拆开

这是整篇文章最重要的一节。下面四类东西经常被混为一谈,但它们的职责完全不同:

层次是什么例子类比
模型格式模型的一种存储表示.ckpt .pt .onnx .param/.bin .tflite .pte .gguf菜谱 + 食材清单
转换工具把一种表示翻译成另一种PNNX、torch.onnx.export、LiteRT Converter、coremltools翻译 / 编译器
推理运行时真正执行计算NCNN、ONNX Runtime、LiteRT、MNN、ExecuTorch、Core ML、llama.cpp厨师和厨房
硬件后端运行时调用的算力ARM NEON、Vulkan、Metal、XNNPACK、QNN/Hexagon、各家 NPU炉灶

完整链路是:

训练框架 ──导出──> 模型格式 ──转换──> 目标格式 ──加载──> 推理运行时 ──调用──> 硬件后端

由此推出三个最常被搞混的不等式:

ONNX  ≠  ONNX Runtime      (格式 ≠ 运行时)
PNNX  ≠  NCNN              (转换器 ≠ 运行时)
NCNN  ≠  Android           (运行时 ≠ 操作系统)

最后一条尤其重要:Android 本身不规定模型格式。你的 App 集成了哪个推理框架,才决定了要放什么格式的文件。

四、模型格式速查表

格式产出方内容能否直接部署
.ckpt训练框架(Lightning 等)权重 + 优化器 + 调度器 + epoch + 超参否
.pth / .ptPyTorch不确定,见下看情况
.onnxtorch.onnx.export 等计算图 + 权重 + opset 版本需 ONNX Runtime 等
.ortORT 转换脚本ONNX 的精简二进制变体ORT minimal build 专用
.param + .binPNNX结构说明书 + 权重数据NCNN 专用,两个都要
.tfliteLiteRT ConverterFlatBuffer 计算图 + 权重LiteRT 专用
.mlpackagecoremltoolsML Program 格式Core ML 专用
.pteExecuTorch 导出序列化程序 + 委托子图ExecuTorch 专用
.mnnMNNConvertMNN 图 + 权重MNN 专用
.ggufllama.cpp 转换脚本量化权重 + 元数据 + 分词器llama.cpp 系专用

.ckpt:训练现场的完整存档

checkpoint 里通常包含模型权重、优化器状态、学习率调度器状态、当前 epoch、超参数、训练框架元数据。它是给”继续训练 / 恢复训练 / 复现实验”用的。

而推理只需要 模型结构 + 模型权重,不需要 优化器 + 学习率 + epoch + 训练日志。

那能不能干脆把 Python 和 PyTorch 塞进手机?理论上有 PyTorch Mobile / ExecuTorch 这类方案,但如果是完整桌面栈,代价是包体积暴涨、依赖复杂、启动慢、内存高、设备兼容性差、桌面算子没有移动端优化、功耗和延迟都不理想。

.pt / .pth:后缀什么也说明不了

这是新手第一个大坑。同样是 .pt,可能是三种完全不同的东西:

# 情况 A:只有权重字典(最常见)
torch.save(model.state_dict(), "model.pth")
# 加载时必须自己先把模型类实例化出来
model = MyModel()
model.load_state_dict(torch.load("model.pth"))

# 情况 B:整个 Python 对象被 pickle 了
torch.save(model, "model.pt")      # 依赖原始类定义,脆弱,不推荐

# 情况 C:TorchScript —— 这才是可部署的那种
traced = torch.jit.trace(model, example_input)
traced.save("model.pt")

判断手上的 .pt 到底是哪种,最快的办法:

import torch
obj = torch.load("model.pt", map_location="cpu", weights_only=False)
print(type(obj))
# dict                                      → state_dict(情况 A)
# torch.jit._script.RecursiveScriptModule   → TorchScript(情况 C)
# 你自己的类名                                → pickle 的模型对象(情况 B)

.param 与 .bin:NCNN 的两个文件

.param 是纯文本的网络结构说明书——有哪些层、层的类型、连接关系、blob 名称、卷积核大小、步长、padding、通道数。你可以直接用文本编辑器打开看,这在调试时非常有用。

.bin 是二进制权重数据——卷积核、偏置、全连接参数。

两个必须配套,且必须来自同一次转换。混用不同批次生成的 param 和 bin 是 Android 端崩溃的经典原因。

五、模型转换到底做了什么

转换不是改后缀,而是对模型做一次重新表达。转换工具要完成七件事:

  1. 提取结构——识别有哪些层、如何连接,构建出计算图。
  2. 提取权重——把卷积核、偏置、全连接参数从训练框架的对象里取出来。
  3. 翻译算子——把 torch.nn.Conv2d、torch.nn.ReLU、torch.nn.MaxPool2d 映射到目标框架的对应算子。这一步是算子不支持报错的来源。
  4. 确定张量形状——推导每个中间张量的 shape,例如 [1, 3, 224, 224] 表示 batch=1、通道=3、高=224、宽=224。这也是为什么 PNNX 一定要你传 inputshape。
  5. 重新序列化权重——不同框架的内存布局、数据排列、对齐方式、数据类型都可能不同,权重要按目标格式重排后存盘。
  6. 图优化——常量折叠、删除无用节点、算子融合(比如 Conv+BN+ReLU 融成一个)、固定部分形状、调整权重布局。
  7. 选择数值精度——FP32 / FP16 / INT8。精度越低通常越小越快,但可能损失准确率。

所以:

模型转换的本质,是把训练框架里的计算图和参数,翻译成目标推理框架能理解、能加载、能高效执行的形式。

六、2026 年的推理运行时全景

原文只讲了 NCNN 一条线,但实际选型空间要大得多。先看通用(CV / 小模型)场景:

运行时主要格式平台适合场景注意
NCNN.param+.binAndroid/iOS/嵌入式轻量 CV、极致包体积、无第三方依赖需 C++/JNI;算子覆盖偏 CV
ONNX Runtime.onnx / .ort全平台跨平台统一、算子覆盖广包体积较大,可用 minimal build 裁剪
LiteRT(原 TFLite).tfliteAndroid 优先Google 生态、Play Services 可更新运行时2024 年 9 月更名,扩展名不变
MNN.mnnAndroid/iOS阿里系,CV + 部分 LLM,工具链成熟中文文档友好
ExecuTorch.pte移动/嵌入式PyTorch 原生血统,LLM 支持好1.0 GA 后迭代快,别锁老版本
Core ML.mlpackageApple 平台iOS/macOS 最佳能效,可用 ANE仅 Apple
OpenVINOIR / .onnxIntel 边缘设备x86 边缘盒子非移动端
TensorRT.engineNVIDIA服务端/Jetson引擎与硬件强绑定

什么时候选 NCNN

适合:目标是 Android/iOS;要求极小的包体积和零重依赖;模型主要由常规 CV 算子构成;团队能处理 C++/JNI;需要对线程、内存、后端做细粒度控制。

不适合:模型含大量 NCNN 不支持的算子;团队已有成熟的 ONNX Runtime 流程;需要强跨平台一致性;目标平台更适合 LiteRT / Core ML / 厂商 NPU;模型结构迭代很快,转换维护成本高。

七、硬件后端:NNAPI 的落幕

这是过去两年端侧领域最需要更新认知的一点。

Android NNAPI 已在 Android 15(API 35)被标记为弃用。 官方给出的理由是:NNAPI 发布后端侧 ML 演进极快(Transformer、扩散模型等),而 NDK 层的 NNAPI 更新节奏跟不上;Google 的替代方案是可通过 Play Services 更新的 LiteRT 运行时,以及 Android 14+ 的 AICore(提供 Gemini Nano 等基础模型)。

NDK 文档里有一句很关键的提示:你仍然可以继续用 NNAPI,但未来大多数设备预计会退回 CPU 后端,所以性能敏感的场景应当迁移。

那 NPU 加速怎么办?现实答案是:目前没有跨厂商的统一替代品。路径变成了:

可移植基线:LiteRT + GPU delegate  或  ONNX Runtime + XNNPACK
      ↓ 需要 NPU 吞吐时再叠加
厂商专用:Qualcomm QNN delegate / 联发科 NeuroPilot / 各家 NPU SDK

所以 2026 年的实践建议是:

  • 新项目不要基于 NNAPI 开工。
  • 已上线且只维护 Android 10–14 的老代码,可以先留着 NNAPI。
  • 需要 NPU 的,接受”一个厂商一条适配线”的现实,并做好 CPU/GPU 兜底。

至于其他后端:ARM NEON 是 CPU SIMD 基线;Vulkan 是 NCNN/MNN 常用的跨厂商 GPU 方案;XNNPACK 是高度优化的浮点算子库,ORT 的 Android/iOS 预编译包里已经带了;Apple 侧则是 Metal + ANE。

八、PNNX 与 ONNX 的关系

名字像,但职责完全不同。

ONNX 是一种开放的模型交换格式,用于在不同框架之间传递模型。ONNX 模型里包含输入输出定义、计算图、节点、算子、权重、张量类型与形状、opset 版本、元数据。官方规范把它描述为一套可扩展的计算图、数据类型和算子集合,且不规定必须用哪个运行时。

它像一门”通用模型语言”:

PyTorch     ↘
TensorFlow  →  ONNX  →  ONNX Runtime / TensorRT / LiteRT / NCNN ...
Paddle      ↗

PNNX(PyTorch Neural Network eXchange)是一套转换工具链,主要服务 PyTorch → NCNN:

PyTorch / TorchScript ──PNNX──> NCNN
ONNX                  ──PNNX──> NCNN     (PNNX 也接受 ONNX 输入)

所以:PNNX = 转换工具,NCNN = 推理运行时,二者紧密相关但不是一回事。PNNX 在开发机上跑,手机上不需要 PNNX、不需要 Python、不需要 PyTorch,只需要 NCNN 运行库 + .param + .bin + 业务代码。

为什么现在推荐 PNNX 而不是 onnx2ncnn

老教程里的 PyTorch → ONNX → onnx2ncnn → NCNN 这条路,现在不再是推荐路径。ncnn 官方已经给 onnx2ncnn 加了警告,提示改用 PNNX;工具仍然能跑,但基本不再新增算子支持。

官方文档给的理由是:传统的 pytorch → onnx → ncnn 流程经常因为 ONNX 算子兼容性和动态 shape 问题而失败,而 PNNX 可以直接从 PyTorch 转换,绕过不稳定的 ONNX 中间步骤,对现代算子和复杂结构的覆盖更好,生成的 ncnn 图也更干净。

现在的三条常见路线:

PyTorch → TorchScript → PNNX → NCNN        ← 推荐
PyTorch → ONNX → ONNX Runtime              ← 跨平台首选
PyTorch → ONNX → PNNX → NCNN               ← 已有 onnx 资产时

九、一个真正能跑通的端到端例子

前面都是概念。这一节给一个从零到验证通过的完整闭环,用 torchvision 的 ResNet-18,不需要你自己有模型。

第 0 步:装工具

pip install torch torchvision
pip install pnnx ncnn

第 1 步:导出 TorchScript,并存一份参考输出

# export_resnet18.py
import torch
import torchvision

model = torchvision.models.resnet18(
    weights=torchvision.models.ResNet18_Weights.DEFAULT
)
model.eval()                      # 关键:切到推理模式,影响 BN 和 Dropout

example = torch.randn(1, 3, 224, 224)

# 注意变量名:这是 trace 不是 script,两者行为不同
traced_model = torch.jit.trace(model, example)
traced_model.save("resnet18.pt")

# 存一份参考输入输出,供后面做数值对齐
with torch.no_grad():
    reference = model(example)
torch.save({"input": example, "output": reference}, "reference.pt")

print("saved resnet18.pt, reference.pt")

第 2 步:PNNX 转换

pnnx resnet18.pt inputshape=[1,3,224,224]

产出一堆文件:

resnet18.pnnx.param     PNNX 中间表示(结构)
resnet18.pnnx.bin       PNNX 中间表示(权重)
resnet18.pnnx.onnx      顺带导出的 onnx
resnet18.ncnn.param     ← NCNN 最终模型(结构)
resnet18.ncnn.bin       ← NCNN 最终模型(权重)
resnet18_pnnx.py        用 PyTorch 重建该图的可读代码
resnet18_ncnn.py        用 ncnn python API 调用该模型的示例代码

第 3 步:确认输入输出 blob 名称

不要猜名字。 PNNX 生成的 ncnn 模型默认用 in0 / out0,但多输入多输出时会是 in0 in1 / out0 out1,而且不同版本可能有差异。去文件里确认:

head -3 resnet18.ncnn.param    # 第 3 行第一个 Input 层,末尾就是输出 blob 名
grep -n "ex.input\|ex.extract" resnet18_ncnn.py

第 4 步:数值验证(这一步不能省)

# verify_ncnn.py
import numpy as np
import torch
import ncnn

blob = torch.load("reference.pt")
x = blob["input"].numpy()                    # (1, 3, 224, 224)
ref = blob["output"].numpy().reshape(-1)     # (1000,)

net = ncnn.Net()
net.opt.use_vulkan_compute = False           # 先用 CPU 对齐,排除 GPU 变量
assert net.load_param("resnet18.ncnn.param") == 0
assert net.load_model("resnet18.ncnn.bin") == 0

with net.create_extractor() as ex:
    ex.input("in0", ncnn.Mat(x[0]))          # ncnn.Mat 用 (c, h, w),没有 batch 维
    _, out = ex.extract("out0")
    got = np.array(out).reshape(-1)

abs_diff = np.abs(ref - got)
cosine = float(ref @ got / (np.linalg.norm(ref) * np.linalg.norm(got)))

print(f"max abs diff  = {abs_diff.max():.3e}")
print(f"mean abs diff = {abs_diff.mean():.3e}")
print(f"cosine        = {cosine:.6f}")
print(f"argmax        = {ref.argmax()} vs {got.argmax()}")

assert cosine > 0.9999, "计算图对不上,去查算子翻译"
assert ref.argmax() == got.argmax(), "分类结果不一致"
print("PASS")

FP32 模型的典型结果是 max abs diff 在 1e-5 量级、cosine 大于 0.99999。如果 cosine 掉到 0.9 甚至更低,说明图本身就翻译错了,别急着去调前处理。

十、数值验证方法论

上一节给了单点验证,真实项目还需要更系统的做法。

用哪些指标。 最大绝对误差看最坏情况;平均绝对误差看整体偏移;余弦相似度对量纲不敏感,最适合判断”图对不对”;此外一定要看任务级指标——分类的 top-1 是否一致、检测框 IoU 是否重合、整个验证集的准确率掉了多少。

误差容忍度大致这样划分:

场景cosine说明
FP32 → FP32> 0.99999只有浮点累加顺序差异
FP32 → FP16> 0.999正常范围
FP32 → INT8> 0.99需同时看任务级准确率
低于 0.99—大概率是算子翻译错误,不是精度问题

逐层定位。 当整体对不上时,二分查找出问题的层:NCNN 的 .param 是纯文本,你可以直接读出每个中间 blob 的名字,然后用 ex.extract("某个中间blob") 取出来,和 PyTorch 侧用 forward hook 抓的同名中间结果比对。找到第一个开始发散的层,问题就定位了。

# PyTorch 侧抓中间结果
acts = {}
def hook(name):
    return lambda m, i, o: acts.__setitem__(name, o.detach().numpy())
model.layer1.register_forward_hook(hook("layer1"))

别只测一个随机输入。 至少覆盖:真实图片、全零输入、极值输入、以及验证集里的一批样本。随机噪声能验证图结构,但验证不了归一化参数是否正确。

十一、Android 集成的真实工程细节

这一节是绝大多数教程写得最粗、而实际最容易出事的地方。

加载模型:assets 不是文件路径

这是新手第一次集成 NCNN 最高频的崩溃原因。放在 src/main/assets/ 里的文件打包进 APK 后不是文件系统上的独立文件,不能 fopen("model.ncnn.param")。必须走 AAssetManager:

#include <android/asset_manager.h>
#include <android/asset_manager_jni.h>
#include "net.h"

ncnn::Net net;

// 先配置,再加载——opt 必须在 load 之前设置好
net.opt.num_threads = 4;
net.opt.use_vulkan_compute = false;
net.opt.lightmode = true;              // 及时回收中间 blob,省内存

AAssetManager* mgr = AAssetManager_fromJava(env, java_asset_manager);

// 一定要检查返回值,0 才是成功
if (net.load_param(mgr, "resnet18.ncnn.param") != 0) {
    __android_log_print(ANDROID_LOG_ERROR, "ncnn", "load_param failed");
    return -1;
}
if (net.load_model(mgr, "resnet18.ncnn.bin") != 0) {
    __android_log_print(ANDROID_LOG_ERROR, "ncnn", "load_model failed");
    return -1;
}

另一种做法是首次启动时把 assets 拷贝到 context.filesDir,之后按普通路径加载。这样能用 mmap,但多占一份存储。

前处理:用 NCNN 自带的 API

前面反复强调前处理要和训练一致。NCNN 为此提供了一等公民 API,不需要你手写循环:

// Bitmap 直接转 Mat 并 resize,一步到位
ncnn::Mat in = ncnn::Mat::from_android_bitmap_resize(
        env, bitmap, ncnn::Mat::PIXEL_RGBA2RGB, 224, 224);

// ImageNet 标准归一化:(pixel/255 - mean) / std
// NCNN 的公式是 (pixel - mean) * norm,所以要把系数折算到 0-255 尺度
const float mean_vals[3] = {0.485f * 255.f, 0.456f * 255.f, 0.406f * 255.f};
const float norm_vals[3] = {1.f / (0.229f * 255.f),
                            1.f / (0.224f * 255.f),
                            1.f / (0.225f * 255.f)};
in.substract_mean_normalize(mean_vals, norm_vals);

从摄像头 YUV 数据来的话用 ncnn::Mat::from_pixels_resize(data, ncnn::Mat::PIXEL_RGB2BGR, w, h, target_w, target_h),注意像素格式常量要和你实际的数据排列对上——PIXEL_RGB2BGR 这类转换标志是最容易搞反的地方。

推理与生命周期

ncnn::Extractor ex = net.create_extractor();
ex.set_light_mode(true);
ex.set_num_threads(4);

ex.input("in0", in);

ncnn::Mat out;
ex.extract("out0", out);

// out 是 (1000, 1, 1),按 float* 遍历
const float* scores = out;

几个性能相关的要点:

  • ncnn::Net 只创建一次,在 Activity/Service 生命周期内复用。每帧都 load_param 是常见的性能灾难。
  • Extractor 每次推理创建一个,它很轻量,且不是线程安全的。
  • 线程数不是越大越好。大小核架构下开满线程反而可能更慢,实测决定,通常 2–4。
  • Vulkan 不一定更快。小模型上 CPU↔GPU 的数据拷贝开销可能吃掉全部收益,必须实测。

ABI 与打包

android {
    defaultConfig {
        ndk {
            // 现代设备 arm64-v8a 足够;armeabi-v7a 视兼容需求决定
            abiFilters += listOf("arm64-v8a")
        }
    }
}

只打 arm64-v8a 能显著减小 APK。如果模型文件较大,考虑用 Play Asset Delivery 或动态功能模块把模型从主包里拆出来按需下载。

十二、量化与优化

先从模型结构下手

这是投入产出比最高的一步,很多人却跳过它直接去搞量化。选用为移动端设计的结构——MobileNetV3、ShuffleNetV2、EfficientNet-Lite、轻量检测头——从计算量层面减负,效果通常好过后期硬优化。

其次是输入分辨率。从 640×640 降到 416×416,计算量直接少一半多,而很多任务的精度损失可以接受。这个旋钮的性价比常常被低估。

FP16

用 16 位浮点存权重和做计算。优点是权重体积减半、内存带宽压力小、部分硬件上更快。缺点是可能有精度误差,且不是所有设备/算子都能受益。

PNNX 直接支持:

pnnx resnet18.pt inputshape=[1,3,224,224] fp16=1

对大多数 CV 模型,FP16 是一个近乎免费的优化,通常先试它。

INT8 量化:NCNN 的三件套

INT8 用 8 位整数表示权重和激活。优点是模型更小、内存更省、部分设备上明显更快、功耗更低;代价是需要校准数据、可能掉点、部分算子不支持、必须额外验证。

NCNN 的训练后量化(PTQ)分三步:

第一步,图优化与融合。 校准和量化都要基于已融合的图:

./ncnnoptimize resnet18.ncnn.param resnet18.ncnn.bin \
               resnet18-opt.param resnet18-opt.bin 0

末尾的 0 表示以 FP32 存储。

第二步,生成校准表。 官方建议用验证集做校准,样本数 5000 以上:

find calib_images/ -type f > imagelist.txt

./ncnn2table resnet18-opt.param resnet18-opt.bin imagelist.txt resnet18.table \
    mean=[123.675,116.28,103.53] \
    norm=[0.01712,0.01751,0.01743] \
    shape=[224,224,3] pixel=RGB thread=8 method=kl

这里的 mean 和 norm 必须和你在 C++ 里传给 substract_mean_normalize() 的值完全一致;shape 按 WHC 顺序(和 ncnn::Mat 的参数顺序一致);method 可选 kl 或 aciq。多输入模型用逗号分隔多个列表文件和多组参数。

第三步,量化:

./ncnn2int8 resnet18-opt.param resnet18-opt.bin \
            resnet18-int8.param resnet18-int8.bin resnet18.table

推理侧代码不用改,加载 int8 模型后 NCNN 会自动走 int8 路径。

两个实用技巧:

  • 选择性量化——如果某一层量化后掉点严重,在 .table 文件里把它的 scale 行用 # 注释掉,该层会回退到 FP32。这是抢救精度最快的手段。
  • 别期待精确的 4× 缩小——只有卷积和全连接会被量化,其他层仍是浮点。

如果 PTQ 掉点无法接受,下一步是量化感知训练(QAT):在训练阶段就插入伪量化节点,让模型学会适应量化误差。代价是要重新训练,但对小模型和分类任务通常能把损失拉回可接受范围。

十三、性能测试与热节流

先用官方工具建立基线。 NCNN 自带 benchncnn,可以直接推到手机上跑:

adb push benchncnn /data/local/tmp/
adb push resnet18.ncnn.param resnet18.ncnn.bin /data/local/tmp/
adb shell "cd /data/local/tmp && ./benchncnn 20 4 0 -1 1"
# 参数:循环次数 线程数 powersave gpu_device cooling_down

至少测这些指标: 模型加载时间、首次推理时间(会明显高于后续,涉及内存分配和缓存预热)、稳定推理时间、P95 延迟(比平均值更能反映体验)、峰值内存、模型文件大小、APK 增量、连续运行稳定性、功耗。

对应的工程手段包括:控制推理帧率而不是能跑多快跑多快、把重负载任务错峰、监控设备温度并动态降级(比如从 GPU 退回 CPU、或切到更小的模型)。

“格式本身不保证快”。 实际速度取决于模型结构、输入分辨率、线程数、CPU 型号与大小核调度、GPU 后端、算子是否被完整支持(不支持会回退且可能引入布局转换)、是否发生 CPU/GPU 间的数据拷贝、是否量化、以及是否在热路径上反复创建销毁推理对象。

十四、端侧大模型:另一套玩法

前面讲的是 CV 模型的部署范式。2024 年以来端侧 LLM 成了独立赛道,玩法差别很大——权重以 GB 计、要管 KV cache、是自回归逐 token 输出而不是一次前向。

方案格式特点
llama.cpp.gguf模型选择最灵活,社区转换快,原生体积小;但 JNI/NDK 集成工作量大,线程、内存、上下文长度都要自己管
LiteRT-LM.litertlm 等Google 官方路线,取代了已弃用的 MediaPipe LLM API;提供 Kotlin/C++ API 和 KV-cache 管理,GPU 加速开箱即用
ExecuTorch.ptePyTorch 原生,1.0 GA 起支持 Gemma3、Llama-3.2、Qwen3、Phi-4-mini 等,Android 侧有 Maven 上的 AAR
MLC LLM编译产物编译式方案,小模型(Phi、Gemma)表现不错,大模型在部分设备上稳定性待验证
AICore系统托管Android 14+ 系统级,直接调用 Gemini Nano,不用自己带模型

几条实践经验:

  • 尺寸选择:3B 参数 + Q4_K_M 量化是当前性价比较好的平衡点,在 Pixel 级设备上大约 8–12 tokens/s。老设备上用 0.5B–1B 模型配更激进的量化(Q3_K_M、Q2_K)。
  • 热节流问题在 LLM 上被放大,因为推理是持续的而不是一次性的。
  • NPU 加速仍然是碎片化的,需要走 AICore 或厂商 SDK,没有统一方案。
  • 选型速判:需要频繁换模型或跑冷门模型 → llama.cpp + GGUF;只做 Android 且想要托管的会话管理和开箱即用的加速 → LiteRT-LM / AICore;已经在 PyTorch 生态里 → ExecuTorch。

十五、动态 shape、多输入与控制流

多输入要在转换时全部声明:

pnnx model.pt inputshape=[1,3,224,224],[1,10]

动态分辨率在 PNNX 里可以给第二组形状,让它推导出可变的图:

pnnx yolov5s.pt inputshape=[1,3,640,640] inputshape2=[1,3,320,320]

但要清楚:动态 shape 是有代价的。它会削弱图优化的空间,可能导致每次 shape 变化都重新分配内存。如果业务上分辨率只有有限几种,导出几个固定 shape 的模型往往比一个动态模型更快。

数据相关的控制流是转换的头号敌人。像这样的代码 trace 不了:

def forward(self, x):
    if x.mean() > 0.5:        # 依赖输入数据的分支
        return self.branch_a(x)
    return self.branch_b(x)

处理方式按优先级:把分支逻辑挪到模型外面用业务代码实现(最推荐);改用 torch.jit.script;或者把分支重写成无分支的算术形式(比如用 mask 加权)。

在模型设计阶段就考虑部署兼容性,比事后补救便宜得多。自定义算子、花哨的索引操作、Python 层的动态逻辑,都会在转换时变成阻塞点。

十六、故障排查手册

症状:PNNX 读不了我的 .pt

多半是 .pt 其实是 state_dict 或 pickle 对象,不是 TorchScript。用第四节那段 type(torch.load(...)) 确认;确认后用正确的模型类加载权重,再重新 torch.jit.trace + save。另外先确认模型在电脑上能正常前向推理——一个本身就跑不通的模型不可能转换成功。

症状:提示某个算子不支持

原因可能是用了目标框架不支持的算子、自定义算子、复杂 Python 控制流、输入形状不明确、或动态维度没处理。

按成本从低到高尝试:查目标框架的算子支持列表 → 提供正确的 inputshape → 固定输入尺寸 → 把复杂操作改写成基础算子的组合 → 在模型设计阶段规避 → 编写自定义算子 → 换推理框架。

症状:转换成功,但结果不对

这是最常见的问题,而且大部分不是转换的锅。 按这个顺序查:

  1. 先用第九节的方法做纯数值验证(喂同一个随机张量)。如果这一步就对不上,是图翻译的问题,去逐层定位。
  2. 如果纯数值验证通过,那问题一定在前后处理。重点查:RGB/BGR 是否反了、NCHW/NHWC 是否反了、缩放方式是否一致(resize vs resize + center crop 结果完全不同)、归一化的 mean/std 是否一致且量纲对得上(0–1 还是 0–255)、输入尺寸是否一致、标签顺序是否一致、Softmax 是否被执行了两次(模型里已有一次,后处理又加一次)、输出 blob 名是否取错、padding 策略是否一致。

症状:能加载,但 Android 崩溃

检查:.param 和 .bin 是否来自同一次转换、assets 路径是否用了 AAssetManager、ABI 是否匹配、C++ 动态库是否正确打包、输入张量形状是否正确、设备内存是否够、输出解析时的索引是否越界。

务必检查 load_param / load_model 的返回值——很多崩溃其实是加载早就失败了,代码却继续往下跑。

症状:跑得慢

见第十三节。核心原则是在目标机型上实测,不要靠比较模型文件大小来推断速度。

十七、模型资产管理与可复现性

不要把几个模型文件散落在项目里。建议这样组织:

models/
└── classifier_v1.2.0/
    ├── model.ncnn.param
    ├── model.ncnn.bin
    ├── labels.txt
    ├── manifest.json
    ├── README.md
    └── checksum.txt

manifest.json 记录所有让模型能被正确使用的信息:

{
  "model_version": "1.2.0",
  "runtime": "ncnn",
  "input_name": "in0",
  "output_name": "out0",
  "input_shape": [1, 3, 224, 224],
  "color_format": "RGB",
  "layout": "NCHW",
  "dtype": "float32",
  "mean": [0.485, 0.456, 0.406],
  "std": [0.229, 0.224, 0.225],
  "resize_mode": "resize_shortest_then_center_crop",
  "postprocess": "softmax",
  "num_classes": 1000,
  "source_checkpoint": "runs/exp42/best.ckpt",
  "pnnx_version": "20260526",
  "torch_version": "2.9.0",
  "converted_at": "2026-08-20",
  "val_top1": 0.6976,
  "val_top1_after_int8": 0.6941
}

同时归档:原始 checkpoint、模型定义代码、导出脚本、完整的转换命令、工具版本、验证脚本和验证结果。

理想状态是把转换和验证做成 CI 的一部分:checkpoint 提交后自动导出、转换、跑数值对齐和验证集准确率,任何一项超出阈值就让流水线红掉。这样”模型换了一版之后端上就不对了”这类问题会在合并前暴露,而不是上线后。

十八、常见误区

“Android 必须用 NCNN。” 不对。Android 只是目标平台,可选 NCNN、ONNX Runtime、LiteRT、MNN、ExecuTorch、Core ML(iOS)、厂商 SDK。是你选的推理框架决定了模型格式,不是操作系统。

“PNNX 就是 NCNN。” 不对。PNNX 是转换工具,跑在开发机上;NCNN 是推理运行时,跑在手机上。

“ONNX 可以直接运行。” 不准确。ONNX 是格式,需要 ONNX Runtime 或其他能解析它的引擎来执行。

“.pt 一定是可部署模型。” 不对。可能是 state_dict、pickle 对象或 TorchScript,必须检查。

“转换成功就代表部署成功。” 不对。还要验证数值一致性、任务准确率、输入输出对应关系和真机性能。

“模型文件包含完整的预处理逻辑。” 通常不包含。前后处理由业务代码负责,必须手动保证与训练一致。

“用 NNAPI 就能吃到 NPU。” 已经过时。NNAPI 在 Android 15 被弃用,未来多数设备会退回 CPU 后端,需要 NPU 就得走厂商 delegate。

“量化只是换个格式。” 不对。量化改变了数值表示,必须重新验证准确率。

“benchmark 跑分好就没问题。” 不对。短时跑分测不出热节流,长时间连续运行才是真实体验。

十九、SOP 总结

完整流程:

 1. 明确部署目标(平台、硬件、体积上限、延迟上限、机型覆盖)
       ↓  不要一上来就默认用 NCNN
 2. 明确输入输出契约(shape、色彩格式、布局、dtype、归一化、标签顺序)
       ↓  这一步写进 manifest.json
 3. 选择推理运行时,倒推所需格式
       ↓
 4. 验证 checkpoint 能正常加载并前向推理
       ↓  model.eval(),torch.no_grad()
 5. 导出中间模型(TorchScript / ONNX)
       ↓  注意 trace 与 script 的区别
 6. 转换成目标格式
       ↓  正确传入 inputshape
 7. 数值对齐验证
       ↓  cosine + 任务级指标,不能只看"没报错"
 8. 集成到 App
       ↓  AAssetManager、ABI、生命周期、前后处理
 9. 真机测试(含长时间连续运行)
       ↓
10. 量化与性能优化
       ↓  先结构、再分辨率、再 FP16、最后 INT8
11. 回归测试、归档模型资产、发布

NCNN 路线的具体形态:

checkpoint → model.eval() → torch.jit.trace → .pt
    → pnnx → .ncnn.param + .ncnn.bin
    → Python 端数值对齐(cosine > 0.9999)
    → Android 集成 NCNN(AAssetManager 加载)
    → 实现前后处理(substract_mean_normalize)
    → 真机验证准确率与性能 → 量化 → 回归

ONNX Runtime 路线:

checkpoint → torch.onnx.export → .onnx
    → onnxruntime Python 端数值对齐
    →(可选)转 .ort + minimal build 裁包体积
    → Android 集成 onnxruntime-android
    → 选 EP:量化模型用 CPU EP,浮点模型先试 XNNPACK
    → 实现前后处理 → 真机验证 → 优化

最后用一句话收束全文:

训练框架负责把模型训练出来,转换工具负责把它翻译成目标运行时能理解的形式,推理运行时负责在具体硬件上执行它,而业务代码负责把输入、模型和最终结果连起来——这四件事互相独立,任何一环出问题都会表现为”模型不对”。

参考资料

打开原图