ONNX 作为中间表示:为什么我们需要它
做模型部署这些年,我最大的体会就是——框架碎片化太折磨人了。
你想想看,PyTorch 训好的模型,要跑到 TensorRT 上,得转一遍。客户那边用的是 OpenVINO,又得转一遍。要是团队里有人用 MXNet、有人用 PaddlePaddle……嗯,那画面太美我不敢看。
ONNX(Open Neural Network Exchange)就是来解决这个问题的。说白了,它想做 AI 模型界的 通用语言。不管你在哪个框架里训模型,导出成 ONNX 格式,就能在支持 ONNX 的推理引擎上跑起来。
核心价值一句话:一次导出,到处推理。
ONNX 的诞生背景
2017 年,微软和 Facebook 联手推出了 ONNX。当时我还在做嵌入式端的模型移植,每天的工作就是对着不同框架的算子手册,手动改写网络结构。说实话,那会儿听到这个消息,我差点没哭出来。
ONNX 的设计目标很明确:
- 互操作性:打通训练框架和推理引擎之间的壁垒
- 标准化:定义一套统一的算子集和计算图表示
- 可优化:中间表示方便做图优化和量化
后来微软把 ONNX Runtime 也开源了,这就更香了。你导出的 ONNX 模型,直接用 ONNX Runtime 就能跑,不需要依赖原始框架。
模型导出流程:从训练到 ONNX
我个人习惯把导出流程分成三步:
- 准备模型:把训练好的模型切换到 eval 模式,关掉 dropout 和 batch norm 的 training 状态
- 构造 dummy 输入:给一个和真实输入 shape 一致的张量,ONNX 导出时需要走一次前向推理来记录计算图
- 调用导出 API:指定输出路径、动态轴、算子版本等参数
拿 PyTorch 举个例子:
import torch
import torch.onnx
# 1. 准备模型
model = MyYOLOModel().eval()
# 2. 构造 dummy 输入
dummy_input = torch.randn(1, 3, 640, 640)
# 3. 导出 ONNX
torch.onnx.export(
model,
dummy_input,
"yolo_model.onnx",
opset_version=11,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"},
"output": {0: "batch_size"}
}
)
小技巧:opset_version 建议选 11 或更高。版本太低的话,一些新算子(比如 Resize、ScatterND)可能不支持。我在项目中遇到过 opset=9 导出后,上采样层直接报错的情况。
ONNX 算子集与版本兼容性
ONNX 算子集(Operator Set)是它的核心。每个版本都会新增、修改或废弃一些算子。
这里我整理了一份常用版本的关键变化:
| opset 版本 | 关键变化 | 推荐场景 |
|---|---|---|
| 9 | 支持 Resize、Scatter 等 | 兼容旧版推理引擎 |
| 10 | 新增 QuantizeLinear/DequantizeLinear | 需要量化时使用 |
| 11 | Resize 支持 4 种模式,新增 ScatterND | 我推荐的首选版本 |
| 12 | 新增 ReduceL1/ReduceL2,完善 Einsum | 需要复杂数学运算时 |
| 13 | 支持 Softmax 的 axis 参数,新增 If 算子 | 有控制流需求时 |
| 15 | 新增 Shape 算子变体,优化内存 | 最新推理引擎 |
避坑指南:我曾经在导出 YOLOv5 时用了 opset=15,结果部署到 Jetson Xavier 上的 TensorRT 8.0 时,Resize 算子不兼容。后来降到 opset=11 才解决。所以,不是版本越高越好,要看目标平台的 ONNX 解析器支持到什么版本。
使用 Netron 可视化模型
模型导出后,第一件事是什么?我建议你先别急着跑推理,打开 Netron 看一眼。
Netron 是一个轻量级的模型可视化工具,支持 ONNX、TensorFlow、PyTorch 等多种格式。你只需要把 .onnx 文件拖进去,就能看到完整的计算图结构。
看什么?我一般关注这几点:
- 输入输出节点:名字对不对?shape 对不对?
- 算子分布:有没有不认识的算子?有没有多余的 reshape/transpose?
- 网络结构:和原始模型是否一致?有没有被框架自动优化掉某些层?
举个例子,YOLOv5 导出后,你会在 Netron 里看到类似这样的结构:
input (float32[1,3,640,640])
└─ Conv_0
└─ BatchNorm_0
└─ SiLU_0
└─ Conv_1
└─ ...
└─ output (float32[1,25200,85])
如果发现某个算子的输入输出 shape 和你预期的不一样,那就要回去检查导出代码了。
个人习惯:我会在 Netron 里把整个网络截图保存,和原始 PyTorch 模型的结构图做对比。这样能快速定位导出过程中有没有丢失层或者多出奇怪的节点。
本章知识体系总览
下面这张图概括了 ONNX 作为中间表示的核心逻辑:
版本兼容性检查清单
每次导出 ONNX 模型前,我建议你过一遍这个清单:
- 目标平台支持的最高 opset 版本是多少?——去查推理引擎的官方文档
- 模型里有没有自定义算子?——如果有,需要注册到 ONNX 的算子库
- 动态 shape 是否开启?——YOLO 模型一般需要动态 batch 或动态输入尺寸
- 导出后有没有用 Netron 验证?——这一步不能省
我曾经踩过的坑:有一次导出 YOLOv8 时,模型里用了 torch.stack,这个算子在 ONNX 里会被拆成多个 Gather + Concat。结果在 TensorRT 上跑的时候,显存占用直接翻倍。后来我手动改成了 cat + unsqueeze,才把内存降下来。所以,导出后一定要看 Netron 里的实际算子分布,别只看原始代码。