ONNX 模型格式与结构详解
好,咱们今天来聊聊 ONNX 模型文件里到底装了什么。说白了,一个 .onnx 文件不是黑盒子,它是一个有标准结构的二进制文件。我个人习惯在拆解模型时,先用工具把它“解剖”开,看看里面到底有哪些零件。
你可能会问,为什么非要了解这些底层结构?我在项目中遇到过一个情况:同事导出的 ONNX 模型在推理时总报“形状不匹配”的错误。我们花了半天时间排查,最后发现是输入张量的名称写错了。如果当时他清楚 ONNX 的输入输出定义方式,这个问题几分钟就能定位。
这一节,我们就来拆解 ONNX 的骨架。从 protobuf 的序列化方式,到 Graph、Node、ValueInfo、Initializer 这些核心概念,我都会讲清楚。
1. ONNX 的 protobuf 结构
ONNX 的底层是基于 Protocol Buffers(protobuf)的。protobuf 是 Google 开发的一种结构化数据序列化方式,它定义了一套接口描述语言(IDL)。
一个 .onnx 文件,本质上就是一个 protobuf 序列化后的二进制流。
小贴士:你可以用 protoc 命令查看 ONNX 的 protobuf 定义。
protoc --decode=onnx.ModelProto onnx.proto < your_model.onnx
它会输出模型的全部结构信息,非常直观。
ONNX 的顶层数据结构是 ModelProto。它包含几个关键字段:ir_version(IR 版本)、opset_import(算子集导入)、producer_name(生成该模型的框架名,比如 PyTorch)、graph(计算图本体)。
我把最核心的结构画了一张图,你看完就清楚了。
2. Graph 与 Node
ONNX 的计算图是一个 有向无环图(DAG)。图中的每个节点(Node)代表一个算子,比如 Conv、Relu、Add。节点之间的连接线代表张量(Tensor)的流动方向。
一个典型的 GraphProto 包含以下要素:
- Node:算子节点列表,每个节点有
input、output、op_type、attribute。 - Edge(隐式):通过节点的 input/output 名称隐式连接。
- ValueInfo:定义模型输入输出张量的元信息。
- Initializer:存放权重和偏置等常量张量。
看一个简化示例。假设我们有一个 Conv → Relu → Add 的小网络。
Node_0: op_type="Conv", input=["X", "W", "B"], output=["C"]
Node_1: op_type="Relu", input=["C"], output=["R"]
Node_2: op_type="Add", input=["R", "residual"], output=["Y"]
在这个图中,X 是输入张量,W 和 B 是卷积的权重和偏置,它们通常存放在 Initializer 里。residual 是另一个输入,Y 是最终输出。
3. ValueInfo 与 Initializer
这两个概念经常被混淆,我专门把它们放在一起对比。
ValueInfoProto 描述的是“张量的元信息”,比如形状(shape)、数据类型(dtype)。它不存储具体的数值。你可以把它理解为接口定义。
TensorProto(Initializer 里存放的类型)存储的是“具体的张量数值”。比如一个 3x3 的卷积核,它的所有浮点数值都存在这里。
| 对比项 | ValueInfoProto | TensorProto(Initializer) |
|---|---|---|
| 主要作用 | 描述张量的形状和类型 | 存储张量的具体数值 |
| 是否包含数值 | 否 | 是 |
| 常见位置 | 模型输入 / 输出定义 | 图中常量(权重、偏置) |
| 可学习性 | 不涉及 | 通常是固定常量 |
避坑指南
我曾在项目中遇到一个事故:导出的 ONNX 模型推理结果完全错误。追查后发现,是某个 BatchNorm 算子的 moving_mean 和 moving_var 没有被正确放在 Initializer 中,而是被当成了输入张量。这就导致推理时这些值没有被冻结,产生了随机数。
排查方法很简单:打印 Initializer 里的张量名列表,对比一下哪些应该存在但缺失了。
4. 输入输出定义
ONNX 模型的输入输出是通过 GraphProto 的 input 和 output 字段定义的。这两个字段的类型都是 ValueInfoProto 的数组。
换句话说,graph.input 里列出的张量,就是你在推理时需要喂数据的入口。而 graph.output 里列出的,就是推理结束后模型会返回的数据。
我看到一个新同学踩过这个坑——他导出一个 ONNX 模型后,发现输出竟然有 50 多个张量。这是因为 PyTorch 默认会导出所有中间变量作为输出。
正确做法:在导出时,明确指定输入和输出张量。
实战建议:我个人习惯在模型转换后,立即用 ONNX Runtime 跑一个假数据验证一下。
import onnxruntime as ort
import numpy as np
session = ort.InferenceSession("my_model.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name
# 制造一个符合输入形状的假数据
dummy_input = np.random.randn(1, 3, 224, 224).astype(np.float32)
result = session.run([output_name], {input_name: dummy_input})
print("推理成功,输出形状为:", result[0].shape)
你想想看,如果这一步跑通了,基本上就排除了 80% 的模型导出问题。
5. 一个完整的 ONNX 结构示例
我用 Python 的 onnx 库加载一个模型,看看它的内部结构。
import onnx
model = onnx.load("my_model.onnx")
print(model.ir_version) # IR 版本
print(model.opset_import) # 算子集信息
graph = model.graph
# 查看所有输入
for input in graph.input:
print("Input:", input.name, input.type.tensor_type.shape)
# 查看所有输出
for output in graph.output:
print("Output:", output.name, output.type.tensor_type.shape)
# 查看所有权重(Initializer)
for init in graph.initializer:
print("Initializer:", init.name, init.dims)
# 查看所有算子(Node)
for node in graph.node:
print("Node:", node.op_type, node.input, node.output)
这段代码会把你模型的“五脏六腑”都打印出来。
6. 关于动态形状
ONNX 支持动态形状(Dynamic Shape),也就是输入张量的某些维度可以是“未知”的。这在处理不同批大小(batch size)或不同长宽的输入时非常有用。
在 ValueInfoProto 中,如果某个维度的大小是 0 或 -1,就表示该维度是动态的。
Input: input, shape: (1, -1, 224, 224)
注意,这里 -1 表示第二个维度(比如通道数)是动态的。不过实际使用时,动态形状对 TensorRT 等推理引擎不太友好,会在推理时引入额外的编译开销。我建议你在部署阶段尽量固定形状,除非真的需要动态输入。
我的常用做法:先在 ONNX Runtime 上用动态形状验证功能正确性,等到部署推理引擎时,再用 onnx.shape_inference.infer_shapes 工具补全形状信息,或者直接在模型转换时指定固定形状。
from onnx import shape_inference
model = onnx.load("dynamic_model.onnx")
model = shape_inference.infer_shapes(model)
onnx.save(model, "inferred_model.onnx")
嗯,到这里,ONNX 模型的基本结构应该讲清楚了。你想想看,一个 .onnx 文件就是 ModelProto 包着一个 GraphProto,里面装着 Node、ValueInfo 和 Initializer。理解了这个骨架,后面我们讲模型转换、算子调优、内存优化的时候,你就能快速定位问题出在哪个环节。