ONNX基础回顾:ONNX格式、计算图、节点与张量、模型文件结构

在深入探讨onnx-simplifier与常量折叠之前,我们必须先建立对ONNX(Open Neural Network Exchange)格式的扎实理解。ONNX不仅仅是一个文件后缀名,它是一套开放的、跨框架的深度学习模型表示标准。如果把模型简化比作“给代码重构”,那么理解ONNX的计算图结构就是读懂“源代码”。本章将系统梳理ONNX的核心概念:从计算图的拓扑结构,到节点与张量的具体定义,再到一个.pb模型文件的物理布局。只有透彻掌握这些基础,你才能理解简化工具究竟在“动”模型的哪些部分,以及为什么某些操作是冗余的。

ONNX 模型核心结构总览 模型文件 (.onnx) ModelProto ir_version / opset_import graph (计算图) metadata_props 计算图 (Graph) node (节点列表) initializer (初始权重) input (模型输入) output (模型输出) value_info (中间张量) 节点 (Node) op_type (算子类型) input / output attribute (属性) 张量 (Tensor) data_type (数据类型) dims (维度) raw_data (原始数据) 包含 包含多个 数据载体 关键关系: 1. 一个 ONNX 模型 = 一个 ModelProto,其中核心是 graph 字段。 2. 计算图由节点(算子)和有向边(张量)组成,数据从 input 流向 output。 3. initializer 存储权重,不参与计算流,但被节点引用。 4. 简化工具主要操作 node 列表和 initializer,删除冗余节点、折叠常量。

上图清晰地展示了ONNX模型文件的逻辑层次。最外层是ModelProto,它包含了模型的基本元信息(如ir_version、opset_import)以及一个唯一的GraphProto。GraphProto内部则是由节点(NodeProto)、初始权重(TensorProto)、输入输出(ValueInfoProto)等组成的复杂结构。下面我们逐一拆解这些核心概念。

2.1 ONNX格式与ModelProto

ONNX采用Protobuf(Protocol Buffers)作为序列化协议,这意味着模型文件本质上是二进制编码的,但也可以通过`onnx.load()`加载为Python对象。ModelProto是ONNX模型的根容器,它包含几个关键字段:

  • ir_version:ONNX中间表示(IR)的版本号,不同版本支持不同的算子集和语法特性。
  • opset_import:声明模型使用的算子集(Operator Set)版本。例如,`opset_import { domain: "" version: 13 }`表示使用默认域(AI ONNX)的算子版本13。
  • graph:这是模型的核心,包含计算图的所有信息。
  • metadata_props:可选的键值对,用于存储模型作者、转换工具等附加信息。

理解ir_version和opset_import至关重要,因为不同版本的算子可能具有不同的行为或属性定义。例如,`Resize`算子在opset 10和opset 11中的坐标变换模式完全不同。当你在简化模型时,如果发现某些节点无法识别或行为异常,首先应该检查opset版本是否匹配。

2.2 计算图:有向无环图(DAG)

计算图是ONNX模型的灵魂。它是一个有向无环图(DAG),其中节点代表数学运算(如卷积、矩阵乘法、激活函数),边代表张量(数据)。数据从图的输入节点流入,经过一系列算子变换,最终从输出节点流出。这种设计使得模型可以被静态分析,从而支持优化和推理引擎的高效执行。

在GraphProto中,有几个关键列表:

  • node:一个NodeProto列表,按拓扑顺序排列。每个节点定义了算子类型、输入张量名、输出张量名以及属性。
  • initializer:一个TensorProto列表,存储模型的权重和常量。这些张量不经过计算产生,而是直接加载到内存中。
  • input:ValueInfoProto列表,声明模型的输入张量名称、数据类型和形状。
  • output:ValueInfoProto列表,声明模型的输出张量信息。
  • value_info:可选,用于描述中间张量的形状和类型,有助于调试和优化。

这里有一个容易混淆的点:initializer中的张量也是图的输入吗? 在ONNX标准中,initializer中的权重通常不显式出现在input列表中,除非你显式地将它们声明为输入。但在某些框架(如PyTorch导出时)中,权重可能同时出现在input和initializer中。这种冗余是导致模型“臃肿”的原因之一,也是简化工具需要处理的问题。

2.3 节点(NodeProto)与算子

节点是计算图的基本执行单元。一个NodeProto包含以下字段:

字段名 类型 说明
op_type string 算子类型,如 "Conv"、"Relu"、"Add"
input string[] 输入张量名称列表,顺序敏感
output string[] 输出张量名称列表,通常只有一个
attribute AttributeProto[] 算子属性,如卷积的kernel_shape、strides
domain string 算子所属域,默认是空字符串(AI ONNX标准域)

理解节点的关键在于区分输入/输出张量和属性。输入输出是数据流,而属性是算子的配置参数。例如,对于`Conv`算子,输入是`[X, W, B]`(输入特征图、权重、偏置),而属性`strides`、`pads`、`dilations`则定义了卷积的行为。在简化过程中,我们经常需要检查属性是否合理,或者是否可以通过属性折叠来消除节点。

2.4 张量(TensorProto)与数据表示

张量是ONNX中数据的基本载体。TensorProto定义了张量的数据类型、形状和原始数据。关键字段包括:

  • dims:一个int64数组,表示张量的维度。例如`[1, 3, 224, 224]`表示一个批次大小为1、3通道、224x224的图像。
  • data_type:枚举类型,如`FLOAT`(1)、`UINT8`(2)、`INT64`(7)等。
  • raw_data:当数据量较大时,数据以字节流形式存储在`raw_data`字段中,避免使用重复的`float_data`或`int32_data`字段。
  • name:张量的名称,在图中必须唯一,用于节点间的连接。

在模型简化中,张量的处理是核心。例如,常量折叠(Constant Folding)就是识别出那些输入全部是initializer的节点,提前计算出结果,并用新的initializer替换该节点。这要求我们能够高效地读取和写入TensorProto中的数据。

2.5 模型文件结构:从磁盘到内存

一个`.onnx`文件在磁盘上的物理结构是Protobuf编码的二进制流。当你使用`onnx.load("model.onnx")`时,ONNX Runtime会解析这个二进制流,填充ModelProto对象。这个过程涉及反序列化,将字节流转换为Python对象。反之,`onnx.save(model, "new_model.onnx")`则执行序列化。

值得注意的是,ONNX模型文件可能很大(数百MB),因为权重数据以原始字节存储。在简化模型时,我们通常先加载模型到内存,然后修改图结构,最后保存。如果模型过大,可以考虑使用`onnx.external_data_helper`将权重数据外置,但这会增加文件管理的复杂性。

核心原理小结:ONNX模型 = ModelProto(元信息) + GraphProto(计算图)。计算图由节点(算子)和张量(数据)构成有向无环图。initializer存储权重,input/output定义接口。简化工具的目标就是优化这个图结构,减少冗余节点和无效计算。

2.6 实战场景:识别模型中的冗余模式

为了让你更直观地理解这些概念,我们来看一个典型的PyTorch导出模型中的冗余模式。假设我们有一个简单的模型,包含一个BatchNorm层后接一个ReLU激活函数。在PyTorch中,BatchNorm在推理时可以被折叠到前面的卷积层中,但ONNX导出时可能保留独立的BatchNorm节点。

# 假设导出的ONNX模型包含以下节点序列(简化示意)
# 节点1: Conv (输入: input, weight, bias) -> conv_output
# 节点2: BatchNormalization (输入: conv_output, scale, B, mean, var) -> bn_output
# 节点3: Relu (输入: bn_output) -> relu_output
# 节点4: ... 后续节点

在这个场景中,`BatchNormalization`节点在推理时是确定性的,其参数(scale, B, mean, var)都是initializer。如果我们将这些参数与卷积的权重和偏置合并,就可以消除BatchNorm节点,从而减少计算量。这正是onnx-simplifier中“算子融合”功能的一部分。

另一个常见冗余是Shape操作链。例如,`Shape` -> `Gather` -> `Unsqueeze` -> `Concat` 这样的序列,通常用于动态计算某个维度的大小。如果输入形状是静态的,这些操作的结果可以预先计算并替换为常量。

2.7 常见误区与总结复盘

在学习和使用ONNX的过程中,初学者容易陷入以下几个误区:

  • 误区一:认为ONNX模型是“扁平”的。 实际上,ONNX支持嵌套的子图(如`If`和`Loop`算子内部包含子图),但大多数标准模型是扁平DAG。简化工具对子图的处理能力有限,需要特别注意。
  • 误区二:忽略opset版本的影响。 不同opset版本下,同一算子的定义可能不同。例如,`Pad`算子在opset 11之前使用`pads`属性,在opset 11之后改为`pads`输入。如果简化工具不支持当前opset,可能会产生错误。
  • 误区三:混淆initializer和input。 有些模型将权重同时声明为input和initializer,这会导致推理引擎尝试从外部输入读取权重,造成错误。简化工具通常会清理这种冗余声明。
  • 误区四:忽视张量的数据类型。 在常量折叠时,如果数据类型不匹配(如将float32的常量与int64的常量混合运算),会导致计算错误。必须严格检查`data_type`。

总结复盘:本章我们建立了ONNX的基础知识框架。你需要记住:模型是分层的(ModelProto -> GraphProto -> NodeProto/TensorProto);计算图是DAG,节点是算子,边是张量;initializer是静态权重,input/output是动态接口。在后续的简化实践中,你将反复与这些概念打交道。建议你使用`onnx.helper.printable_graph(model.graph)`打印出图的文本表示,直观地观察节点和连接关系,这将极大帮助你理解模型结构。

经验提醒:在动手简化模型之前,先使用`onnx.checker.check_model(model)`验证模型的完整性。这可以避免因模型损坏或版本不兼容导致的后续问题。

风险提示:不要试图手动修改ONNX模型的二进制文件。任何修改都应该通过`onnx`库的API进行,以确保Protobuf结构的正确性和完整性。