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

Ch09: 模型格式与转换——从 PyTorch 到手机的桥梁

Part 2: 移动端推理基础(从零开始) 前置章节:Ch08: 内存管理入门 后续章节:Ch10: 量化入门


模型格式就像语言

假设你在中国用普通话(PyTorch)写了一本技术手册。现在你要把这本手册翻译给不同国家的人看:有人说日语(ncnn 格式),有人说韩语(MNN 格式),有人说德语(TNN 格式)。直接从普通话翻译到每种语言都很困难——你需要找到懂普通话又懂日语的翻译,再找懂普通话又懂韩语的翻译……

更聪明的做法是:先把普通话翻译成英语(ONNX 格式)。英语是国际学术通用语,大多数翻译都懂英语。然后再从英语翻译到各国语言——从英语到日语、从英语到韩语,比直接从普通话翻译容易得多,因为英语的翻译工具和人才更丰富。

这就是深度学习模型格式转换的基本格局。PyTorch 是训练界的”普通话”——绝大多数研究论文的模型都用 PyTorch 实现。ONNX 是中间标准——几乎所有推理引擎都支持从 ONNX 转换。各推理引擎有自己的”母语”——ncnn 的 .param/.bin、MNN 的 .mnn、ORT 直接使用 .onnx。

还有一种特殊情况:ncnn 的 PNNX 工具,相当于一个”精通普通话的日语翻译”——它可以直接把 PyTorch 模型翻译成 ncnn 格式,绕过英语(ONNX)这个中间环节。好处是翻译更精确(保留了更多 PyTorch 特有的语义),坏处是只能翻成日语(只有 ncnn 能用)。


为什么需要模型转换

为什么不能直接在手机上跑 PyTorch 的模型文件?Ch04 已经从”体积/速度/依赖”的角度解释过。这里从模型格式的角度补充三个技术原因。

原因一:运行时体积

PyTorch 的模型文件(.pt 或 .pth)是一个 Python pickle 序列化的对象——它不仅包含权重数据,还包含完整的 Python 类定义、前向传播的 Python 代码逻辑。要”读懂”这个文件,你需要一个完整的 Python 运行时和 PyTorch 库——加起来超过 100MB。

推理引擎的模型格式则专门为轻量级加载而设计。ncnn 的 .param 文件是纯文本(人类可读),解析器只有几百行 C++ 代码。MNN 的 .mnn 文件用 FlatBuffers 编码——一种零拷贝的序列化格式,解析速度极快且不需要内存分配。ORT 的 .onnx 文件用 Protobuf 编码——标准的高效二进制格式。

格式 编码方式 解析器体积 解析速度
PyTorch .pt Python pickle >100 MB (需要 Python) 慢(Python 解释执行)
ncnn .param 纯文本 几百行 C++ 极快
MNN .mnn FlatBuffers ~100 KB 极快(零拷贝)
ONNX .onnx Protobuf ~500 KB
TNN .tnnproto 类文本 几百行 C++ 极快
Paddle-Lite .nb 自定义二进制 内置 快(含预优化结果)
MACE .pb + .data Protobuf + 独立权重 ~500 KB

原因二:计算图的表达方式

PyTorch 使用动态图(eager mode)——计算图在每次前向传播时动态构建。这对训练很方便(可以用 Python 的 if/else/for 来控制计算流程),但对推理引擎来说是个问题:推理引擎需要在执行前就知道完整的计算图结构,才能做内存规划、算子融合等优化。

推理引擎的模型格式都使用静态图——计算图的结构在文件中完全确定,不依赖运行时条件。从 PyTorch 的动态图导出为静态图的过程(通过 torch.jit.tracetorch.onnx.export),需要用一个示例输入”跑一遍”网络,记录下执行路径上的所有操作——这就是”trace”(追踪)的含义。

如果你的 PyTorch 模型中有数据依赖的控制流(比如 if x.mean() > 0.5: 走路径A else: 走路径B),trace 只能记录示例输入走过的那条路径——另一条路径在导出的模型中不存在。这是模型转换中最常见的失败模式之一。

原因三:针对目标硬件的优化

通用的模型格式(PyTorch .pt、ONNX .onnx)不包含任何针对特定硬件的优化信息。但推理引擎的模型格式可以。

Paddle-Lite 的 .nb 格式是一个典型例子。它不是”原始模型的翻译”,而是”优化后模型的存档”——包含了 50+ 个 MIR pass 优化后的结果:算子融合结果、Kernel 选择结果、内存复用规划。这意味着加载 .nb 文件时可以跳过所有优化步骤,直接进入执行。代价是 .nb 文件和目标硬件绑定——在骁龙 8 Gen3 上生成的 .nb 文件不一定能在联发科天玑 9000 上使用。


六个引擎的模型格式详解

ncnn: .param + .bin——人类可读

ncnn 的模型格式是六个引擎中最”人类友好”的。它由两个文件组成:

.param 文件:纯文本,描述计算图结构。每一行定义一个算子,包含算子类型、名称、输入输出 blob 名称、参数列表。

7767517                        # 魔数(版本标识)
3 3                            # 3个算子,3个blob
Input            input  0 1 input  # 输入层: 0个输入,1个输出,输出名为"input"
Convolution      conv1  1 1 input conv1_out  0=64 1=3 2=1 3=1 4=0 5=1 6=1728
                                    # 卷积: 输出通道64,kernel3x3,stride1,pad0,dilation1,权重数1728
Pooling          pool1  1 1 conv1_out pool1_out  0=0 1=2 2=2 3=0
                                    # 池化: MaxPool, kernel2x2, stride2

这种纯文本格式的优点是调试极其方便——你可以用文本编辑器直接打开 .param 文件,看到每一层的参数设置,甚至手动修改。这对学习和排错非常有价值。

.bin 文件:二进制,存储所有层的权重数据。按 .param 中的层顺序依次存储每一层的权重矩阵。没有额外的元数据——纯粹的浮点/整数数组。

这种”文本结构 + 二进制权重”的分离设计在推理引擎中很常见(TNN 也是类似设计),其优势是结构信息和数据信息的关注点分离:修改网络结构只改文本文件,不用动几十 MB 的权重文件。

MNN: .mnn——FlatBuffers 单文件

MNN 使用 Google 的 FlatBuffers 作为序列化格式,把计算图结构和权重数据打包在一个 .mnn 文件中。

FlatBuffers 和 JSON/Protobuf 的最大区别是零拷贝解析——反序列化时不需要把数据从文件格式转换为内存对象,而是直接在文件的二进制数据上建立访问指针。换句话说,”解析”这个步骤几乎不花时间也不分配额外内存。

传统 Protobuf 解析:
  文件二进制 → 解码 → 创建内存对象 → 填充字段值
  (需要分配新内存,需要拷贝数据)

FlatBuffers 解析:
  文件二进制 → 直接建立指针到文件数据上
  (不分配新内存,不拷贝数据)

MNN 的 Op 定义用 FlatBuffers schema 语言声明(.fbs 文件),编译后生成 C++ 访问代码。每个 Op 的参数(如 Convolution 的 kernel_size、stride、padding 等)都有精确的类型定义和版本管理。

单文件格式的好处是部署简单——只需管理一个文件。坏处是修改不方便——不能像 ncnn 那样用文本编辑器直接修改结构。

ONNX Runtime: .onnx——标准格式

ORT 直接使用 ONNX(Open Neural Network Exchange)标准格式,无需转换。这是 ORT 最大的优势——用户不需要额外的转换步骤,PyTorch 导出的 .onnx 文件可以直接传入 ORT 推理。

ONNX 文件使用 Protobuf 编码,包含以下核心信息:

ModelProto
├── opset_import: 使用的算子版本(如 opset 17)
├── graph: GraphProto
│   ├── node[]: 算子列表,每个包含 op_type, inputs, outputs, attributes
│   ├── initializer[]: 权重数据(可选:外部文件存储)
│   ├── input[]: 模型输入的名称和类型信息
│   └── output[]: 模型输出的名称和类型信息
└── metadata_props: 元数据(如生产者、版本等)

ONNX 的 opset 版本化是一个重要概念。同一个算子(比如 Add)在不同的 opset 版本中可能有不同的语义。ORT 为此需要维护不同 opset 版本的 kernel 实现——Add 算子在 opset 7-12、opset 13、opset 14+ 各有一套实现。这增加了代码复杂度,但保证了对不同版本 ONNX 模型的向后兼容。

对于大模型,ONNX 支持”外部数据”模式——权重数据存储在 .onnx 文件之外的独立文件中。这样 .onnx 文件本身只包含图结构,体积很小。

TNN: .tnnproto + .tnnmodel——类 ncnn 的双文件

TNN 的模型格式和 ncnn 的 .param + .bin 很像——设计理念相同,但细节有差异。

.tnnproto:类文本格式,描述计算图。语法和 ncnn 的 .param 略有不同但思路一致。

.tnnmodel:二进制权重文件。

作为”ncnn 的架构翻新版”,TNN 在模型格式上也做了一些改进——比如更完善的层参数规范和更灵活的数据类型支持。转换工具 convert2tnn 支持从 ONNX、TF、Caffe、PyTorch 等多种来源转入。

Paddle-Lite: .nb——预优化的部署格式

Paddle-Lite 的 .nb(NaiveBinary)格式是经过完整优化管线后的产物。整个流程是:

PaddlePaddle 模型 → opt 工具 → [50+ MIR pass 优化] → .nb 文件

.nb 文件包含的不是”原始模型”,而是”优化后的执行计划”——kernel 已选择、算子已融合、内存已规划。加载 .nb 时跳过所有优化步骤,直接构建 RuntimeProgram 执行。

这种设计的优势是部署后的冷启动极快(不需要重新优化),劣势是 .nb 文件和 opt 工具运行时的目标设备绑定——在不同硬件上可能需要重新生成。

MACE: .pb + .data——Protobuf + 独立权重

MACE 使用 Protobuf 描述计算图(.pb 文件),权重数据存储在独立的 .data 文件中。

MACE 的转换工具 mace_tools 支持从 TensorFlow、ONNX、Caffe 转入。转换过程中会根据目标后端(CPU/GPU/DSP)做一些预处理,比如 GPU 后端会把权重预转换为 image2d 需要的布局格式。


ONNX:模型格式的”英语”

ONNX 值得单独用一节来讲,因为它在模型转换生态中扮演了”中间桥梁”的核心角色。

ONNX(Open Neural Network Exchange)是 2017 年由微软和 Facebook 联合发起的开放格式标准。它定义了一套标准化的算子语义(目前有 180+ 个标准算子)和一种统一的模型表示方式。

ONNX 的核心价值在于:几乎所有主流训练框架都支持将模型导出为 ONNX 格式。

PyTorch      → torch.onnx.export() → .onnx
TensorFlow   → tf2onnx             → .onnx
PaddlePaddle → paddle2onnx         → .onnx
JAX/Flax     → jax2onnx            → .onnx

同时,几乎所有主流推理引擎都支持从 ONNX 格式导入。

.onnx → onnx2ncnn    → ncnn
.onnx → MNNConvert   → MNN
.onnx → ORT 直接使用  → ORT
.onnx → convert2tnn  → TNN
.onnx → opt          → Paddle-Lite
.onnx → mace_tools   → MACE

ONNX 就像一个”万能接口”——只要你能导出 ONNX,就能用任何推理引擎。这大大简化了模型部署的复杂度。

但 ONNX 也有局限性。最主要的是标准算子集的覆盖有限。如果你的模型使用了 ONNX 标准中没有定义的操作(比如某些自定义的注意力机制变体),导出时要么失败、要么需要用多个标准算子拼合来近似实现——后者可能引入精度损失或性能下降。


转换链路总览

把上面的信息串联起来,模型从训练到手机的完整转换链路如下:

flowchart TD
    PT[PyTorch .pt] --> TS[TorchScript]
    PT --> PNNX[PNNX 直通]
    TS --> ONNX[ONNX .onnx]
    ONNX --> SIMP[onnx-simplifier]
    SIMP --> ncnn[onnx2ncnn → .param + .bin]
    PNNX --> ncnn
    SIMP --> MNN[MNNConvert → .mnn]
    SIMP --> ORT_F[ORT 直接使用 .onnx]
    SIMP --> TNN_F[convert2tnn → .tnnproto + .tnnmodel]
    SIMP --> PL[opt → .nb]
    SIMP --> MACE_F[mace_tools → .pb + .data]

标准路径:PyTorch → ONNX → 引擎格式

这是最通用的转换路径,适用于所有六个引擎。步骤如下:

第一步,从 PyTorch 导出 ONNX:

import torch
model = MyModel()
model.eval()                              # 切换到推理模式(关闭 Dropout 等)
dummy_input = torch.randn(1, 3, 224, 224) # 示例输入
torch.onnx.export(
    model, dummy_input,
    "model.onnx",
    opset_version=13,                     # 推荐 opset 13+
    input_names=["input"],
    output_names=["output"],
    dynamic_axes={"input": {0: "batch"}}  # 可选:动态 batch 维度
)

第二步,用 onnx-simplifier 简化(推荐但非必须)。onnx-simplifier 会做常量折叠、移除冗余节点、简化图结构——让后续的转换工具更容易处理:

pip install onnx-simplifier
onnxsim model.onnx model_simplified.onnx

第三步,转换到目标引擎格式:

# ncnn
onnx2ncnn model_simplified.onnx model.param model.bin

# MNN
MNNConvert -f ONNX --modelFile model_simplified.onnx --MNNModel model.mnn

# TNN
python convert2tnn.py onnx -i model_simplified.onnx -o model

PNNX:ncnn 的”直通车”

PNNX(PyTorch Neural Network Exchange)是 ncnn 作者 nihui 开发的一个特殊转换工具。它不走 ONNX 中间格式,而是直接从 PyTorch 的 TorchScript 转换到 ncnn 格式。

# Step 1: 从 PyTorch 导出 TorchScript
python -c "
import torch
model = MyModel()
model.eval()
ts = torch.jit.trace(model, torch.randn(1, 3, 224, 224))
ts.save('model.pt')
"

# Step 2: PNNX 直接转换
pnnx model.pt inputshape=[1,3,224,224]
# 输出: model.ncnn.param + model.ncnn.bin

PNNX 的优势在于它能保留 PyTorch 原生的语义信息——某些在 ONNX 转换中会丢失或被拆分的操作(比如 F.pad 的各种模式、nn.LSTM 等),PNNX 可以直接映射为 ncnn 的原生层。这意味着转换成功率更高、生成的模型更精确。

缺点是 PNNX 只能输出 ncnn 格式——如果你要用 MNN 或 ORT,还是得走 ONNX 路径。


转换过程中的常见失败模式

模型转换不总是一帆风顺的。以下是三种最常见的失败模式及其原因。

失败一:动态 Shape

PyTorch 的模型可以处理任意大小的输入(动态 Shape),但静态图格式通常假设输入尺寸在转换时确定。

# PyTorch 中可以正常运行
model(torch.randn(1, 3, 224, 224))  # 224x224 输入
model(torch.randn(1, 3, 640, 640))  # 640x640 输入,同一个模型

# 导出 ONNX 时需要决定:固定为哪个尺寸?还是标记为动态?

如果导出时没有正确设置 dynamic_axes,生成的 ONNX 模型会把形状写死。后续转换到 ncnn/MNN 时,如果输入尺寸和导出时不一致,要么报错、要么结果错误。

ncnn 在六个引擎中对动态 Shape 的支持最好——它的 .param 格式可以用 -1 表示动态维度。MNN 也支持动态 Shape,但需要在 Session 创建时指定可能的输入尺寸范围。

失败二:自定义算子

如果你的 PyTorch 模型使用了自定义的 C++ 扩展算子(通过 torch.utils.cpp_extension 注册),ONNX 导出器不知道怎么处理它——因为这个算子不在 ONNX 标准算子集中。

解决方案通常有两种:一是用 ONNX 标准算子组合来”模拟”自定义算子的功能(可能有性能损失);二是在推理引擎中注册同名的自定义层(需要自己实现),然后在 ONNX 导出时用 torch.onnx.register_custom_op_symbolic 告诉导出器这个算子的签名。

失败三:控制流

数据依赖的控制流(if/else/for 的条件取决于输入数据)是最难处理的。

def forward(self, x):
    if x.mean() > 0.5:           # 条件取决于输入数据
        return self.branch_a(x)  # 路径 A
    else:
        return self.branch_b(x)  # 路径 B

torch.jit.trace 只能记录示例输入实际走过的那条路径——另一条路径会被完全忽略。torch.jit.script 理论上可以保留两条路径,但 script 对 Python 语法的支持有诸多限制。

ONNX 标准中有 If/Loop 等控制流算子,但大多数推理引擎对它们的支持有限或不支持。实际工程中最常见的做法是:在模型设计阶段就避免数据依赖的控制流——用乘法 mask 替代 if/else,用固定次数的循环替代 while。


格式选择决策树

面对六个引擎和各自的格式,实际项目中如何选择?以下是一个简化的决策流程。

你的模型是 PyTorch 的吗?
├── 是
│   ├── 目标引擎是 ncnn?
│   │   ├── 是 → 优先试 PNNX 直通;失败则走 ONNX→onnx2ncnn
│   │   └── 否 → 走 ONNX 路径
│   └── 目标引擎是 ORT? → 直接导出 ONNX,不需要额外转换
│
├── 是 PaddlePaddle 的? → 走 Paddle-Lite opt 工具
│
└── 需要跨多个引擎对比? → 先导出 ONNX,再分别转换

另外几个实际考虑因素:


模型格式和推理引擎的关系

最后用一张全景表总结各格式的特点,帮助你在后续阅读中快速查阅。

引擎 格式 编码 文件数 人类可读 零拷贝解析 预含优化
ncnn .param + .bin 文本 + 二进制 2 .param 可读
MNN .mnn FlatBuffers 1
ORT .onnx Protobuf 1 (大模型可拆分)
TNN .tnnproto + .tnnmodel 文本 + 二进制 2 .tnnproto 可读
Paddle-Lite .nb 自定义二进制 1
MACE .pb + .data Protobuf + 二进制 2 部分

本章小结

模型格式是训练框架和推理引擎之间的桥梁。PyTorch 的模型不能直接在手机上运行,需要经过格式转换——从 Python 动态图变成轻量级的静态计算图描述。

ONNX 是最重要的中间格式,扮演”国际通用语”的角色——几乎所有训练框架都能导出 ONNX,几乎所有推理引擎都能导入 ONNX。标准转换路径是 PyTorch → TorchScript → ONNX → 目标引擎格式。ncnn 的 PNNX 提供了绕过 ONNX 的直通车,保留更多 PyTorch 语义。

六个引擎各有自己的模型格式,设计取舍各不相同:ncnn 的 .param 人类可读,方便调试;MNN 的 .mnn 零拷贝解析,加载极快;ORT 直接使用 ONNX 标准格式,免去转换步骤;Paddle-Lite 的 .nb 包含预优化结果,冷启动快。

转换过程中最常遇到的三类问题是动态 Shape、自定义算子和数据依赖的控制流。在模型设计阶段就考虑部署兼容性,可以避免大部分转换失败。

下一章是 Part 2 的最后一个基础概念——量化。量化技术让模型的体积和计算量大幅缩减,是移动端部署的必备技能。


导读目录:README.md 上一章:Ch08: 内存管理入门——为什么”省内存”比”跑得快”更重要 下一章:Ch10: 量化入门——用精度换速度的艺术