2、ONNX模型结构解析:计算图、节点(Node)、张量(Tensor)、初始器(Initializer)与图(Graph)
好,咱们直接进入正题。ONNX模型说白了就是一个「计算图」。你想想看,深度学习模型本质上就是一堆数学运算的组合,而ONNX用了一种非常直观的方式把这些运算组织起来。我刚开始接触ONNX时,第一反应是:这不就是个有向无环图吗?嗯,确实如此,但细节上还是有不少门道的。
2.1 计算图:模型骨架
计算图是ONNX模型的骨架。它描述了数据从输入到输出的流动路径。每个节点代表一个算子(比如Conv、Relu),每条边代表张量数据。
我个人习惯把计算图想象成「工厂流水线」。原材料(输入张量)进入工厂,经过一道道工序(节点),最终产出成品(输出张量)。中间会有一些半成品(中间张量),也会有一些预先准备好的零件(初始器)。
核心要点:ONNX的计算图是静态图。这意味着图的结构在推理前就完全确定了。这和PyTorch的动态图不同,但好处是便于优化和部署。
2.2 节点(Node):运算单元
节点是计算图的基本运算单元。每个节点都包含:
- op_type:算子类型,比如"Conv"、"Relu"、"Add"
- inputs:输入张量名称列表
- outputs:输出张量名称列表
- attributes:算子属性,比如卷积的kernel_shape、strides
举个例子,一个卷积节点大概长这样:
node {
input: "input_tensor"
input: "conv_weight"
input: "conv_bias"
output: "conv_output"
op_type: "Conv"
attribute {
name: "kernel_shape"
ints: 3
ints: 3
}
attribute {
name: "strides"
ints: 1
ints: 1
}
attribute {
name: "pads"
ints: 1
ints: 1
ints: 1
ints: 1
}
}
我在项目中遇到过一个问题:同一个算子在不同框架中属性命名不一致。比如PyTorch的padding参数是"padding",而ONNX里是"pads"。这种细节很容易踩坑。
小技巧:用Netron可视化工具打开ONNX模型,点击任意节点就能看到它的完整属性。这是排查算子兼容性问题最直接的方法。
2.3 张量(Tensor):数据载体
张量就是多维数组,是ONNX中数据的基本形式。每个张量都有:
- name:唯一标识符
- shape:形状,比如[1, 3, 224, 224]
- data_type:数据类型,比如FLOAT、INT64
- raw_data:原始数据(如果是常量张量)
张量分为两种:
- 输入/输出张量:在推理时动态传入或传出
- 中间张量:节点之间的临时数据
说白了,张量就是数据在计算图中的「快递包裹」。每个节点收到包裹,处理一下,再传给下一个节点。
注意:ONNX中张量的数据类型是严格定义的。我曾经遇到一个模型,PyTorch里用float32训练,导出ONNX时某个中间张量变成了float64,结果在推理引擎上直接报错。所以导出时一定要检查数据类型一致性。
2.4 初始器(Initializer):预定义常量
初始器就是模型中的常量张量。比如卷积层的权重、偏置,BatchNorm的均值和方差。它们被存储在模型文件中,推理时直接加载,不需要额外输入。
初始器在ONNX中是这样表示的:
initializer {
name: "conv1.weight"
data_type: FLOAT
dims: 64
dims: 3
dims: 3
dims: 3
raw_data: "..."
}
我个人习惯把初始器理解为「预制菜」。模型训练好之后,这些参数就固定下来了。推理时直接拿来用,不用再重新计算。
这里有个容易混淆的点:初始器虽然也是张量,但它们不属于计算图的「数据流」。它们更像是挂在图上的「附件」。你想想看,权重不会在节点之间传递,而是被节点直接引用。
关键区别:输入张量在推理时由外部提供,初始器则内嵌在模型文件中。两者在节点中都以input形式出现,但来源完全不同。
2.5 图(Graph):一切的总和
图就是把上面所有东西组合起来的容器。一个ONNX模型包含一个计算图,图里包含:
- node:所有运算节点
- initializer:所有常量张量
- input:模型输入张量的定义
- output:模型输出张量的定义
- value_info:中间张量的类型和形状信息(可选)
用代码加载一个ONNX模型,看看它的结构:
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)}")
# 遍历所有节点
for node in graph.node:
print(f"算子: {node.op_type}, 输入: {node.input}, 输出: {node.output}")
嗯,这里要注意:value_info不是必须的。有些模型导出时会省略中间张量的形状信息,这会导致推理引擎无法进行内存预分配。我建议在导出ONNX时尽量保留value_info,方便后续调试。
2.6 实战:手撕一个简单ONNX模型
光说不练假把式。咱们用代码手动构建一个ONNX模型,加深理解:
import onnx
from onnx import helper, TensorProto
# 创建输入张量
input_tensor = helper.make_tensor_value_info(
"input", TensorProto.FLOAT, [1, 3, 224, 224]
)
# 创建权重初始器
weight = helper.make_tensor(
"weight", TensorProto.FLOAT, [64, 3, 3, 3],
[0.1] * (64 * 3 * 3 * 3) # 随便填点数据
)
# 创建卷积节点
conv_node = helper.make_node(
"Conv",
inputs=["input", "weight"],
outputs=["conv_output"],
kernel_shape=[3, 3],
strides=[1, 1],
pads=[1, 1, 1, 1]
)
# 创建Relu节点
relu_node = helper.make_node(
"Relu",
inputs=["conv_output"],
outputs=["output"]
)
# 创建图
graph = helper.make_graph(
[conv_node, relu_node],
"simple_cnn",
[input_tensor],
[helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 64, 224, 224])],
[weight]
)
# 创建模型
model = helper.make_model(graph, producer_name="my_tool")
# 保存
onnx.save(model, "simple_cnn.onnx")
print("模型构建完成!")
这段代码构建了一个「Conv + Relu」的简单模型。你仔细看:weight既是初始器,又是卷积节点的输入。这就是初始器和张量之间的关系——它们通过名称连接。
调试技巧:当你遇到ONNX模型加载失败时,先用onnx.checker.check_model(model)检查模型完整性。这个函数会验证图结构、节点属性、张量形状等是否合规。我每次导出模型后都会跑一遍这个检查,能省去很多排查时间。
2.7 常见坑点总结
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 初始器未注册 | 推理时报"找不到张量xxx" | 检查初始器列表是否包含所有常量 |
| 张量名称冲突 | 两个节点输出同名张量 | 导出时设置unique_name策略 |
| 形状信息缺失 | 推理引擎无法分配内存 | 导出时设置keep_initializers_as_inputs=False |
| 数据类型不匹配 | 算子执行时类型转换失败 | 统一使用float32,避免混合精度 |
我曾经在一个项目中,模型导出后死活跑不通。查了两天才发现,有个BatchNorm层的初始器忘记添加到图里了。推理引擎找不到均值和方差,直接崩溃。从那以后,我每次导出都会用onnx.save的save_as_external_data参数,把初始器单独保存,方便检查。
好了,这一章的内容就到这里。记住一句话:ONNX模型就是「图 + 节点 + 张量 + 初始器」的组合。理解了这个结构,后续的算子兼容性问题排查就会轻松很多。