Ch23: 端到端部署流水线——从训练到手机推理的完整路径
Part 5: 实战与竞赛 前置章节:Ch22: 横向总结——六个引擎的设计取舍全景图 后续章节:Ch24: ncnn 实战——编译、推理、性能测试
本章目标
前 22 章讲的都是推理引擎”内部是怎么回事”。从这一章开始,我们切换到实操视角:你手上有一个训练好的 PyTorch 模型,怎么让它在手机上跑起来?
这一章会走完整个流水线的每一步:训练框架导出、中间格式转换、量化压缩、最终部署。每一步都给出具体的命令行和代码,以及最常见的失败模式和排查方法。
读完这一章,你应该能:
- 画出”训练→导出→转换→量化→部署”的完整流程图,知道每一步用什么工具
- 独立完成 PyTorch → ONNX → ncnn/MNN/TNN 的全链路转换
- 使用 Ultralytics 一条命令完成 YOLOv8 到端侧的部署
- 理解 PNNX 的设计动机和七文件输出结构
- 识别四种常见的导出失败模式,并知道如何排查
前置准备
- 已安装 Python 3.8+、PyTorch 1.10+
- 已安装 ONNX(
pip install onnx onnxruntime) - 了解 Ch09(模型格式)和 Ch10(量化入门)的基本概念
- 有一台 Mac 或 Linux 机器用于编译(Windows 也行,但本章示例以 Unix 系为主)
流水线全景
端侧部署的完整流水线可以分成五个阶段。先画一张全景图,后面逐个展开:
阶段 1: 训练 阶段 2: 导出 阶段 3: 转换 阶段 4: 量化 阶段 5: 部署
┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ ┌───────────────┐
│ PyTorch │────>│ torch.export │──>│ onnx2ncnn │──>│ ncnn2table │──>│ ncnn C++/ │
│ 训练脚本 │ │ torch.onnx │ │ MNNConvert │ │ MNN 内建量化 │ │ Python 推理 │
│ │ │ PNNX │ │ convert2tnn │ │ ORT quantize │ │ │
│ │ │ Ultralytics │ │ │ │ │ │ │
└──────────┘ └──────────────┘ └───────────────┘ └──────────────┘ └───────────────┘
关键认知:这条流水线不是线性的”一条路走到底”。根据你选择的推理引擎和导出方式,中间有多条分支。比如用 PNNX 导出可以跳过 ONNX 阶段直接到 ncnn;用 Ultralytics 的 yolo export 可以一条命令走完整个链路。
阶段 1: 训练——输出什么格式给下游
训练阶段本身不是本章的重点,但训练时的一些选择会直接影响后续导出的成功率。这里列出三个需要提前注意的事项。
第一,避免在 forward() 中使用数据依赖的控制流。 推理引擎的模型格式本质上是一个静态计算图——它在编译期就确定了所有算子的连接关系和执行顺序。如果你的 forward() 里有 if tensor.sum() > 0 这样的条件分支,导出时会遇到麻烦,因为分支的走向取决于运行时的数据值,静态图无法表达”看情况走哪条路”。
# 不友好的写法——数据依赖的控制流
class BadModel(nn.Module):
def forward(self, x):
if x.sum() > 0: # 分支取决于输入数据
return self.branch_a(x)
else:
return self.branch_b(x)
# 友好的写法——用 torch.where 替代 if-else
class GoodModel(nn.Module):
def forward(self, x):
a = self.branch_a(x)
b = self.branch_b(x)
mask = (x.sum() > 0).float()
return a * mask + b * (1 - mask) # 两条路都算,用 mask 选择
第二个写法虽然多算了一条分支,但它是一个纯粹的计算图,每个导出工具都能处理。在端侧推理中,计算量的微小增加远比”导出失败”好得多。
第二,固定输入 shape 或明确标注动态维度。 大多数端侧引擎(ncnn、TNN)不支持真正的动态 shape——它们需要在模型加载时就知道所有张量的形状。如果你的模型需要处理不同大小的输入,在导出时需要用 dynamic_axes 参数明确标注哪些维度是动态的:
torch.onnx.export(
model, dummy_input, "model.onnx",
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch", 2: "height", 3: "width"},
"output": {0: "batch"}
}
)
不过要注意:即使你在 ONNX 中标注了动态维度,下游的 ncnn/TNN 在转换时可能会把它固定为一个具体值。MNN 对动态 shape 的支持相对好一些。
第三,自定义算子要在导出前确认引擎支持。 如果你的模型用了 PyTorch 的自定义 C++ 扩展算子,这个算子不会出现在 ONNX 的标准算子集中。导出时 PyTorch 会把它标记为一个 custom op,但下游引擎不知道怎么执行它。解决方案有两个:要么在导出前把自定义算子替换成标准算子的组合,要么在目标引擎中手写对应的 Layer 实现(Ch24、Ch25 会讲怎么做)。
阶段 2: 导出——四条主要路径
从 PyTorch 模型到推理引擎的输入格式,有四条主要路径。每条路径适用于不同的场景。
路径 A: torch.onnx.export(通用路径)
这是最通用、最广泛支持的导出路径。ONNX 是一个开放的模型交换格式,几乎所有端侧引擎都支持从 ONNX 导入。
import torch
import torchvision
# 加载预训练模型
model = torchvision.models.mobilenet_v2(pretrained=True)
model.eval()
# 创建示例输入
dummy_input = torch.randn(1, 3, 224, 224)
# 导出 ONNX
torch.onnx.export(
model, # 要导出的模型
dummy_input, # 示例输入(用于 trace)
"mobilenet_v2.onnx", # 输出文件名
opset_version=13, # ONNX opset 版本
input_names=["input"], # 输入节点名
output_names=["output"], # 输出节点名
do_constant_folding=True # 折叠常量表达式
)
导出后用 ONNX 自带的工具验证模型结构是否正确:
import onnx
model = onnx.load("mobilenet_v2.onnx")
onnx.checker.check_model(model) # 检查结构合法性
print(onnx.helper.printable_graph(model.graph)) # 打印计算图
也可以用 Netron 可视化工具查看模型结构。Netron 是一个跨平台的模型可视化工具,支持浏览器直接打开:
pip install netron
netron mobilenet_v2.onnx
# 会在浏览器中打开模型结构图
opset_version 怎么选? 不同的 opset 版本支持不同的算子和语义。一般建议用 opset 11-13,因为这是各引擎支持最完整的版本范围。opset 太高(比如 18+)可能包含下游引擎尚未支持的新算子;太低(比如 7-9)可能缺少一些常用算子的标准定义。
一个需要注意的坑: torch.onnx.export 底层用的是 trace 模式——它运行一遍 forward(),记录下所有经过的算子。这意味着没有被执行到的代码路径会被丢弃。如果你的模型有 if-else 分支,trace 只会保留当前示例输入触发的那条路径。从 PyTorch 2.0 开始,torch.export 提供了基于图捕获的导出方式,能更好地处理控制流,但生态尚未完全成熟。
路径 B: PNNX——保留 PyTorch 语义的直达通道
PNNX(PyTorch Neural Network eXchange)是 ncnn 作者 nihui 开发的导出工具,设计目标是”直接从 PyTorch 到 ncnn,跳过 ONNX 中间层,保留 nn.Module 的高层语义”。
为什么需要 PNNX?因为 ONNX 导出有一个根本性的问题:它把 PyTorch 的高层模块(比如 nn.Conv2d、nn.BatchNorm2d)分解成了底层的数学运算(矩阵乘法、加法、广播等)。这个分解过程丢失了语义信息——下游引擎拿到的是一堆零散的算子,不知道它们原来属于同一个 Conv2d。这让引擎很难做针对性的融合优化(比如 Conv+BN 融合需要知道哪些算子原来是一个 BN)。
PNNX 的做法是:先用 torch.jit.trace 或 torch.jit.script 得到 TorchScript IR,然后在 IR 层面做模式匹配,把底层算子重新”升级”回 nn.Module 级别的高层算子。
# 先导出 TorchScript
python -c "
import torch
import torchvision
model = torchvision.models.resnet18(pretrained=True)
model.eval()
x = torch.randn(1, 3, 224, 224)
traced = torch.jit.trace(model, x)
traced.save('resnet18.pt')
"
# 用 PNNX 转换
pnnx resnet18.pt inputshape=[1,3,224,224]
PNNX 的输出不是一个文件,而是七个文件:
resnet18.pnnx.param # PNNX 高层参数(nn.Module 级别)
resnet18.pnnx.bin # PNNX 权重
resnet18.pnnx.py # 等价的 PyTorch Python 代码(可直接运行)
resnet18.pnnx.onnx # 等价的 ONNX 模型
resnet18.ncnn.param # ncnn 参数文件(最终部署用)
resnet18.ncnn.bin # ncnn 权重文件(最终部署用)
resnet18_pnnx.py # PNNX 图的 Python 表示(调试用)
七个文件看似复杂,其实设计思路很清晰:pnnx.* 系列是中间表示,保留了 PyTorch 语义,方便调试和验证;ncnn.* 系列是最终部署用的文件,可以直接被 ncnn 加载;.pnnx.py 是一个可运行的 Python 文件,你可以用它验证转换后的模型输出是否和原模型一致。
# 验证 PNNX 转换精度
python resnet18.pnnx.py # 运行 PNNX 重建的 PyTorch 模型,对比原模型输出
PNNX vs ONNX 的取舍: PNNX 的优势是保留高层语义、ncnn 原生支持、转换精度通常更高;劣势是只能到 ncnn(虽然也输出 ONNX 作为副产品),而且对 TorchScript 不支持的操作(比如某些动态 shape 操作)同样无能为力。如果你的目标引擎只有 ncnn,优先用 PNNX;如果需要同时部署到多个引擎,用 ONNX 更通用。
路径 C: Ultralytics 一键导出(YOLO 系列专属)
如果你用的是 Ultralytics 的 YOLO 系列模型(YOLOv5、YOLOv8、YOLOv11 等),有一条最省事的路径:yolo export 命令一步到位,从 PyTorch 权重直接输出目标引擎的格式文件。
# 安装 Ultralytics
pip install ultralytics
# 一键导出到 ncnn 格式
yolo export model=yolov8n.pt format=ncnn
# 一键导出到其他格式
yolo export model=yolov8n.pt format=onnx # ONNX
yolo export model=yolov8n.pt format=tflite # TFLite
yolo export model=yolov8n.pt format=coreml # CoreML (Apple)
format=ncnn 这一条命令背后做了什么?它实际上执行了完整的流水线:
- 加载 PyTorch 权重
- 调用 PNNX 转换(Ultralytics 内置了 PNNX 的调用逻辑)
- 输出
yolov8n_ncnn_model/目录,包含.param和.bin文件
输出目录结构:
yolov8n_ncnn_model/
├── model.ncnn.param # ncnn 参数文件
├── model.ncnn.bin # ncnn 权重文件
└── metadata.yaml # 模型元信息(类别名、输入尺寸等)
这条路径的优势是极低的门槛——你不需要理解 ONNX、不需要手动安装 PNNX、不需要处理转换参数。代价是只适用于 Ultralytics 官方支持的模型架构;如果你对 YOLO 做了自定义修改(比如换了 backbone 或加了自定义模块),yolo export 可能会失败。
# 导出后直接在 Python 中验证推理
yolo predict model=yolov8n_ncnn_model source=bus.jpg
# 也可以指定 half 精度
yolo export model=yolov8n.pt format=ncnn half=True
路径 D: 框架原生导出工具
部分推理框架提供了直接从 PyTorch 导出的工具,不经过 ONNX:
- MNN:
MNNConvert可以直接接受 TorchScript 模型(.pt),但文档推荐走 ONNX 路径更稳定 - TNN:官方推荐 ONNX 路径,也支持 TFLite 和 Caffe 输入
- ORT Mobile:直接加载 ONNX,不需要额外转换,只需要用
onnxruntime.tools.convert_onnx_models_to_ort做优化
实践中,ONNX 路径是最稳妥的”最大公约数”。只有在 ONNX 导出遇到问题(比如某些算子不支持)时,才需要考虑非 ONNX 的替代路径。
阶段 3: 格式转换——从 ONNX 到引擎原生格式
拿到 ONNX 文件后,需要用各引擎的转换工具把它转成引擎原生格式。每个引擎的转换工具名字和参数不同,但本质上做的事情是一样的:解析 ONNX 算子图 → 映射到引擎内部算子 → 做图优化(算子融合、常量折叠等)→ 输出引擎原生格式。
onnx2ncnn
# 如果已经编译了 ncnn,onnx2ncnn 在 build/tools/onnx/ 目录下
./onnx2ncnn mobilenet_v2.onnx mobilenet_v2.ncnn.param mobilenet_v2.ncnn.bin
# 转换成功后检查文件
ls -la mobilenet_v2.ncnn.*
# mobilenet_v2.ncnn.param (文本文件,可以直接打开看)
# mobilenet_v2.ncnn.bin (二进制权重文件)
打开 .param 文件看看里面长什么样:
7767517 # 魔数(magic number)
75 80 # 层数 和 blob数
Input input0 0 1 input0 0=224 1=224 2=3
Convolution conv0 1 1 input0 conv0_output 0=32 1=3 ...
...
.param 是纯文本格式——每一行描述一个算子,包含算子类型、名称、输入输出连接、以及算子参数。这种设计让调试极其方便:你可以直接用文本编辑器打开 .param 文件,找到可疑的层,检查参数是否合理。
常见问题:onnx2ncnn 报不支持的算子。 如果 ONNX 模型中包含 ncnn 没有实现的算子,onnx2ncnn 会打印类似 Unsupported op: NonMaxSuppression 的错误。解决方案:
- 检查是否能在 PyTorch 侧用支持的算子替换
- 降低 ONNX opset 版本重新导出(某些算子在低 opset 中有不同的实现方式)
- 用
onnx-simplifier简化模型图(很多不支持的算子其实是 ONNX 导出时引入的冗余节点)
# onnx-simplifier 经常能解决算子不支持的问题
pip install onnxsim
onnxsim mobilenet_v2.onnx mobilenet_v2_sim.onnx
./onnx2ncnn mobilenet_v2_sim.onnx mobilenet_v2.ncnn.param mobilenet_v2.ncnn.bin
MNNConvert
# MNN 的转换工具
./MNNConvert -f ONNX \
--modelFile mobilenet_v2.onnx \
--MNNModel mobilenet_v2.mnn \
--bizCode biz
# 可选:转换时直接做量化
./MNNConvert -f ONNX \
--modelFile mobilenet_v2.onnx \
--MNNModel mobilenet_v2_int8.mnn \
--bizCode biz \
--weightQuantBits 8
MNNConvert 的参数说明:
-f ONNX:指定输入格式(还支持 TF、Caffe、TFLITE)--modelFile:输入模型路径--MNNModel:输出.mnn文件路径--bizCode:业务标识码(MNN 的内部管理机制,随便填一个字符串即可)--weightQuantBits:权重量化位数,支持 2/4/8 位
MNN 的 .mnn 格式基于 FlatBuffers 序列化——与 ncnn 的纯文本 .param 不同,.mnn 是二进制格式,不能直接用文本编辑器查看。可以用 MNN 自带的 MNNDump2Json 工具把它转成 JSON 查看结构。
convert2tnn
# TNN 的转换工具
python convert2tnn.py onnx2tnn \
mobilenet_v2.onnx \
-optimize \
-o ./output/
# 输出文件
ls output/
# mobilenet_v2.tnnproto # 模型结构(类似 ncnn 的 .param)
# mobilenet_v2.tnnmodel # 模型权重(类似 ncnn 的 .bin)
TNN 的转换工具是一个 Python 脚本,底层调用了 TNN 的 C++ 转换库。-optimize 参数会开启图优化(算子融合等),建议始终开启。
格式对照表
| 引擎 | 转换工具 | 输入格式 | 输出格式 | 备注 |
|---|---|---|---|---|
| ncnn | onnx2ncnn / PNNX | ONNX / TorchScript | .param + .bin | 文本+二进制 |
| MNN | MNNConvert | ONNX / TF / Caffe | .mnn | FlatBuffers 二进制 |
| TNN | convert2tnn | ONNX / TFLite / Caffe | .tnnproto + .tnnmodel | 类 Caffe 结构 |
| ORT Mobile | ort_convert | ONNX | .ort | 优化后的 ONNX |
| Paddle-Lite | opt | Paddle 格式 | .nb | Paddle 生态内部 |
阶段 4: 量化——在精度和速度之间找平衡
量化的原理在 Ch10 已经讲过。这里聚焦于实操:各引擎的量化工具怎么用、需要准备什么数据、有哪些参数可以调。
ncnn 量化(ncnn2table + ncnnoptimize)
ncnn 的量化是经典的 Post-Training Quantization(PTQ)——不需要重新训练模型,只需要一组校准图片。
# 步骤 1: 先用 ncnnoptimize 优化模型(必须)
./ncnnoptimize mobilenet_v2.ncnn.param mobilenet_v2.ncnn.bin \
mobilenet_v2_opt.param mobilenet_v2_opt.bin 65536
# 65536 = fp16 存储标志位
# ncnnoptimize 会做算子融合、去除无用层等优化
# 步骤 2: 准备校准图片
# 创建一个 image_list.txt,每行一个图片路径
find ./calibration_images/ -name "*.jpg" > image_list.txt
# 步骤 3: 生成量化表
./ncnn2table \
mobilenet_v2_opt.param \
mobilenet_v2_opt.bin \
image_list.txt \
mobilenet_v2.table \
mean=[104,117,123] \
norm=[0.017,0.017,0.017] \
shape=[224,224,3] \
pixel=BGR \
thread=4
# 步骤 4: 应用量化表生成 INT8 模型
./ncnn2int8 \
mobilenet_v2_opt.param \
mobilenet_v2_opt.bin \
mobilenet_v2_int8.param \
mobilenet_v2_int8.bin \
mobilenet_v2.table
校准图片的选择很关键。 校准集不需要很大(100-500 张通常够了),但需要有代表性——覆盖模型在实际部署中会遇到的各种输入场景。如果你做的是人脸检测,校准集里应该有不同光照、不同角度、不同大小的人脸。用训练集的一个子集通常是最安全的选择。
mean 和 norm 参数必须和训练时的预处理参数一致。如果训练时用的是 ImageNet 标准化(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225],注意是 RGB 0-1 范围),需要换算成 ncnn 的 BGR 0-255 范围的等价参数。
MNN 量化
MNN 的量化有两种方式:转换时直接量化,或者用 quantized.py 工具做更精细的量化控制。
# 方式 1: 转换时直接做权重量化(最简单)
./MNNConvert -f ONNX \
--modelFile mobilenet_v2.onnx \
--MNNModel mobilenet_v2_w8.mnn \
--weightQuantBits 8
# 方式 2: 使用校准集做完整的 INT8 量化
# 准备校准数据(需要用 MNN 的格式)
python MNN/tools/script/prepareCalibData.py \
--images ./calibration_images/ \
--output ./calib_data/ \
--input_size 224
# 创建量化配置文件 quant_config.json
# {
# "format": "ONNX",
# "modelFile": "mobilenet_v2.onnx",
# "MNNModel": "mobilenet_v2_int8.mnn",
# "calibrationDataPath": "./calib_data/",
# "featureQuantizeMethod": "KL",
# "weightQuantizeMethod": "MAX_ABS"
# }
./quantized.out quant_config.json
MNN 支持的量化方法包括 KL 散度(KL)、最大绝对值(MAX_ABS)、ADMM 等。KL 散度是默认选择,在大多数场景下效果最好。
量化效果验证
量化不是”做完就完了”——必须验证量化后的模型精度是否可接受。
# 伪代码:对比 FP32 和 INT8 的输出差异
import numpy as np
# 用同一组测试图片分别在 FP32 和 INT8 模型上推理
fp32_outputs = run_fp32_model(test_images)
int8_outputs = run_int8_model(test_images)
# 计算输出差异
for fp32_out, int8_out in zip(fp32_outputs, int8_outputs):
cos_sim = np.dot(fp32_out.flatten(), int8_out.flatten()) / (
np.linalg.norm(fp32_out) * np.linalg.norm(int8_out)
)
print(f"余弦相似度: {cos_sim:.6f}") # 期望 > 0.99
# 对于分类任务,直接对比 Top-1 准确率
fp32_acc = evaluate(fp32_model, val_dataset)
int8_acc = evaluate(int8_model, val_dataset)
print(f"FP32 准确率: {fp32_acc:.2%}")
print(f"INT8 准确率: {int8_acc:.2%}")
print(f"精度损失: {fp32_acc - int8_acc:.2%}")
# 一般经验:INT8 量化的精度损失在 1% 以内是可接受的
阶段 5: 部署推理——让模型在设备上跑起来
这一阶段的细节会在 Ch24(ncnn 实战)和 Ch25(MNN 实战)中展开。这里先给出最基本的推理骨架代码,让你对”最终代码长什么样”有一个直觉。
ncnn C++ 推理骨架
#include "net.h"
int main() {
// 1. 创建网络对象
ncnn::Net net;
// 2. 加载模型
net.load_param("mobilenet_v2.ncnn.param");
net.load_model("mobilenet_v2.ncnn.bin");
// 3. 创建 Extractor(线程安全的推理实例)
ncnn::Extractor ex = net.create_extractor();
// 4. 准备输入
ncnn::Mat input = ncnn::Mat::from_pixels_resize(
image_data, ncnn::Mat::PIXEL_BGR, w, h, 224, 224
);
const float mean_vals[3] = {104.f, 117.f, 123.f};
const float norm_vals[3] = {0.017f, 0.017f, 0.017f};
input.substract_mean_normalize(mean_vals, norm_vals);
// 5. 设置输入
ex.input("input0", input);
// 6. 提取输出
ncnn::Mat output;
ex.extract("output0", output);
// 7. 解析结果
for (int i = 0; i < output.w; i++) {
float score = output[i];
// ... 处理分类结果
}
return 0;
}
MNN C++ 推理骨架
#include <MNN/Interpreter.hpp>
int main() {
// 1. 创建解释器
auto interpreter = MNN::Interpreter::createFromFile("mobilenet_v2.mnn");
// 2. 创建 Session 配置
MNN::ScheduleConfig config;
config.numThread = 4;
config.type = MNN_FORWARD_CPU;
// 3. 创建 Session
auto session = interpreter->createSession(config);
// 4. 获取输入 Tensor
auto inputTensor = interpreter->getSessionInput(session, nullptr);
// 5. 填充输入数据
auto inputHost = new MNN::Tensor(inputTensor, MNN::Tensor::CAFFE);
// ... 填充 inputHost 的数据
inputTensor->copyFromHostTensor(inputHost);
// 6. 执行推理
interpreter->runSession(session);
// 7. 获取输出
auto outputTensor = interpreter->getSessionOutput(session, nullptr);
auto outputHost = new MNN::Tensor(outputTensor, MNN::Tensor::CAFFE);
outputTensor->copyToHostTensor(outputHost);
// 8. 解析结果
auto values = outputHost->host<float>();
// ... 处理结果
delete inputHost;
delete outputHost;
delete interpreter;
return 0;
}
两段代码的核心模式是一样的:加载模型 → 准备输入 → 执行推理 → 读取输出。差异在于 API 风格——ncnn 用 Extractor 封装推理状态,MNN 用 Session 管理推理状态。具体的编译方式、Android 集成、性能优化等细节,分别在 Ch24 和 Ch25 详细展开。
四种常见的导出失败模式
在整个流水线中,阶段 2(导出)和阶段 3(转换)是最容易出问题的环节。这里总结四种最常见的失败模式和排查思路。
失败模式 1: 动态 shape 不支持
症状: 转换工具报错 “dynamic shape is not supported” 或推理时输出 shape 不对。
根因: 模型中存在依赖输入数据的 shape 操作,比如 x.reshape(x.size(0), -1) 在 ONNX 中会产生一个 Shape 算子来动态计算维度,下游引擎可能不支持这种动态行为。
排查步骤:
- 用 Netron 打开 ONNX 模型,搜索
Shape、Gather、Unsqueeze这些和动态 shape 相关的算子 - 检查这些算子的输入是否连接到了数据流(而非静态参数)
- 用
onnxsim简化模型,看能否消除这些动态节点
# onnx-simplifier 经常能把动态 shape 操作折叠成常量
onnxsim model.onnx model_sim.onnx --input-shape 1,3,224,224
失败模式 2: 条件分支丢失
症状: 导出后的模型只有一条分支的输出,另一条分支的结果永远是零或者不存在。
根因: torch.onnx.export 使用 trace 模式,只记录示例输入触发的那条代码路径。如果模型有 if-else 分支,未被触发的分支会被直接丢弃。
排查步骤:
- 检查 forward() 中是否有
if/else语句 - 如果有,确认是”结构性控制流”(比如不同模式用不同的 head)还是”数据依赖控制流”(比如根据输入值选择路径)
- 结构性控制流可以在导出前手动选择模式;数据依赖控制流需要重写为
torch.where
# 导出前手动设置模式,确保走到正确的分支
model.eval() # 确保是推理模式
model.export = True # 如果模型有 export 标志位
失败模式 3: 自定义算子缺失
症状: ONNX 导出时报 “Exporting the operator ‘xxx’ to ONNX opset version X is not supported”。
根因: 模型使用了 PyTorch 的自定义 C++ 扩展算子,或者使用了 ONNX 不支持的 PyTorch 操作。
排查步骤:
- 确认缺失的算子是自定义的还是 PyTorch 内建但 ONNX 不支持的
- 如果是自定义算子,写一个 ONNX symbolic function 来定义导出行为
- 如果是 PyTorch 内建算子,尝试升级 opset 版本或用等价的支持算子替换
# 为自定义算子注册 ONNX symbolic
@torch.onnx.symbolic_helper.parse_args("v", "i")
def my_custom_op_symbolic(g, input, param):
return g.op("custom_domain::MyOp", input, param_i=param)
torch.onnx.register_custom_op_symbolic(
"mylib::custom_op", my_custom_op_symbolic, opset_version=13
)
失败模式 4: MoE 路由导出失败
症状: Mixture-of-Experts 模型导出后行为异常,不同输入得到相同的输出,或者直接报错。
根因: MoE 的核心是动态路由——Router 根据输入数据决定激活哪些 Expert。这涉及 top-k 选择、稀疏激活、条件执行,三者都是端侧引擎的”禁区”:
- top-k 选择产生数据依赖的索引,静态图无法表达”运行时才知道选哪个 Expert”
- 稀疏激活意味着不同输入执行不同的子图,违反了静态图”固定执行路径”的假设
- 条件执行是 trace 模式天然不支持的
排查思路: MoE 到端侧是一个系统性的难题,不是简单调参能解决的。Ch27 会详细讨论三条可行路径:单专家蒸馏、标准模型替代、以及实验性的全 MoE 导出方案。
常见问题排查
Q: ONNX 导出成功了,但 onnx2ncnn 报 Segmentation Fault?
A: 大概率是 ONNX 模型中有 ncnn 不认识的算子结构。先用 onnxsim 简化,再试一次。如果还是崩溃,用 onnx.checker.check_model() 检查 ONNX 模型是否合法,有时候是 PyTorch 导出了一个结构有问题的 ONNX 文件。
Q: 转换后的模型推理结果和 PyTorch 不一致?
A: 按以下顺序排查:(1)检查预处理参数是否一致(mean/norm/RGB vs BGR/像素值范围);(2)用 ONNX Runtime 跑一遍 ONNX 模型,和 PyTorch 对比,确认是 ONNX 导出阶段引入的误差还是引擎转换阶段引入的;(3)如果是引擎转换阶段的问题,用二分法找到是哪一层开始出现误差(ncnn 可以逐层提取中间结果对比)。
Q: 量化后精度掉了很多(> 3%),怎么办?
A: 几个可以尝试的方向:(1)增加校准集大小和多样性;(2)换量化方法(比如从 MAX_ABS 换成 KL);(3)对精度敏感的层保持 FP32 不量化(mixed precision quantization);(4)如果 PTQ 怎么调都不行,考虑 QAT(Quantization-Aware Training),在训练时就模拟量化误差。
Q: PNNX 转换时报 “unsupported TorchScript node”?
A: PNNX 依赖 TorchScript IR,如果 PyTorch 操作不能被 torch.jit.trace 或 torch.jit.script 正确捕获,PNNX 也处理不了。常见原因:(1)使用了 torch.jit.trace 不支持的动态控制流——尝试用 torch.jit.script 代替;(2)使用了第三方库的算子——需要在 trace 前替换为标准 PyTorch 操作。
本章小结
端到端部署流水线的核心是五个阶段:训练(注意导出友好性)→ 导出(ONNX/PNNX/Ultralytics/原生工具)→ 转换(onnx2ncnn/MNNConvert/convert2tnn)→ 量化(PTQ 校准)→ 部署推理。
四条导出路径的选择很简单:如果用 Ultralytics YOLO,直接 yolo export;如果目标只有 ncnn,用 PNNX;如果需要多引擎兼容,用 ONNX;其他情况用框架原生工具。
四种常见失败模式中,动态 shape 和条件分支丢失是最高频的。记住一个核心原则:端侧推理引擎需要的是静态计算图——任何依赖运行时数据的动态行为都是潜在的导出障碍。 在写模型代码时就考虑导出友好性,比事后修修补补高效得多。
下一步建议
- 想亲手编译 ncnn 并跑通第一个模型?→ Ch24: ncnn 实战
- 想用 MNN 的更丰富的 API 做推理?→ Ch25: MNN 实战
- 想了解 MoE 模型如何部署到端侧?→ Ch27: YOLO 到端侧
导读目录:README.md 上一章:Ch22: 横向总结——六个引擎的设计取舍全景图 下一章:Ch24: ncnn 实战——编译、推理、性能测试