2、ONNX模型结构解析:理解ONNX的protobuf结构、Graph、Node、Tensor与ValueInfo
好,咱们进入正题。上一章我们聊了ONNX的来龙去脉,这一章,我带你亲手拆开一个ONNX模型,看看里面到底长什么样。
说实话,我第一次接触ONNX的时候,也是懵的。一个 .onnx 文件,黑盒一样。后来我养成了一个习惯——拿到任何模型,第一件事就是把它拆开看看结构。你想想看,连模型长什么样都不知道,你怎么敢往生产环境里丢?
2.1 ONNX的protobuf结构:一切的基础
ONNX模型本质上是一个 protobuf(Protocol Buffers) 序列化文件。说白了,就是Google搞的一种高效数据交换格式,比XML、JSON小得多,解析也快。
我个人习惯用 protoc 命令来查看ONNX的proto定义。你可以在ONNX官方仓库找到 onnx.proto,核心结构就这几层:
// 简化后的核心结构
message ModelProto {
int64 ir_version = 1; // ONNX IR版本号
repeated OperatorSetIdProto opset_import = 2; // 算子集
string producer_name = 3; // 生成工具名(比如pytorch)
string producer_version = 4; // 生成工具版本
string domain = 5; // 域名
GraphProto graph = 7; // 核心:计算图
}
message GraphProto {
repeated NodeProto node = 1; // 所有算子节点
repeated TensorProto initializer = 5; // 常量权重
repeated ValueInfoProto input = 2; // 输入信息
repeated ValueInfoProto output = 3; // 输出信息
repeated ValueInfoProto value_info = 4; // 中间张量信息
}
message NodeProto {
string name = 1; // 节点名
string op_type = 2; // 算子类型(如Conv、Relu)
repeated string input = 3; // 输入张量名列表
repeated string output = 4; // 输出张量名列表
repeated AttributeProto attribute = 5; // 算子属性
}
嗯,这里要注意:ModelProto 是顶层容器,里面装着一个 GraphProto。GraphProto 才是真正的计算图本体。
核心要点:ONNX模型 = 元信息(ModelProto)+ 计算图(GraphProto)。计算图里,节点(Node)描述计算,初始化器(initializer)存权重,ValueInfo 描述张量形状。
2.2 Graph:计算图的骨架
GraphProto 是整个ONNX模型的心脏。它由三部分组成:
- 节点列表(node):所有算子的有序集合
- 初始化器(initializer):常量权重,比如卷积核、BN的gamma/beta
- 值信息(input / output / value_info):描述每个张量的名字、数据类型、形状
我在项目中遇到过一个问题:用PyTorch导出的模型,Graph里居然有2000多个节点。当时我以为是模型太大,后来一查,发现很多是 Shape 和 Gather 这种动态形状操作。这就是后面要讲的动态形状处理的前奏。
你可以用下面这段Python代码,快速查看一个ONNX模型的Graph结构:
import onnx
model = onnx.load("model.onnx")
graph = model.graph
print(f"节点数量: {len(graph.node)}")
print(f"初始化器数量: {len(graph.initializer)}")
print(f"输入数量: {len(graph.input)}")
print(f"输出数量: {len(graph.output)}")
# 打印前5个节点
for i, node in enumerate(graph.node[:5]):
print(f"节点{i}: op_type={node.op_type}, name={node.name}")
2.3 Node:算子的具体实现
每个 NodeProto 代表一个算子操作。比如 Conv、Relu、Add、MatMul 等等。Node 的核心字段就三个:
| 字段 | 说明 | 示例 |
|---|---|---|
| op_type | 算子类型 | "Conv", "Relu", "Reshape" |
| input | 输入张量名列表 | ["input.1", "conv.weight", "conv.bias"] |
| output | 输出张量名列表 | ["output.1"] |
你想想看,一个卷积节点,输入有三个:特征图、权重、偏置。输出只有一个:卷积后的特征图。这些名字在Graph里是全局唯一的,用来连接不同的节点。
小技巧:我习惯用 onnx.numpy_helper.to_array() 把初始化器里的权重转成numpy数组,直接打印形状和数值,排查模型导出问题特别快。
2.4 Tensor与ValueInfo:张量的身份证
TensorProto 是ONNX里存储实际数据的地方。权重、偏置、常数,都存成 TensorProto。它包含:
- dims:形状,比如 [1, 3, 224, 224]
- data_type:数据类型,比如 FLOAT、INT64
- raw_data:原始二进制数据
而 ValueInfoProto 是张量的“身份证”——它只描述信息,不存数据。每个输入、输出、中间张量,都有一个 ValueInfoProto 来描述它的名字、类型和形状。
我曾经踩过一个坑:导出的ONNX模型,ValueInfo里某个中间张量的形状是 ["unk__1123"]。这是什么鬼?后来发现是PyTorch的动态形状没处理好,ONNX用了一个符号变量来表示未知维度。这就是动态形状问题的根源之一。
# 查看输入输出的形状信息
for input_info in graph.input:
print(f"输入: {input_info.name}")
shape = input_info.type.tensor_type.shape
for dim in shape.dim:
if dim.HasField("dim_value"):
print(f" 维度: {dim.dim_value}")
elif dim.HasField("dim_param"):
print(f" 动态维度: {dim.dim_param}")
注意:如果 ValueInfo 里的维度是 dim_param 而不是 dim_value,说明这个维度是动态的。后面章节我们会专门讲怎么处理这种情况。
2.5 实战:用Python解析ONNX模型结构
光说不练假把式。我建议你动手跑一下这段代码,看看你手头的ONNX模型长什么样:
import onnx
from onnx import helper, TensorProto
# 加载模型
model = onnx.load("your_model.onnx")
graph = model.graph
print("=== 模型元信息 ===")
print(f"IR版本: {model.ir_version}")
print(f"算子集: {[opset.domain + ':' + str(opset.version) for opset in model.opset_import]}")
print("\n=== 输入信息 ===")
for inp in graph.input:
print(f" {inp.name}: {inp.type.tensor_type.elem_type}")
shape_str = [str(d.dim_value) if d.HasField('dim_value') else f"dynamic({d.dim_param})"
for d in inp.type.tensor_type.shape.dim]
print(f" 形状: {', '.join(shape_str)}")
print("\n=== 输出信息 ===")
for out in graph.output:
print(f" {out.name}")
print("\n=== 前10个节点 ===")
for i, node in enumerate(graph.node[:10]):
print(f" [{i}] {node.op_type}: {node.input} -> {node.output}")
运行之后,你会看到类似这样的输出:
=== 模型元信息 ===
IR版本: 8
算子集: ai.onnx:17
=== 输入信息 ===
input.1: 1
形状: 1, 3, 224, 224
=== 输出信息 ===
output.1
=== 前10个节点 ===
[0] Conv: ['input.1', 'conv.weight', 'conv.bias'] -> ['conv_output']
[1] Relu: ['conv_output'] -> ['relu_output']
[2] MaxPool: ['relu_output'] -> ['pool_output']
看到没?整个计算图就是一张有向无环图。节点是算子,边是张量。每个张量都有名字,通过名字连接上下游。
2.6 避坑指南
最后,分享几个我踩过的坑:
- 初始化器不在节点列表里:权重是存在 initializer 里的,不是 node。别在 node 里找权重。
- ValueInfo 可能不完整:有些ONNX模型会省略中间张量的 ValueInfo,只保留输入输出。这时候你只能靠节点连接关系推断形状。
- 动态维度用 dim_param 表示:如果看到
"batch"、"unk__123"这种字符串,说明是动态形状。别慌,后面有办法处理。
嗯,这一章就到这里。你只要记住:ONNX模型 = protobuf + Graph + Node + Tensor。下一章,我们开始动手处理动态形状,那才是真正的硬仗。