2. ONNX模型结构解析:ONNX Protobuf格式、Graph与Node、ValueInfo与TensorProto

好,咱们进入第二讲。上一章我们聊了ONNX的生态和基本概念,这一章得动真格的了——直接拆开ONNX模型,看看里面到底长什么样。

说实话,我第一次接触ONNX模型结构时,第一反应是:这不就是个Protobuf吗?嗯,确实如此。但真正深入之后才发现,里面的门道比想象中多得多。今天我就带你一层层剥开它。

2.1 ONNX的Protobuf格式

ONNX模型本质上是一个序列化的Protobuf文件。你想想看,跨框架、跨语言、跨平台,什么格式能担此重任?Protobuf天生就是干这个的。

我个人习惯把ONNX模型比作一个「集装箱」:

  • 最外层:ModelProto,相当于集装箱本身
  • 第二层:GraphProto,相当于集装箱里的货物布局
  • 第三层:NodeProto、TensorProto、ValueInfoProto,相当于一个个货物和标签

咱们来看一个最简的ONNX模型结构定义:

// 这是简化后的伪代码,方便理解
message ModelProto {
  int64 ir_version = 1;        // ONNX IR版本
  string producer_name = 2;    // 谁生成的(比如pytorch)
  GraphProto graph = 7;        // 核心:计算图
  // ... 还有其他字段
}

message GraphProto {
  repeated NodeProto node = 1;           // 所有算子节点
  repeated ValueInfoProto input = 2;     // 输入信息
  repeated ValueInfoProto output = 3;    // 输出信息
  repeated TensorProto initializer = 5;  // 常量/权重
  // ...
}

关键点:ModelProto 里最核心的就是 graph 字段。你拿到一个.onnx文件,99%的时间都在跟GraphProto打交道。

我在项目中遇到过一个问题:有个同事导出的ONNX模型死活加载不了,报错说ir_version不匹配。后来发现是他用的旧版本ONNX导出,但推理引擎要求新版本。嗯,版本兼容性是个坑,后面我们会专门讲。

2.2 Graph与Node:计算图的骨架

GraphProto 就是整个计算图。它由三部分组成:

  1. node:算子节点列表,定义了计算逻辑
  2. input / output:图的输入输出描述
  3. initializer:常量数据(权重、偏置等)

每个 NodeProto 长这样:

message NodeProto {
  repeated string input = 1;    // 输入张量名称列表
  repeated string output = 2;   // 输出张量名称列表
  string name = 3;              // 节点名称(可选)
  string op_type = 4;           // 算子类型,比如 "Conv", "Relu"
  string domain = 7;            // 所属域,自定义算子用 "" 或 "ai.onnx"
  repeated AttributeProto attribute = 5;  // 算子属性
}

你想想看,一个神经网络模型,其实就是一堆Node串起来。每个Node吃几个输入,吐几个输出,中间经过op_type指定的运算。

举个例子,一个简单的Conv+Relu结构:

// 伪代码展示Graph中的Node关系
Node 1: op_type="Conv", input=["input", "conv_weight", "conv_bias"], output=["conv_out"]
Node 2: op_type="Relu", input=["conv_out"], output=["relu_out"]

这里有个细节要注意:Node的input/output是字符串名称,不是实际数据。这些名称就像「线」,把各个Node连接起来。ONNX通过名称匹配来建立数据流关系。

我的小技巧:调试自定义算子时,我习惯先打印出Graph中所有Node的op_type和input/output名称。这样一眼就能看出计算图的结构对不对。用 onnx.load() 加载模型后,直接 print(model.graph.node) 就行。

2.3 ValueInfoProto:张量的「身份证」

ValueInfoProto 描述的是张量的元信息。说白了,就是告诉框架:这个张量叫什么名字、是什么数据类型、形状是多少。

message ValueInfoProto {
  string name = 1;              // 张量名称
  TypeProto type = 2;           // 类型信息
}

message TypeProto {
  message Tensor {
    int32 elem_type = 1;        // 数据类型(1=float, 7=int64...)
    TensorShapeProto shape = 2; // 形状
  }
  Tensor tensor_type = 1;
}

GraphProto 的 input 和 output 字段就是 ValueInfoProto 的列表。它们定义了模型的「接口」——输入长什么样,输出长什么样。

我曾经踩过一个坑:导出的ONNX模型,输入形状是动态的(比如batch size是-1),但推理引擎不支持动态形状。结果推理时直接崩了。所以,ValueInfoProto里的shape信息一定要跟推理引擎的能力匹配

2.4 TensorProto:真正的数据载体

TensorProto 存储的是实际数据——权重、偏置、常量等。它跟 ValueInfoProto 不同:ValueInfoProto 只是描述,TensorProto 是实实在在的数据。

message TensorProto {
  string name = 1;              // 张量名称
  int32 data_type = 2;          // 数据类型
  repeated int64 dims = 3;      // 形状
  // 数据存储方式有多种,比如:
  repeated float float_data = 4;    // float类型数据(小模型用)
  bytes raw_data = 9;               // 原始字节数据(大模型用,更高效)
  // ...
}

这里有个性能相关的知识点:raw_data 比 float_data 更高效。因为 raw_data 是连续内存块,反序列化时直接memcpy就行。而 float_data 是repeated字段,每个元素都要单独解析。

注意:TensorProto 存储在 GraphProto 的 initializer 字段中。这些数据是图的「常量」部分,不会在推理过程中改变。如果你自定义算子需要用到常量,记得把它加到 initializer 里。

我记得有一次调试自定义算子,发现权重数据死活不对。打印出来一看,全是0。后来才发现,我把TensorProto放到了Node的attribute里,而不是initializer里。嗯,位置放错了,数据就丢了。

2.5 四者关系总结

咱们用一张表来理清这四个核心概念的关系:

概念 作用 存储位置 典型内容
ModelProto 模型最外层容器 .onnx文件根 ir_version, producer, graph
GraphProto 计算图主体 model.graph node, input, output, initializer
NodeProto 单个算子节点 graph.node列表 op_type, input, output, attribute
ValueInfoProto 张量元信息 graph.input / graph.output name, shape, dtype
TensorProto 实际数据 graph.initializer 权重、偏置等常量

你想想看,整个ONNX模型的结构其实就这五样东西。搞懂了它们,你就掌握了ONNX模型的「骨架」和「血肉」。

2.6 实战小练习:用Python查看模型结构

光说不练假把式。咱们写几行代码,实际看看一个ONNX模型内部长什么样:

import onnx

# 加载模型
model = onnx.load("your_model.onnx")
graph = model.graph

# 查看图基本信息
print(f"IR版本: {model.ir_version}")
print(f"生产者: {model.producer_name}")
print(f"节点数: {len(graph.node)}")
print(f"输入数: {len(graph.input)}")
print(f"输出数: {len(graph.output)}")
print(f"常量数: {len(graph.initializer)}")

# 查看所有算子类型
op_types = set()
for node in graph.node:
    op_types.add(node.op_type)
print(f"算子类型: {sorted(op_types)}")

# 查看第一个输入的信息
input_0 = graph.input[0]
print(f"输入名称: {input_0.name}")
print(f"输入形状: {[d.dim_value for d in input_0.type.tensor_type.shape.dim]}")
print(f"输入类型: {input_0.type.tensor_type.elem_type}")

我的建议:刚开始接触ONNX时,多跑跑这段代码。把各种模型(ResNet、BERT、YOLO)都加载一遍,看看它们的结构差异。看得多了,你自然就能一眼看出模型结构是否合理。

好了,这一章的内容就到这儿。我们拆解了ONNX模型的Protobuf结构,搞清楚了Graph、Node、ValueInfo、TensorProto各自的作用和关系。下一章,我们会基于这些知识,开始动手写自定义算子的注册代码。

记住一句话:理解结构是注册算子的前提。你连模型怎么组织的都不知道,怎么往里面塞自定义算子呢?