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

我个人习惯把导出流程分成三步:

  1. 准备模型:把训练好的模型切换到 eval 模式,关掉 dropout 和 batch norm 的 training 状态
  2. 构造 dummy 输入:给一个和真实输入 shape 一致的张量,ONNX 导出时需要走一次前向推理来记录计算图
  3. 调用导出 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 中间表示核心流程 PyTorch / TF 训练框架 torch.onnx.export 导出 API model.onnx 中间表示 Netron 可视化 TensorRT ONNX Runtime OpenVINO CoreML / NCNN 关键说明: 1. 训练框架导出 ONNX 时,opset 版本决定可用算子范围 2. Netron 可视化用于验证导出结构是否正确 3. 不同推理引擎对 ONNX 算子的支持程度不同,需要针对性适配

版本兼容性检查清单

每次导出 ONNX 模型前,我建议你过一遍这个清单:

  • 目标平台支持的最高 opset 版本是多少?——去查推理引擎的官方文档
  • 模型里有没有自定义算子?——如果有,需要注册到 ONNX 的算子库
  • 动态 shape 是否开启?——YOLO 模型一般需要动态 batch 或动态输入尺寸
  • 导出后有没有用 Netron 验证?——这一步不能省

我曾经踩过的坑:有一次导出 YOLOv8 时,模型里用了 torch.stack,这个算子在 ONNX 里会被拆成多个 Gather + Concat。结果在 TensorRT 上跑的时候,显存占用直接翻倍。后来我手动改成了 cat + unsqueeze,才把内存降下来。所以,导出后一定要看 Netron 里的实际算子分布,别只看原始代码。