第3章:ONNX模型结构解析
说实话,很多同学刚开始接触ONNX时,都把它当成一个黑盒子。模型导进去,跑起来就完事了。但我在实际项目中踩过不少坑——比如模型推理速度上不去、精度对不上、甚至直接跑崩了。最后发现,问题都出在对模型结构理解不够深。
这一章,我们就来把这个黑盒子拆开看看。
3.1 ONNX计算图:模型的核心骨架
ONNX模型本质上是一个计算图。什么叫计算图?说白了,就是描述数据怎么流动、计算怎么进行的蓝图。
我习惯把计算图想象成一个工厂流水线:
- 原材料:输入张量(比如一张图片)
- 加工设备:节点(比如卷积、激活函数)
- 传送带:边(张量在节点间传递)
- 成品:输出张量(比如分类结果)
计算图是有向无环图(DAG)。数据只能沿着箭头方向流动,不能回头。这个特性很关键——它保证了推理过程的可预测性。
重要概念:ONNX的计算图是静态的。模型一旦导出,图结构就固定了。这和PyTorch的动态图不同,后者每次运行都可以改变结构。
3.2 节点(Node):计算的基本单元
节点就是计算图中的一个个操作。每个节点都干一件事:接收输入,做计算,产生输出。
一个典型的ONNX节点包含:
- 名称:节点的唯一标识
- 算子类型:比如Conv、Relu、Add
- 输入列表:指向输入张量的名称
- 输出列表:指向输出张量的名称
- 属性:算子的参数,比如卷积的kernel_shape、strides
举个例子,一个卷积节点大概长这样:
node {
name: "conv1"
op_type: "Conv"
input: "input_tensor"
input: "conv1_weight"
input: "conv1_bias"
output: "conv1_output"
attribute {
name: "kernel_shape"
ints: 3
ints: 3
}
attribute {
name: "strides"
ints: 1
ints: 1
}
}
我在项目中遇到过一个问题:两个模型结构看起来一样,但推理结果对不上。查了半天,发现是其中一个节点的dilations属性默认值不同。嗯,这种细节最容易翻车。
小技巧:调试时可以用onnx.helper.printable_graph()打印出可读的图结构,比直接看protobuf舒服多了。
3.3 张量(Tensor):数据的载体
张量就是ONNX里的数据单元。你可以把它理解成多维数组。但和NumPy数组不同,ONNX张量有几个关键属性:
| 属性 | 说明 | 示例 |
|---|---|---|
| 名称 | 张量的唯一标识 | "input_1" |
| 数据类型 | float32、int64等 | 1 (代表float32) |
| 形状 | 各维度大小 | [1, 3, 224, 224] |
| 数据 | 原始数值(可选) | 权重、偏置等 |
这里有个容易混淆的点:ONNX张量分为输入张量和中间张量。输入张量是模型接收的外部数据,中间张量是节点之间传递的临时数据。我刚开始做模型转换时,经常把这两者搞混,导致输入输出对不上。
注意:ONNX的维度是静态的。如果你需要动态batch,必须显式设置dim_param。我曾经因为这个原因,导出的模型只能跑batch size=1,折腾了好久才发现问题。
3.4 图(Graph):节点的容器
图就是所有节点、张量、输入输出的集合。一个ONNX模型只有一个计算图(虽然ONNX支持子图,但实际很少用)。
图的结构很简单:
- 节点列表:所有计算节点
- 输入列表:模型的输入张量
- 输出列表:模型的输出张量
- 初始化器列表:权重、偏置等常量张量
你想想看,图的作用就是把这些元素组织起来,形成一个完整的计算流程。我习惯用Python代码来遍历图结构:
import onnx
model = onnx.load("model.onnx")
graph = model.graph
print(f"节点数量: {len(graph.node)}")
print(f"输入数量: {len(graph.input)}")
print(f"输出数量: {len(graph.output)}")
print(f"初始化器数量: {len(graph.initializer)}")
# 遍历所有节点
for node in graph.node:
print(f"节点: {node.name}, 算子: {node.op_type}")
这段代码我几乎每个项目都会用到。特别是在做模型优化时,先看看图结构,心里就有底了。
3.5 模型结构可视化工具:Netron
光看代码还是不够直观。这时候就需要Netron出马了。
Netron是一个轻量级的模型可视化工具。它支持ONNX、TensorFlow、PyTorch等多种格式。我个人习惯用它的在线版本(netron.app),拖拽文件就能看,省去安装的麻烦。
Netron能展示什么?
- 整体架构:模型的层次结构一目了然
- 节点详情:点击任意节点,右侧显示算子类型、输入输出、属性
- 张量信息:每个张量的名称、形状、数据类型
- 数据流:箭头清晰展示数据流动方向
我记得有一次,一个同事说他的模型推理速度特别慢。我让他用Netron打开模型一看,发现中间插了一个没必要的Reshape节点。去掉之后,速度提升了30%。这种问题,光看代码很难发现,但可视化一下就暴露了。
使用建议:Netron还可以导出为SVG或PNG。我习惯把优化前后的模型截图对比,方便团队review。
3.6 实战:用代码解析ONNX模型
光说不练假把式。我们来写一段代码,完整解析一个ONNX模型的结构:
import onnx
from onnx import helper
def analyze_onnx_model(model_path):
model = onnx.load(model_path)
graph = model.graph
print("=" * 50)
print("模型基本信息")
print("=" * 50)
print(f"IR版本: {model.ir_version}")
print(f"算子集版本: {model.opset_import[0].version}")
print("\n" + "=" * 50)
print("输入信息")
print("=" * 50)
for input_tensor in graph.input:
shape = [dim.dim_value for dim in input_tensor.type.tensor_type.shape.dim]
print(f"名称: {input_tensor.name}, 形状: {shape}")
print("\n" + "=" * 50)
print("输出信息")
print("=" * 50)
for output_tensor in graph.output:
shape = [dim.dim_value for dim in output_tensor.type.tensor_type.shape.dim]
print(f"名称: {output_tensor.name}, 形状: {shape}")
print("\n" + "=" * 50)
print("节点列表")
print("=" * 50)
for i, node in enumerate(graph.node):
print(f"[{i}] {node.op_type:10s} -> {node.name}")
# 打印输入输出
for input_name in node.input:
print(f" 输入: {input_name}")
for output_name in node.output:
print(f" 输出: {output_name}")
# 使用示例
analyze_onnx_model("resnet50.onnx")
这段代码会输出模型的完整结构。我在做模型转换时,一定会先跑一遍这个脚本,确认结构没问题再继续。
3.7 常见问题与避坑指南
最后,分享几个我踩过的坑:
- 节点顺序不等于执行顺序:ONNX的节点列表是按添加顺序排列的,但实际执行顺序由数据依赖关系决定。别被列表顺序骗了。
- 初始化器也是张量:权重、偏置这些常量都存储在
initializer中。它们也是张量,只是不参与梯度计算。 - Netron显示不全怎么办:如果模型太大,Netron可能会卡顿。我建议先导出子图,或者用
onnx.utils.extract_model()提取关键部分。
我曾经接手过一个项目,模型文件有500MB。用Netron打开直接卡死。后来我用脚本把图结构打印出来,发现里面有一堆没用的节点。清理之后,模型缩小到200MB,推理速度也快了一倍。
嗯,这一章的内容就到这里。理解了ONNX模型的结构,后面的优化工作才能有的放矢。下一章,我们会深入ONNX Runtime的推理引擎,看看它到底是怎么把计算图跑起来的。
公众号:蓝海资料掘金营,微信deep3321