3、ONNX模型结构解析:protobuf结构、节点、张量与图
说实话,刚接触ONNX那会儿,我也觉得它就是个模型格式转换器。直到有一次,我在NPU上部署一个YOLOv5模型,死活跑不出正确结果。折腾了两天,最后发现是ONNX图结构里多了一个没用的Identity节点。嗯,从那以后,我就老老实实把ONNX的底层结构啃了一遍。
今天咱们就来聊聊ONNX模型到底长什么样。说白了,它就是个用protobuf序列化好的计算图。
3.1 ONNX的protobuf结构
ONNX用的是Google的protobuf来做数据序列化。你想想看,一个深度学习模型动辄几百兆,如果用JSON或者XML存,那解析速度简直不敢想。protobuf的好处就是:体积小、解析快、跨语言。
我个人习惯用onnx.load()加载模型后,直接打印看看它的顶层结构:
import onnx
model = onnx.load("model.onnx")
print(model.ir_version) # IR版本号
print(model.producer_name) # 生成这个模型的框架
print(model.graph) # 核心:计算图
这里有个小细节——ir_version。我在项目中遇到过,有些老NPU工具链只支持IR版本6以下的ONNX模型。如果你用新版本的PyTorch导出的ONNX(IR版本8或9),直接扔给老工具链就会报错。解决办法是手动降级IR版本,或者用onnx.version_converter做转换。
核心要点:ONNX的protobuf结构最外层是ModelProto,里面包含一个GraphProto。GraphProto才是真正的计算图本体。
3.2 节点(Node)—— 计算的基本单元
节点是什么?说白了,就是计算图中的一个个操作。比如Conv、Relu、Add、MatMul,这些都是节点。
每个节点包含三个关键信息:
- 输入张量名:这个操作要吃哪些数据
- 输出张量名:这个操作会吐出什么数据
- 算子类型和属性:比如Conv的kernel_shape、strides、pads
看个实际例子:
for node in model.graph.node:
print(f"节点名: {node.name}")
print(f"算子类型: {node.op_type}")
print(f"输入: {node.input}")
print(f"输出: {node.output}")
print("---")
输出大概长这样:
节点名: Conv_0
算子类型: Conv
输入: ['input', 'conv1.weight', 'conv1.bias']
输出: ['conv1_output']
---
节点名: Relu_1
算子类型: Relu
输入: ['conv1_output']
输出: ['relu1_output']
你看,节点之间通过张量名连接起来。Conv_0的输出conv1_output,正好是Relu_1的输入。这就是计算图的本质——数据流动的路径。
避坑指南:我曾经在调试一个量化模型时,发现NPU推理结果全是0。排查了半天,原来是ONNX里有个Reshape节点的shape属性写死了,输入张量维度变化后reshape就错了。记住:节点属性是静态的,但输入张量的形状可能是动态的。
3.3 张量(Tensor)—— 数据的载体
张量就是ONNX里的数据本体。权重、偏置、中间特征图,统统都是张量。
ONNX里的张量有两种存在形式:
- 初始化张量(Initializer):存在模型文件里的固定数据,比如卷积层的权重
- 计算时张量(ValueInfo):只在推理时动态生成,比如每层的输出特征图
看代码:
# 查看所有初始化张量(权重)
for init in model.graph.initializer:
print(f"张量名: {init.name}")
print(f"形状: {init.dims}")
print(f"数据类型: {init.data_type}")
# 实际数据存在 raw_data 或对应的类型字段里
print("---")
# 查看输入输出张量的形状信息
for input_info in model.graph.input:
print(f"输入: {input_info.name}, 形状: {input_info.type.tensor_type.shape}")
这里有个坑——数据类型。ONNX用整数表示数据类型,比如1是float32,6是int32,9是bool。我遇到过NPU不支持float16(数据类型10)的情况,推理直接崩了。所以拿到模型后,第一件事就是检查所有张量的数据类型。
注意:ONNX张量的数据存储方式有两种:一种是直接存数值(比如float_data、int64_data),另一种是存成bytes(raw_data)。raw_data更省空间,但解析时要注意字节序。我踩过这个坑——在x86上导出的模型,放到ARM的NPU上解析,raw_data的字节序反了,权重全乱套。
3.4 图(Graph)—— 把一切串起来
图就是节点、张量、初始化器的集合体。它定义了整个计算流程。
一个完整的GraphProto包含:
| 组成部分 | 说明 | 我常用的调试方法 |
|---|---|---|
| node | 所有计算节点(列表) | 遍历检查每个节点的op_type |
| initializer | 所有权重张量(列表) | 检查权重形状是否和节点匹配 |
| input | 模型输入(列表) | 确认输入名称和形状 |
| output | 模型输出(列表) | 确认输出名称和形状 |
| value_info | 中间张量的形状信息(列表) | 用于检查张量流动是否正常 |
我个人习惯用onnx.shape_inference.infer_shapes()来补全value_info。为什么?因为很多模型导出时,中间张量的形状信息是缺失的。NPU编译器需要这些信息来做内存分配,缺了就会报错。
from onnx import shape_inference
model = shape_inference.infer_shapes(model)
# 现在每个中间张量都有形状信息了
for v in model.graph.value_info:
print(v.name, v.type.tensor_type.shape)
3.5 实战:手撕一个ONNX模型
光说不练假把式。咱们写个脚本,把ONNX模型的结构可视化出来:
import onnx
from onnx import helper
model = onnx.load("model.onnx")
graph = model.graph
print(f"模型IR版本: {model.ir_version}")
print(f"算子集版本: {model.opset_import[0].version}")
print(f"节点总数: {len(graph.node)}")
print(f"权重张量数: {len(graph.initializer)}")
print()
# 打印计算图拓扑
print("计算图拓扑(简化版):")
for i, node in enumerate(graph.node):
# 跳过一些无聊的节点
if node.op_type in ["Identity", "Cast"]:
continue
in_str = ", ".join(node.input[:2]) # 只显示前两个输入
out_str = node.output[0]
print(f" [{i:3d}] {node.op_type:10s} {in_str:30s} -> {out_str}")
这个脚本我几乎每个项目都会用。它能快速帮你发现模型里有没有多余的节点。比如我之前遇到过一个模型,导出时自动插入了十几个Identity节点,NPU编译器处理不了,推理速度直接掉了30%。删掉这些冗余节点后,速度就正常了。
小技巧:用onnx.utils.extract_model()可以提取子图。我在做模型裁剪时经常用——只保留从某个输入到某个输出的路径,去掉无关的分支。这样NPU的编译时间能缩短不少。
3.6 总结一下
ONNX模型的结构其实不复杂:
- protobuf是它的序列化格式,高效但不太直观
- 节点是计算操作,每个节点有固定的算子类型和属性
- 张量是数据,分初始化和计算时两种
- 图是容器,把节点和张量组织成有向无环图
嗯,掌握了这些,你就能看懂ONNX模型的内部构造了。下一章咱们聊聊怎么用Python API来修改和优化ONNX图结构——这可是NPU适配的关键技能。