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

Ch23: 端到端部署流水线——从训练到手机推理的完整路径

Part 5: 实战与竞赛 前置章节:Ch22: 横向总结——六个引擎的设计取舍全景图 后续章节:Ch24: ncnn 实战——编译、推理、性能测试


本章目标

前 22 章讲的都是推理引擎”内部是怎么回事”。从这一章开始,我们切换到实操视角:你手上有一个训练好的 PyTorch 模型,怎么让它在手机上跑起来?

这一章会走完整个流水线的每一步:训练框架导出、中间格式转换、量化压缩、最终部署。每一步都给出具体的命令行和代码,以及最常见的失败模式和排查方法。

读完这一章,你应该能:


前置准备


流水线全景

端侧部署的完整流水线可以分成五个阶段。先画一张全景图,后面逐个展开:

阶段 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.Conv2dnn.BatchNorm2d)分解成了底层的数学运算(矩阵乘法、加法、广播等)。这个分解过程丢失了语义信息——下游引擎拿到的是一堆零散的算子,不知道它们原来属于同一个 Conv2d。这让引擎很难做针对性的融合优化(比如 Conv+BN 融合需要知道哪些算子原来是一个 BN)。

PNNX 的做法是:先用 torch.jit.tracetorch.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 这一条命令背后做了什么?它实际上执行了完整的流水线:

  1. 加载 PyTorch 权重
  2. 调用 PNNX 转换(Ultralytics 内置了 PNNX 的调用逻辑)
  3. 输出 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:

实践中,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 的错误。解决方案:

  1. 检查是否能在 PyTorch 侧用支持的算子替换
  2. 降低 ONNX opset 版本重新导出(某些算子在低 opset 中有不同的实现方式)
  3. 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 的参数说明:

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 张通常够了),但需要有代表性——覆盖模型在实际部署中会遇到的各种输入场景。如果你做的是人脸检测,校准集里应该有不同光照、不同角度、不同大小的人脸。用训练集的一个子集通常是最安全的选择。

meannorm 参数必须和训练时的预处理参数一致。如果训练时用的是 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 算子来动态计算维度,下游引擎可能不支持这种动态行为。

排查步骤:

  1. 用 Netron 打开 ONNX 模型,搜索 ShapeGatherUnsqueeze 这些和动态 shape 相关的算子
  2. 检查这些算子的输入是否连接到了数据流(而非静态参数)
  3. onnxsim 简化模型,看能否消除这些动态节点
# onnx-simplifier 经常能把动态 shape 操作折叠成常量
onnxsim model.onnx model_sim.onnx --input-shape 1,3,224,224

失败模式 2: 条件分支丢失

症状: 导出后的模型只有一条分支的输出,另一条分支的结果永远是零或者不存在。

根因: torch.onnx.export 使用 trace 模式,只记录示例输入触发的那条代码路径。如果模型有 if-else 分支,未被触发的分支会被直接丢弃。

排查步骤:

  1. 检查 forward() 中是否有 if/else 语句
  2. 如果有,确认是”结构性控制流”(比如不同模式用不同的 head)还是”数据依赖控制流”(比如根据输入值选择路径)
  3. 结构性控制流可以在导出前手动选择模式;数据依赖控制流需要重写为 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 操作。

排查步骤:

  1. 确认缺失的算子是自定义的还是 PyTorch 内建但 ONNX 不支持的
  2. 如果是自定义算子,写一个 ONNX symbolic function 来定义导出行为
  3. 如果是 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 选择、稀疏激活、条件执行,三者都是端侧引擎的”禁区”:

排查思路: 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.tracetorch.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 和条件分支丢失是最高频的。记住一个核心原则:端侧推理引擎需要的是静态计算图——任何依赖运行时数据的动态行为都是潜在的导出障碍。 在写模型代码时就考虑导出友好性,比事后修修补补高效得多。


下一步建议


导读目录:README.md 上一章:Ch22: 横向总结——六个引擎的设计取舍全景图 下一章:Ch24: ncnn 实战——编译、推理、性能测试