3. ONNX模型结构解析
ONNX这玩意儿,说白了就是AI模型的"通用语言"。你训练好的PyTorch模型、TensorFlow模型,想部署到不同硬件上?得先转成ONNX。我刚开始接触ONNX时,总觉得它就是个黑盒子——输入输出一接,完事。直到有一次在嵌入式设备上跑YOLOv5,死活推理不对,才逼着我把它扒了个底朝天。
这一章,咱们就把ONNX模型拆开来看。它到底长什么样?节点、张量、图这些概念怎么理解?怎么用Netron可视化?搞懂了这些,后面做算子适配、模型优化才能心里有数。
3.1 ONNX的protobuf格式
ONNX文件本质上是一个protobuf序列化文件。protobuf是Google搞的一套数据交换格式,比XML、JSON紧凑得多。你想想看,一个YOLOv5s的ONNX模型也就几十MB,换成JSON可能得几百MB。
ONNX的protobuf定义在onnx.proto里。核心结构就三层:
| 层级 | 对应结构 | 作用 |
|---|---|---|
| 模型层 | ModelProto | 包含模型元信息、算子集版本、图结构 |
| 图层 | GraphProto | 包含所有节点、张量、输入输出定义 |
| 节点层 | NodeProto | 每个算子的具体定义 |
我习惯用Python直接解析ONNX文件,看看里面到底有啥:
import onnx
model = onnx.load("yolov5s.onnx")
print(f"IR版本: {model.ir_version}")
print(f"算子集版本: {model.opset_import[0].version}")
print(f"生产者: {model.producer_name}")
graph = model.graph
print(f"节点数: {len(graph.node)}")
print(f"张量数: {len(graph.initializer)}")
print(f"输入: {[i.name for i in graph.input]}")
print(f"输出: {[o.name for o in graph.output]}")
onnx.shape_inference.infer_shapes(model) 可以补全模型中缺失的张量形状信息。我在做YOLOv5转ONNX时,经常遇到某些中间节点的shape是未知的,跑一步shape inference就全出来了。
3.2 节点(Node)——模型的基本运算单元
ONNX里的节点,对应的是一个个算子。每个节点包含:
- op_type:算子类型,比如Conv、Relu、Add
- input:输入张量名称列表
- output:输出张量名称列表
- attribute:算子属性,比如卷积的kernel_shape、strides
- name:节点名称(可选)
举个例子,YOLOv5s里的一个卷积节点:
node {
input: "images"
input: "model.0.conv.weight"
input: "model.0.conv.bias"
output: "model.0.conv.output"
name: "Conv_0"
op_type: "Conv"
attribute {
name: "kernel_shape"
ints: 6
ints: 6
}
attribute {
name: "strides"
ints: 2
ints: 2
}
attribute {
name: "pads"
ints: 2
ints: 2
ints: 2
ints: 2
}
}
这里要注意,ONNX的卷积输入顺序是:输入特征图、权重、偏置。跟PyTorch的nn.Conv2d完全一致。但TensorFlow的卷积输入顺序是反的,所以做转换时得调一下。
3.3 张量(Tensor)——数据流动的载体
ONNX里的张量分两种:
- ValueInfoProto:描述张量的名称、数据类型、形状,用于输入输出定义
- TensorProto:包含实际数据,用于存储权重、偏置等常量
看个例子,YOLOv5s的输入张量定义:
input {
name: "images"
type {
tensor_type {
elem_type: 1 # FLOAT
shape {
dim { dim_value: 1 }
dim { dim_value: 3 }
dim { dim_value: 640 }
dim { dim_value: 640 }
}
}
}
}
数据类型用整数表示,我列个对照表:
| 类型值 | ONNX类型 | 对应Python类型 |
|---|---|---|
| 1 | FLOAT | numpy.float32 |
| 2 | UINT8 | numpy.uint8 |
| 3 | INT8 | numpy.int8 |
| 6 | INT32 | numpy.int32 |
| 7 | INT64 | numpy.int64 |
| 10 | FLOAT16 | numpy.float16 |
权重张量存在graph.initializer里。我经常直接读出来做量化分析:
import numpy as np
for init in graph.initializer:
if "conv.weight" in init.name:
weight = numpy_helper.to_array(init)
print(f"{init.name}: shape={weight.shape}, "
f"min={weight.min():.4f}, max={weight.max():.4f}")
3.4 图(Graph)——整个计算流程
ONNX的图结构,说白了就是一个有向无环图(DAG)。节点是算子,张量是边。数据从输入节点流入,经过一系列算子处理,最终从输出节点流出。
图的核心组成部分:
- input:模型输入,比如YOLOv5的"images"
- output:模型输出,比如YOLOv5的"output"
- node:所有算子节点,按执行顺序排列
- initializer:权重、偏置等常量张量
- value_info:中间张量的类型和形状信息
我画了一张ONNX图结构的示意图,帮你理解数据怎么流动:
遍历图结构,我常用这个代码片段来理解模型的计算流程:
for i, node in enumerate(graph.node):
inputs = ", ".join(node.input)
outputs = ", ".join(node.output)
print(f"[{i:3d}] {node.op_type:15s} "
f"in: {inputs:40s} "
f"out: {outputs}")
3.5 使用Netron可视化
Netron是我最常用的ONNX可视化工具。它能把模型的计算图画得清清楚楚。我个人习惯在模型转换后,第一件事就是用Netron打开看看:
- 检查结构:确认输入输出形状对不对
- 定位问题:如果推理报错,看哪个节点有问题
- 优化参考:看哪些算子可以合并或替换
Netron的使用方式:
- 在线版:打开
netron.app,直接拖入ONNX文件 - 本地版:
pip install netron,然后netron yolov5s.onnx - Python API:
netron.start("yolov5s.onnx")
torch.onnx.export的dynamic_axes参数,Reshape节点就少了很多。
Netron里看节点信息,重点关注:
- 节点颜色:不同颜色代表不同算子类型,比如卷积是蓝色,激活函数是绿色
- 连线粗细:粗线代表多维张量,细线代表标量或低维数据
- 属性面板:点击节点,右侧会显示所有属性,包括kernel_shape、strides等
graph.node。