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.trace 或 torch.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,再分别转换
另外几个实际考虑因素:
- 如果你使用 Ultralytics 的 YOLO 模型,
yolo export format=ncnn一条命令搞定全流程——它内部调用了 PNNX - 如果你需要在 iOS 上跑,MNN(Metal 后端)或 ORT(CoreML EP)是主要选择
- 如果你需要适配大量国产 NPU 硬件,Paddle-Lite 的 NNAdapter 覆盖最广
- 如果你追求最小体积和最快加载,ncnn 的 .param + .bin 是最轻量的选择
模型格式和推理引擎的关系
最后用一张全景表总结各格式的特点,帮助你在后续阅读中快速查阅。
| 引擎 | 格式 | 编码 | 文件数 | 人类可读 | 零拷贝解析 | 预含优化 |
|---|---|---|---|---|---|---|
| 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: 量化入门——用精度换速度的艺术