第 18 篇 · 第四部分 · 压缩与工程落地
ONNX:模型的"通用翻译器"
训练用一套框架、部署用另一套环境,两边语言不通。ONNX 就是它们之间的翻译。
一、一个真实的痛点:训练框架和部署环境不匹配
你在 PyTorch 里把模型训练完,经过一番排查,指标也调到了满意的水平。准备上线时,目标却是另一套环境:服务器上运行着 TensorRT,手机端用 TFLite,摄像头盒子可能只有一台 C++ 工控机。它们的共同点是——都无法识别 torch.nn.Module。
理论上你可以把 PyTorch 整体部署到端侧。可它体积不小,依赖一整套 Python 生态,很多嵌入式设备根本无法安装。你也不能每次都重写一遍推理前向代码,参数量上亿的模型,手写一遍相当于从头重新实现,还容易出错——抄错一层结构就可能酿成难以察觉的事故:线上输出出现细微偏移,却难以排查。
这就是 ONNX 要解决的麻烦:把"模型"和"训练框架"解绑,让人取到一份与框架无关的模型文件,任何支持 ONNX 的运行时都能读。
二、ONNX 是什么:一份可执行的计算图规范
ONNX 全称 Open Neural Network Exchange,开放神经网络交换格式。名字里"交换"两个字是关键——它不负责训练,也不负责推理,它是两边中间那个翻译官。翻译腔并不少见,它做的事就是把一种语言的意思,如实转成另一种语言能懂的句子。
具体来说,ONNX 定义了一套中间表示(IR):模型被存成一张计算图,加上一组元数据(输入输出的名字、形状、类型)。图里没有 Python 代码,也没有 Python 里的 if、for 分支,只有算子节点和数据张量。它是静态的、可序列化的,存成 .onnx 文件,用 protobuf 编码。
为什么非要做成静态图?因为一旦前向流程变成一张没有分支的图,各种后端就能基于它做优化:合并算子、重排内存、往特定硬件上映射。这种优化的前提是"看得见全部结构",动态的 Python 代码做不到这一点。
还有个容易被忽略的点:.onnx 文件是自包含的。图结构、权重、再加上一段可选的元信息(导出工具、版本号、作者)全部打包进同一个文件,不依赖任何外部配置。你把这个文件复制到另一台机器上,运行时可以直接读取,无需再配置大量环境变量。
三、图的组成:节点、算子、张量、初始化器
一张 ONNX 图拆开来看,只有四个核心概念。
- 张量(Tensor):数据本身,有形状和类型,比如
[1, 3, 224, 224]的 float32。 - 节点(Node):一次运算,比如 Conv、MatMul、Relu。节点有输入名、输出名、算子类型和属性。
- 算子(Operator):节点所做运算的定义,相当于算子清单里的一条。所有算子由 ONNX 标准统一规定行为。
- 初始化器(Initializer):训练学出来的权重和偏置,直接嵌在文件里,当常量用。
一张图就是大量节点连接起来,边的名字对应张量名,数据从输入流到输出。用 Netron 打开一个 .onnx,你可以像浏览流程图一样逐一点开各个方块,看每层的形状怎么变化。这个办法在排查转换错误时特别好用——哪一层形状突然不一致,一眼就能发现。
四、opset 版本:为什么升级转换器有时会导致转换出错
ONNX 有个版本号叫 opset,表示"这一版定义了多少算子、它们的语义是什么"。每过一段时间,标准会新增算子,也会对旧算子的定义作出修订。
问题正源于"修正"。某些算子在早期 opset 里对边界情况的行为没有明确,后来才补充规定。你用新版导出器指定低 opset,或者反过来,就可能碰上语义不一致:导出报错,或者更隐蔽的情形——不报错却计算出错。后者危害更大,因为你的测试集也许恰好没覆盖到那个边界。
所以导出时一般要显式指定 opset,常见的选择集中在 11 到 17 之间,较新的导出器也会用到更高的版本。选哪个不是越高越好:得看目标运行时的支持表。你的边缘设备上的推理库也许只支持到 opset 12,你导出成 17,它便无法读取。
经验之谈:导出前先查目标运行时文档支持的最大 opset,然后按它的上限来选,不要一味追求最高版本。
五、动态轴:batch 和序列长度怎么做到可变
训练时 batch size 可能是 32,上线后每个请求只处理一条,序列长度更是长短不一。如果导出的图把 batch 固定为 32,真到线上推理时要么报维度不匹配,要么为了凑数白白做了大量计算。
ONNX 的解法是动态轴:把某个维度的形状标成符号名(比如 'batch'、'seq_len'),运行时按实际输入决定。导出 PyTorch 模型时靠 dynamic_axes 参数指定,大致长这样:
torch.onnx.export(
model, dummy_input, "model.onnx",
input_names=["input_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "seq_len"},
"logits": {0: "batch", 1: "seq_len"},
},
opset_version=14,
)
标了动态轴,一个文件就能处理各种长度的输入。代价是运行时的优化会受限:有些硬件特别擅长针对固定形状做预编译,一旦设为动态,其优化能力就会受限。所以生产上常见的折中是"只让 batch 动态,序列长度固定成几个档位",为每个档位分别导出一份,随需选用。
符号名的命名也有讲究。同一个 batch 维在图里出现的地方,符号名必须一致,否则运行时无法还原出正确的形状;给输出也标上同名符号,模型才算真正把"动态"这一信息表达清楚。
六、从 PyTorch 导出 ONNX 的典型流程和常见报错
流程本身不复杂,麻烦主要在于排错。典型三步:让模型进入 eval 模式、构造一个形状正确的示例输入、调用 export。
model.eval()
dummy = torch.randn(1, 3, 224, 224)
torch.onnx.export(model, dummy, "model.onnx", opset_version=12)
# 校验一遍,看图和运行时的行为是否一致
onnx.checker.check_model("model.onnx")
常见的报错有这么几类:
- 不支持的算子。 若使用了自定义算子,或用了新版本才有的操作,导出器无法识别。需要改写成等价实现,或者去注册自定义算子。
- 没进 eval 模式。 Dropout、BatchNorm 在训练和推理下行为不同,若没有调用
model.eval(),导出的图会包含训练态的逻辑,结果不一致。 - 控制流。 前向里包含
if和for,而且判断条件跟输入数据相关,静态图无法表达。需要用torch.jit.script包裹一层,或把逻辑移出模型。 - 形状不一致。 示例输入的形状必须与模型实际要求完全一致,少一维、多一维都会在导出过程中报错中断。
七、ONNX 不等于自动变快(图优化与算子融合)
有个误解需要先澄清:导出成 ONNX,本身不会让模型变快。文件只是换了一种存储方式,算子还是那些算子,计算量没有丝毫减少。
快,是从"运行时基于这张图做优化"开始的。最典型的是算子融合:Conv → BatchNorm → Relu 三个节点,BatchNorm 在推理时可以整体并入卷积的权重和偏置里,变成一次带新参数的 Conv 再接一个 Relu。三个算子合成两个,节省了两次内存读写,在内存带宽紧张的端侧,这种收益相当可观。
还有常量折叠(把可以提前计算的部分提前算出)、死代码消除(删掉输出用不到的节点)、布局转换(NHWC 换 NCHW 之类)。这些优化在运行时里由不同级别的图优化开关控制,级别越高,融合越激进。但不必迷信最高级别:某些优化在个别模型上可能计算出错,需要用测试集兜底验证。
举一个具体例子:一个残差块里有 Conv、BatchNorm、Relu,再加一条带 Add 的跳跃连接,原始图可能是六七个节点,融合之后往往能减到两三个。节点数量一旦减少,运行时调度和内存分配的开销也随之下降。
八、ONNX Runtime、TensorRT、OpenVINO 各占什么位置
ONNX 是格式,不是引擎。真正执行这张图的是"运行时",它读 .onnx,决定在什么硬件上用什么内核执行。常见的几个:
- ONNX Runtime(ORT):微软维护的通用运行时,跨平台支持最广。它在内部用"执行提供器"(Execution Provider,EP)这种机制对接不同硬件——CPU 上用 MLAS,NVIDIA GPU 上用 CUDA EP,Intel 上还有 OpenVINO EP。
- TensorRT:NVIDIA 的推理引擎,专为自有 GPU 优化,能针对具体显卡把算子重新编译成最优内核,在 GPU 上通常是最快的选择之一。
- OpenVINO:Intel 推出的,主要面向自有的 CPU、核显和神经计算棒(VPU),在 x86 和边缘 Intel 设备上表现良好。
它们并非彼此排斥的对手,可以叠加使用。ORT 内部就可以接入 TensorRT EP 或 OpenVINO EP,让你用一套 API 适配多种硬件。
九、什么时候不该用 ONNX
ONNX 并非必需,有些场景它反而带来麻烦。
还在快速迭代的研究阶段:模型结构改动频繁,每次都需要随之调整导出、修正算子,导出这一环的成本比收益高。直接在原框架中运行最为省事。
只有 PyTorch 环境、又是小模型:一个几十 MB 的模型本来就能够运行,强行增加一层 ONNX 只是多一份文件,也多一处可能出错的地方。
依赖高度自定义算子:模型里依赖大量自研 CUDA 算子,ONNX 标准中并不包含,导出只会增加风险。
换句话说,ONNX 的价值在异构部署:当你需要把同一个模型部署到多种硬件、多种语言上时,它所说的"一次导出、到处运行"才真正体现价值。下一篇接着讨论,存模型这件事到底有多少种文件格式,各自适合什么场景。
参考资料
- ONNX 官方网站. onnx.ai(格式规范与整体介绍)
- onnx/onnx 开源仓库. github.com/onnx/onnx(IR 定义与算子清单)
- PyTorch 官方文档, torch.onnx. pytorch.org/docs/stable/onnx.html(导出参数与示例)
- ONNX Runtime 官方站点. onnxruntime.ai(运行时与执行提供器)
- ONNX 算子文档. onnx.ai/onnx/operators(逐个算子查语义)
- Intel OpenVINO 文档. docs.openvino.ai(Intel 平台的部署路径)