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多个节点。当时我以为是模型太大,后来一查,发现很多是 ShapeGather 这种动态形状操作。这就是后面要讲的动态形状处理的前奏。

你可以用下面这段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。下一章,我们开始动手处理动态形状,那才是真正的硬仗。