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 就是整个计算图。它由三部分组成:
- node:算子节点列表,定义了计算逻辑
- input / output:图的输入输出描述
- 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各自的作用和关系。下一章,我们会基于这些知识,开始动手写自定义算子的注册代码。
记住一句话:理解结构是注册算子的前提。你连模型怎么组织的都不知道,怎么往里面塞自定义算子呢?