第2章:ONNX模型结构解析
好,咱们接着聊。上一章我们把ONNX的来龙去脉讲清楚了,这一章我带你深入看看ONNX模型内部到底长什么样。说白了,就是拆开这个“黑盒子”,看看里面的零件和线路。
2.1 ONNX的算子集:模型的“乐高积木”
ONNX模型里最核心的东西,就是算子。你可以把算子想象成乐高积木里的基础块。一个复杂的神经网络,就是由这些基础块拼起来的。
我个人习惯把算子分成几大类:
- 张量操作类:比如Reshape、Transpose、Concat、Split。这些算子不改变数据的“值”,只改变数据的“形状”或“排列顺序”。
- 数学运算类:比如Add、Mul、MatMul、Softmax。这些是真正做数值计算的。
- 激活函数类:比如Relu、Sigmoid、Tanh。给模型引入非线性能力。
- 神经网络层类:比如Conv、Pool、BatchNormalization、LSTM。这些是高级封装,底层其实也是由基础算子组合的。
- 数据流控制类:比如If、Loop。这个在ONNX里比较特殊,用于实现条件分支和循环。
嗯,这里要注意一点。不同版本的ONNX,支持的算子集是不一样的。比如ONNX opset 11和opset 15,支持的算子数量和参数都有差异。我在项目中遇到过,一个在opset 13上导出的模型,拿到opset 11的推理引擎上直接报错。所以,跨平台部署时,一定要确认算子集的版本兼容性。
核心要点:算子集版本号(opset version)是ONNX模型的“身份证”之一。它决定了模型能用哪些积木块。
3.2 图结构:模型的“电路图”
有了积木块,还得有图纸把它们连起来。这个图纸就是ONNX的图结构。ONNX使用有向无环图(DAG)来描述计算流程。
你想想看,一个典型的图结构包含三个要素:
- 节点(Node):每个节点就是一个算子。它定义了“做什么运算”。
- 张量(Tensor):节点之间的连线,就是数据流。数据以张量的形式在节点间传递。
- 边(Edge):连接节点和张量的关系。一个节点的输出张量,会成为下一个节点的输入张量。
举个例子,一个简单的“输入 -> Conv -> Relu -> 输出”流程,在ONNX图里就是三个节点,中间通过两个张量边连接。我刚开始看ONNX图的时候,总觉得它跟PyTorch的模型定义不太一样。后来发现,ONNX图是静态图,所有计算路径在导出时就固定了。而PyTorch是动态图,每次运行都可能不同。这个区别,在部署时影响很大。
我的小技巧:用Netron工具可视化ONNX模型,可以非常直观地看到图结构。我每次拿到新模型,第一件事就是拖进Netron里看一眼。哪个节点是瓶颈,一目了然。
3.3 节点与张量:模型的“零件”和“导线”
咱们再深入一点,看看节点和张量具体长什么样。
节点(Node)
每个节点包含以下信息:
- op_type:算子类型,比如"Conv"、"Relu"。
- name:节点名称,通常自动生成,但也可以手动命名方便调试。
- inputs:输入张量名称列表。
- outputs:输出张量名称列表。
- attribute:算子属性,比如卷积的kernel_shape、strides、pads。这些是算子的“参数”,在推理时是固定的。
举个例子,一个Conv节点的attribute里会记录卷积核大小、步长、填充方式。这些属性在模型导出时就确定了,推理时不能改。我曾经犯过一个错,以为可以在推理时动态修改卷积步长,结果模型直接崩溃。嗯,后来我记住了:attribute是静态的,input/output才是动态的。
张量(Tensor)
张量是ONNX里数据的载体。它包含:
- name:张量名称,全局唯一。
- data_type:数据类型,比如float32、int64、bool。
- shape:形状,比如[1, 3, 224, 224]表示一张图片。
- data:实际数据。对于权重张量,数据是存储在模型文件里的。对于中间张量,数据是在推理时动态计算的。
这里有个容易踩的坑:张量的shape可以是动态的。比如输入张量的shape可以是[1, 3, -1, -1],表示高度和宽度在推理时才能确定。我在部署一个目标检测模型时,就遇到了动态shape的问题。ARM平台的推理引擎对动态shape支持不太好,最后我不得不把输入固定成[1, 3, 640, 640]才搞定。
避坑指南:我曾经在转换模型时,发现推理结果全是NaN。查了半天,原来是某个中间张量的数据类型是float16,但ARM CPU不支持半精度计算。所以,部署前一定要检查所有张量的数据类型是否被目标硬件支持。
3.4 模型元数据:模型的“身份证”
除了算子和图结构,ONNX模型还包含一些元数据。这些信息不参与计算,但对部署非常重要。
| 元数据字段 | 说明 | 我的经验 |
|---|---|---|
| ir_version | ONNX中间表示版本 | 不同版本对图结构的定义略有差异,建议统一使用较新版本 |
| opset_import | 导入的算子集版本 | 这个最关键,决定了模型能用哪些算子 |
| producer_name | 导出模型的框架名称,比如pytorch、tensorflow | 看到这个就知道模型是从哪个框架来的,方便排查问题 |
| producer_version | 导出模型的框架版本 | 有时候不同框架版本导出的模型行为不一样 |
| domain | 模型所属域,通常为空或"ai.onnx" | 自定义算子会使用自定义domain,部署时要额外处理 |
| model_version | 模型版本号,可自定义 | 我习惯在训练不同版本时更新这个字段,方便追溯 |
这些元数据,说白了就是模型的“身份证”。你拿到一个ONNX模型,先看opset_import,再看producer_name,心里就有底了。我每次部署前,都会写一个小脚本,把模型的元数据打印出来,确认版本信息无误再往下走。
一句话总结:ONNX模型 = 算子集(积木) + 图结构(图纸) + 节点与张量(零件和导线) + 元数据(身份证)。把这四样搞明白,ONNX模型在你面前就没有秘密了。
好,这一章就到这里。下一章我们聊聊怎么把PyTorch模型导出成ONNX,以及导出时那些让人头疼的坑。到时候我会分享一些我踩过的雷,保证让你少走弯路。