二、ONNX 基础与模型格式:从 IR 到 proto 的全面拆解
大家好,我是你们的讲师。今天我们来聊聊 ONNX 的基因——它的定义、结构,以及那些你迟早会碰上的细节。这部分是后面所有优化的基石,搞不清楚这些,后面可能会踩坑。
别急,我们一步步来。
2.1 ONNX 是什么?它为什么重要?
ONNX,全称 Open Neural Network Exchange。说白了,它就是一个开放的、跨框架的模型中间格式。你可以把它想象成深度学习世界的“通用语言”。
我个人习惯把 ONNX 比作“模型的二进制交换协议”。为什么这么说?因为它的设计思路和 protobuf 很像——都是一种序列化方案,只不过序列化的对象是神经网络的结构和权重。
目的只有一个:让你的模型从 PyTorch 训出来,转成 ONNX,然后扔到 TensorRT 上去跑。中间不用重写代码,不用手动对齐算子。
我记得早些年做项目,从 Caffe 转 PyTorch 简直要命,算子名对不上、维度顺序反了、权重读出来是反的……有了 ONNX 之后,至少有了一个标准接口。虽然坑依然不少,但至少方向清楚了。
它背后由微软、Facebook(Meta)、Amazon、华为等公司联合维护。目前生态已经非常成熟,几乎所有主流框架都支持导出或导入 ONNX。
2.2 模型格式的“骨架”:IR 与 proto 定义
ONNX 的模型文件(.onnx)本质上是一个protocol buffer(protobuf)序列化后的二进制文件。这个文件由 onnx.proto 文件定义,里面描述了一个深度学习模型的全部要素。
我建议你直接去看官方 repo 里的 onnx/onnx/onnx.proto3。这个文件就是整个 ONNX 世界的“宪法”。
它的核心结构是 IR(Intermediate Representation,中间表示)。IR 定义了一个图(Graph),图里有一堆节点(Node),节点之间通过张量(Tensor)连接。
// 简化后的核心结构
message ModelProto {
int64 ir_version = 1; // IR 版本号
repeated OperatorSetIdProto opset_import = 2; // 算子集引入
string producer_name = 3; // 导出工具名(比如 pytorch)
string producer_version = 4; // 导出工具版本
GraphProto graph = 7; // 核心:计算图
// ... 其他元数据
}
message GraphProto {
repeated NodeProto node = 1; // 图中的所有算子节点
repeated TensorProto initializer = 5; // 常量(权重、bias)
repeated ValueInfoProto input = 2; // 输入张量描述
repeated ValueInfoProto output = 3; // 输出张量描述
string name = 4;
}
message NodeProto {
repeated string input = 1; // 输入张量名列表
repeated string output = 2; // 输出张量名列表
string name = 3; // 节点名(可选)
string op_type = 4; // 算子类型(如 Conv、Relu)
string domain = 7; // 域(标准算子留空,自定义算子填 ai.onnx.contrib)
repeated AttributeProto attribute = 5; // 算子属性(如 kernel_shape、strides)
}
message TensorProto {
enum DataType { FLOAT = 1; UINT8 = 2; INT8 = 3; ... }
DataType data_type = 1;
repeated int64 dims = 2; // 形状
bytes raw_data = 5; // 原始权重数据(字节流)
}
我刚开始读这个 proto 时,觉得太啰嗦了。但后来发现,正是这种“啰嗦”保证了兼容性。每个字段都有版本号、都有清晰的定义,不同框架导出时不会因为字段缺失而崩溃。
2.3 模型结构:图、节点与张量的关系
一个 ONNX 模型,本质就是一个有向无环图。你可以用任何图可视化工具把它画出来,就是一堆框框(节点)和箭头(边)。
| 概念 | 对应 proto 类型 | 我理解的方式 |
|---|---|---|
| 图 (Graph) | GraphProto | 整个神经网络的所有计算步骤 |
| 节点 (Node) | NodeProto | 一个原子操作,比如卷积、激活、归一化 |
| 张量 (Tensor) | ValueInfoProto / TensorProto | 流动的数据,包括输入、输出、权重 |
我画了一张图,帮你理清它们之间的关系。
上面这张图你应该能看明白:
- 输入张量从左侧进入,流过算子节点,每一步都输出一个新的张量。
- 权重(initializer)是固化在 GraphProto 里的常量,比如卷积核的数值。它们在推理时不会改变。
- 每个节点只依赖它的输入张量,独立计算。这给后面的图优化(比如算子融合)提供了理论支撑。
2.4 常用算子与数据类型
ONNX 定义了一套标准算子集,放在 ai.onnx 域下。版本号就是 opset(算子集版本)。从 opset 7 到 opset 21,新增了很多算子,也废弃了一些老算子。
我挑几个你一定会碰到的算子说说。
| 算子类别 | 常用算子(op_type) | 我踩过的坑 |
|---|---|---|
| 张量操作 | Reshape, Transpose, Gather, Unsqueeze | Reshape 的 shape 参数必须是常量,否则转 TensorRT 会报错 |
| 数学运算 | Add, Mul, Gemm, MatMul | MatMul 的精度问题,fp16 下容易溢出 |
| 激活函数 | Relu, Sigmoid, Tanh, LeakyRelu | LeakyRelu 的 alpha 属性有些框架导出不一样 |
| 归一化 | BatchNormalization, InstanceNormalization | BN 在推理时一定要 freeze,不然会多出训练相关的分支 |
| 池化 | MaxPool, AveragePool | ceil_mode 参数默认不同框架不一致,导出前要检查 |
| 卷积 | Conv, ConvTranspose | 分组卷积(group)的导出优化很棘手 |
| 形状推理 | Shape, Size, Slice, Tile | 动态形状下,这些算子很难完全静态化 |
至于数据类型,ONNX 支持常见的浮点型和整型:
FLOAT(32位浮点,默认)FLOAT16(16位浮点,TensorRT 的 FP16 推理要用)DOUBLE(64位浮点,很少用,推理时太慢)INT8/UINT8(量化必备)INT64(主要用于形状参数,比如 Reshape 的 target shape)BOOL(用于条件判断)
2.5 算子集的版本演进:你不能忽视的兼容性
ONNX 的算子集(opset)版本号决定了你的模型能被哪些运行时引擎加载。opset 7(2018 年)和 opset 21(2024 年)之间差了很多东西。
举几个例子:
- opset 9:引入了
Identity算子,看起来没用,但导出时经常被当做占位符。 - opset 11:强化了
ScatterElements、BatchNormalization的定义,修复了很多歧义。 - opset 15:增加了
DynamicQuantizeLinear,开始支持动态量化。 - opset 18:引入了
GroupNormalization、LayerNormalization等现代算子。
我曾经在项目里遇到过一个 bug:用 PyTorch 1.12 导出的 ONNX 模型(opset 13),在 TensorRT 8.2 上跑不通,报错说 BatchNormalization 算子不支持。后来查到,TensorRT 8.2 只支持 opset 11 的 BN 实现。最后还是降级 opset 解决的。
学习 ONNX 基础,你不需要背下所有的 proto 字段。但你必须理解:
- 一个 .onnx 文件里,图、节点、张量是怎么组织的。
- 算子的版本和兼容性是怎么影响你的推理流程的。
- 那些你常常忽略的细节(比如数据类型、形状来源)可能成为未来优化的瓶颈。
嗯,这部分就讲到这里。下一小节,我们会深入 ONNX 的导出实践,手把手带你从 PyTorch 导出一个干净的 ONNX 模型。做好准备,我们马上动手。