很多人第一次做模型部署,看到的是这样一条流水线:
.ckpt → .pt → PNNX → .param + .bin → NCNN → Android 推理
文件后缀在变,工具名字在变,于是问题堆了一串:训练好的模型为什么不能直接放进手机?PNNX 和 NCNN 是不是一个东西?ONNX 和 PNNX 名字这么像,什么关系?转换脚本是 Python 写的,手机上却没有 Python,那它到底转了什么?
这些问题的根源都是同一个:把四种不同层次的东西混成了一坨。这篇文章先把这四层拆干净,再给一个能复制粘贴跑通的完整例子,然后覆盖真实项目里绕不开的量化、验证、Android 工程细节和端侧大模型。
一、部署到底在解决什么问题
一个神经网络可以写成一个函数:
其中 是输入, 是训练得到的参数, 是结构与计算过程, 是输出。
训练和部署服务于完全不同的目标:
| 训练阶段 | 部署阶段 | |
|---|---|---|
| 目标 | 找到合适的 | 用最少资源稳定快速地执行已固定的 |
| 设备 | 服务器 / 桌面 GPU | ARM CPU、手机 GPU、NPU |
| 语言 | Python | Kotlin / Java / C++ / Swift |
| 栈 | PyTorch、JAX | NCNN、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 / .pt | PyTorch | 不确定,见下 | 看情况 |
.onnx | torch.onnx.export 等 | 计算图 + 权重 + opset 版本 | 需 ONNX Runtime 等 |
.ort | ORT 转换脚本 | ONNX 的精简二进制变体 | ORT minimal build 专用 |
.param + .bin | PNNX | 结构说明书 + 权重数据 | NCNN 专用,两个都要 |
.tflite | LiteRT Converter | FlatBuffer 计算图 + 权重 | LiteRT 专用 |
.mlpackage | coremltools | ML Program 格式 | Core ML 专用 |
.pte | ExecuTorch 导出 | 序列化程序 + 委托子图 | ExecuTorch 专用 |
.mnn | MNNConvert | MNN 图 + 权重 | MNN 专用 |
.gguf | llama.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 端崩溃的经典原因。
五、模型转换到底做了什么
转换不是改后缀,而是对模型做一次重新表达。转换工具要完成七件事:
- 提取结构——识别有哪些层、如何连接,构建出计算图。
- 提取权重——把卷积核、偏置、全连接参数从训练框架的对象里取出来。
- 翻译算子——把
torch.nn.Conv2d、torch.nn.ReLU、torch.nn.MaxPool2d映射到目标框架的对应算子。这一步是算子不支持报错的来源。 - 确定张量形状——推导每个中间张量的 shape,例如
[1, 3, 224, 224]表示 batch=1、通道=3、高=224、宽=224。这也是为什么 PNNX 一定要你传inputshape。 - 重新序列化权重——不同框架的内存布局、数据排列、对齐方式、数据类型都可能不同,权重要按目标格式重排后存盘。
- 图优化——常量折叠、删除无用节点、算子融合(比如 Conv+BN+ReLU 融成一个)、固定部分形状、调整权重布局。
- 选择数值精度——FP32 / FP16 / INT8。精度越低通常越小越快,但可能损失准确率。
所以:
模型转换的本质,是把训练框架里的计算图和参数,翻译成目标推理框架能理解、能加载、能高效执行的形式。
六、2026 年的推理运行时全景
原文只讲了 NCNN 一条线,但实际选型空间要大得多。先看通用(CV / 小模型)场景:
| 运行时 | 主要格式 | 平台 | 适合场景 | 注意 |
|---|---|---|---|---|
| NCNN | .param+.bin | Android/iOS/嵌入式 | 轻量 CV、极致包体积、无第三方依赖 | 需 C++/JNI;算子覆盖偏 CV |
| ONNX Runtime | .onnx / .ort | 全平台 | 跨平台统一、算子覆盖广 | 包体积较大,可用 minimal build 裁剪 |
| LiteRT(原 TFLite) | .tflite | Android 优先 | Google 生态、Play Services 可更新运行时 | 2024 年 9 月更名,扩展名不变 |
| MNN | .mnn | Android/iOS | 阿里系,CV + 部分 LLM,工具链成熟 | 中文文档友好 |
| ExecuTorch | .pte | 移动/嵌入式 | PyTorch 原生血统,LLM 支持好 | 1.0 GA 后迭代快,别锁老版本 |
| Core ML | .mlpackage | Apple 平台 | iOS/macOS 最佳能效,可用 ANE | 仅 Apple |
| OpenVINO | IR / .onnx | Intel 边缘设备 | x86 边缘盒子 | 非移动端 |
| TensorRT | .engine | NVIDIA | 服务端/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 | .pte | PyTorch 原生,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 → 固定输入尺寸 → 把复杂操作改写成基础算子的组合 → 在模型设计阶段规避 → 编写自定义算子 → 换推理框架。
症状:转换成功,但结果不对
这是最常见的问题,而且大部分不是转换的锅。 按这个顺序查:
- 先用第九节的方法做纯数值验证(喂同一个随机张量)。如果这一步就对不上,是图翻译的问题,去逐层定位。
- 如果纯数值验证通过,那问题一定在前后处理。重点查:RGB/BGR 是否反了、NCHW/NHWC 是否反了、缩放方式是否一致(
resizevsresize + 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
→ 实现前后处理 → 真机验证 → 优化
最后用一句话收束全文:
训练框架负责把模型训练出来,转换工具负责把它翻译成目标运行时能理解的形式,推理运行时负责在具体硬件上执行它,而业务代码负责把输入、模型和最终结果连起来——这四件事互相独立,任何一环出问题都会表现为”模型不对”。