3. ONNX 导出原理:从 PyTorch 到通用中间格式
好,咱们进入第三章。这一章我打算把 ONNX 导出这件事彻底讲透。很多同学跑通了导出,但遇到动态尺寸报错、算子不兼容、精度对不上就懵了。说白了,就是没搞懂 ONNX 到底是个什么东西,以及 torch.onnx.export 背后在干什么。
我个人习惯把 ONNX 看作「模型的世界语」。你训练好的 PyTorch 模型,别人可能用 TensorFlow、用 Caffe、用 CoreML,怎么互通?ONNX 就是那个中间桥梁。而 TensorRT 部署,只是这座桥梁的一个目的地。
3.1 ONNX 格式:不只是个「模型文件」
ONNX(Open Neural Network Exchange)本质上是一个计算图的表示协议。它不关心你怎么训练的,只关心网络长什么样、数据怎么流动。
一个 ONNX 模型文件(.onnx)内部包含三样东西:
- 计算图结构:节点(算子)和边(张量)构成的有向无环图
- 权重参数:所有可训练参数序列化存储
- 元数据:输入输出名称、数据类型、形状信息、opset 版本等
你可以用 onnx.load() 把它读进来,然后用 onnx.checker.check_model() 验证完整性。我在项目中经常遇到导出时没报错,但 checker 一跑就发现图结构有问题的案例——嗯,这一步千万别省。
核心认知:ONNX 是静态图。PyTorch 是动态图(define-by-run),导出时 torch.onnx.export 会 trace 一遍 forward,把动态执行路径「凝固」成静态计算图。这意味着——控制流(if/for)默认不会被导出,除非你用 torch.jit.script 或显式标注。
3.2 torch.onnx.export 参数详解:每个参数我都踩过坑
这个函数是导出流程的核心。我把它最常用的参数列出来,每个都附上我在实战中遇到的教训。
| 参数 | 类型 | 说明 | 避坑指南 |
|---|---|---|---|
model |
torch.nn.Module | 要导出的模型实例 | 记得调成 model.eval() 模式,否则 BN/Dropout 行为会错 |
args |
tuple | 模型的示例输入 | 形状和数据类型必须与真实推理一致,否则 trace 出来的图是错的 |
f |
str | 输出文件路径 | 建议用 .onnx 后缀,不要用 .pt 或 .pth |
export_params |
bool | 是否导出权重 | 默认 True。部署场景必须 True,否则推理结果全错 |
opset_version |
int | ONNX 算子集版本 | YOLO 系列建议 11~17,后面单独讲 |
do_constant_folding |
bool | 是否做常量折叠优化 | 建议 True,能减少冗余计算节点 |
input_names |
list[str] | 输入张量名称 | YOLO 通常用 ['images'] |
output_names |
list[str] | 输出张量名称 | YOLOv8 用 ['output0'],注意区分不同版本 |
dynamic_axes |
dict | 定义动态轴 | YOLO 部署必配,后面重点讲 |
我曾经踩过的坑:有一次导出 YOLOv5,忘记设 model.eval(),结果导出的 ONNX 在 TensorRT 上推理结果全是 NaN。查了两天才发现是 BN 层在 training 模式下统计了错误的 running mean。所以——导出前先调 eval,这是铁律。
3.3 动态轴与静态轴:YOLO 部署的命门
这是整个导出环节最容易出错的地方。我先说清楚概念。
静态轴:导出时固定死的维度。比如 batch=1,那导出的 ONNX 就只能一次推理一张图。想改 batch size?重新导出。
动态轴:导出时不固定,推理时再指定。比如 batch 可以是 1、4、8、16,同一个 ONNX 文件都能处理。
YOLO 部署场景下,我们通常需要动态的 batch 和动态的 height/width。为什么?因为生产环境里,你不可能保证每次请求的图片尺寸都一样。
来看一个 YOLOv8 的典型配置:
dynamic_axes = {
'images': {
0: 'batch',
2: 'height',
3: 'width'
},
'output0': {
0: 'batch',
2: 'height',
3: 'width'
}
}
torch.onnx.export(
model,
dummy_input,
'yolov8n.onnx',
input_names=['images'],
output_names=['output0'],
dynamic_axes=dynamic_axes,
opset_version=17,
do_constant_folding=True
)
注意看:images 的形状是 (batch, 3, height, width),我把第 0、2、3 维都设成了动态。第 1 维是通道数(RGB=3),这个通常是固定的,不需要动。
我的建议:如果你只做推理服务,batch 维度设动态就够了。height 和 width 设动态会引入额外的 resize 操作,在 TensorRT 上可能带来性能损失。我一般做法是:固定输入尺寸(如 640x640),只动态 batch。这样既灵活又高效。
3.4 opset 版本选择:不是越新越好
opset 就是 ONNX 算子集的版本号。每个版本会新增、废弃或修改一些算子。选错了,要么导出报错,要么推理结果不对。
我整理了一个版本选择参考表:
| opset 版本 | 关键变化 | YOLO 适配建议 |
|---|---|---|
| 11 | 支持 Resize 的 coordinate_transformation_mode |
YOLOv5 最低要求 |
| 13 | 支持 Softmax 的 axis 参数 |
YOLOv8 常用基线 |
| 15 | 支持 Shape 的 start/end 切片 |
部分后处理算子需要 |
| 17 | 支持 LayerNormalization 等新算子 |
YOLOv8/v11 推荐 |
| 18+ | 新增 GroupNormalization 等 |
TensorRT 可能不支持,慎用 |
我个人习惯:YOLOv5 用 opset 12,YOLOv8/v11 用 opset 17。为什么不用 18?因为 TensorRT 8.x 对 opset 18 的支持还不完善,我在项目里遇到过 GroupNormalization 算子无法转换的问题。你想想看,导出成功了,但 TensorRT 编译失败,那不等于白干?
注意:opset 版本不是越高越好。高版本算子可能不被目标推理引擎支持。选版本前,先查一下你的 TensorRT 版本支持的最高 opset。比如 TensorRT 8.5 最高支持 opset 17,你选 18 就会报 Unsupported operator。
3.5 完整导出流程:从 PyTorch 到 ONNX
把上面所有知识点串起来,一个完整的 YOLO 导出流程长这样:
- 加载模型:
torch.load()或ultralytics.YOLO() - 调 eval 模式:
model.eval() - 构造 dummy input:形状与真实输入一致,值可以是随机数
- 配置 dynamic_axes:至少动态 batch
- 选择 opset:YOLO 推荐 12~17
- 执行导出:
torch.onnx.export() - 验证 ONNX:
onnx.checker.check_model() - 测试推理:用
onnxruntime跑一遍,对比 PyTorch 输出
最后一步很多人会跳过。但我建议你务必做——用 ONNX Runtime 跑一次推理,把输出和 PyTorch 的原始输出做数值对比。误差在 1e-3 以内算正常,超过 1e-2 就要排查了。
经验之谈:我遇到过导出时一切正常,但 ONNX Runtime 推理结果和 PyTorch 差了 0.5 的情况。最后发现是 torch.onnx.export 的 do_constant_folding 把某些 BN 层的参数折叠错了。关掉这个选项就恢复了。所以——不要盲目信任默认参数,每个选项都要理解它的影响。
3.6 知识结构图:ONNX 导出全景
下面这张图把本章的核心知识点串起来了。你可以把它当作导出前的检查清单。
这张图从左到右展示了导出流程的三个关键决策点:参数怎么配、动态轴怎么设、opset 怎么选。每一步都直接影响最终 ONNX 文件的质量。我建议你把它打印出来贴在工位上,每次导出前扫一眼,能少踩很多坑。