2、ONNX基础:ONNX格式概述、模型文件组成(.onnx)、protobuf序列化原理
在深度学习工程化与跨平台部署的实践中,ONNX(Open Neural Network Exchange)已成为事实上的中间表示标准。它并非一种新的训练框架,而是一座连接PyTorch、TensorFlow、PaddlePaddle等训练框架与ONNX Runtime、TensorRT、OpenVINO等推理引擎的“语言桥梁”。理解ONNX的底层文件格式与序列化机制,是进行模型结构检查、节点编辑、量化裁剪以及自定义算子开发的前提。本章将深入剖析.onnx文件的物理组成、protobuf序列化原理,并演示如何通过工具读取模型的结构化信息。
2.1 ONNX格式概述:为什么需要中间表示
ONNX最初由微软与Facebook于2017年联合推出,其核心目标是解决深度学习模型在“训练”与“部署”之间的鸿沟。每个训练框架都有自己独特的模型保存格式,例如PyTorch的.pt/.pth、TensorFlow的.pb/.h5,这些格式与框架的运行时环境深度耦合。当我们需要将模型部署到云端、移动端或嵌入式设备时,如果目标推理引擎不支持该框架的格式,就需要进行繁重的格式转换甚至重写推理代码。
ONNX定义了一套与框架无关的计算图规范。它将模型描述为有向无环图(DAG),图中的节点(Node)表示算子(如Conv、Relu、MatMul),边(Edge)表示张量(Tensor)的数据流向。通过这套统一规范,任何支持ONNX导出的训练框架,都能生成标准的.onnx文件;任何支持ONNX解析的推理引擎,都能读取并执行该文件。这种“一次导出,多处部署”的模式,极大地简化了模型跨平台迁移的流程。
2.2 .onnx模型文件的物理组成
一个标准的.onnx文件本质上是一个使用protobuf(Protocol Buffers)序列化后的二进制文件。我们可以将其视为一个多层嵌套的容器结构,最外层是ModelProto,它包含了模型的基本信息以及一个核心的GraphProto。
以下是.onnx文件的主要组成层次:
| Proto层级 | 关键字段 | 作用与说明 |
|---|---|---|
| ModelProto | ir_version, opset_import, producer_name, graph | 模型元数据(如IR版本、算子集版本)以及指向计算图的入口。 |
| GraphProto | node[], initializer[], input[], output[], value_info[] | 计算图的核心结构,包含所有计算节点、权重参数、输入输出张量描述。 |
| NodeProto | op_type, input[], output[], attribute[] | 描述单个算子实例。例如op_type="Conv",input为["X","W","B"]。 |
| TensorProto | dims[], data_type, float_data[]/raw_data | 存储权重、偏置等常量数据。通常对应Initializer中的条目。 |
| AttributeProto | name, type, i/f/s/t/... | 存储算子的静态属性,如卷积的strides、pads、group等。 |
| ValueInfoProto | name, type | 描述输入、输出以及中间张量的名称和数据类型(含形状)。 |
在GraphProto中,node[]列表是有序的,它定义了计算执行的拓扑顺序。而initializer[]则存放了所有训练得到的权重张量。值得注意的是,input[]中既包含模型真正的输入占位符(如图像的NCHW张量),也可能包含那些未在initializer中初始化的权重名称(通常为空)。
2.3 protobuf序列化原理与数据布局
protobuf是Google开发的一种高效、语言无关的结构化数据序列化机制。与JSON或XML不同,protobuf采用二进制编码,具有体积小、解析快的优点。ONNX选择protobuf作为其序列化基础,正是看中了其在深度学习场景下对大规模权重数据的紧凑存储能力。
protobuf序列化的核心逻辑基于字段编号(Field Number)和Wire Type。每个字段在定义时都有一个唯一的编号,编码时,字段编号与Wire Type组合成一个Tag字节(或变长字节),随后紧跟该字段的值。对于ONNX模型而言,其protobuf定义文件(.proto)由ONNX社区维护,我们无需手动编写序列化代码,只需通过官方提供的Python/C++ API即可完成读写。
在数据布局上,ONNX的权重数据通常以两种形式存储在TensorProto中:
- typed_data:针对小数据量,直接使用
float_data、int64_data等重复字段存储。 - raw_data:针对大权重(如卷积核),将数据连续打包为字节流存入
raw_data字段,避免protobuf对每个元素进行额外的Tag标记,从而大幅减小文件体积。
raw_data字段非空,说明权重是连续存储的。在内存中加载时,需要根据data_type(如FLOAT=1)将字节流强制转换为对应的数据类型数组。
2.4 案例拆解:使用Python读取并解析ONNX模型结构
为了直观理解上述概念,我们通过一个实际案例来拆解如何读取一个ResNet-50的.onnx文件。假设我们已安装onnx库,并拥有一个名为resnet50.onnx的模型文件。
第一步:加载模型并查看顶层信息
import onnx
model = onnx.load("resnet50.onnx")
print("IR版本:", model.ir_version)
print("生产者:", model.producer_name)
print("算子集版本:", [(op.domain, op.version) for op in model.opset_import])
print("图名称:", model.graph.name)
输出结果将显示该模型基于IR版本7,算子集为ai.onnx v13。这些信息对于判断模型是否兼容当前推理引擎至关重要。
第二步:分析图的节点与初始器
graph = model.graph
print("节点数量:", len(graph.node))
print("初始器(权重)数量:", len(graph.initializer))
# 打印前5个节点信息
for node in graph.node[:5]:
print(f"节点类型: {node.op_type}, 输入: {list(node.input)}, 输出: {list(node.output)}")
通过遍历graph.node,我们可以清晰地看到模型的计算顺序。例如,前几个节点通常是Conv、BatchNormalization和Relu的组合。
第三步:检查权重张量的形状与数据类型
init = graph.initializer[0] # 假设第一个是Conv的权重
print("权重名称:", init.name)
print("维度:", list(init.dims))
print("数据类型:", init.data_type) # 1 代表 FLOAT
# 如果数据量小,直接读取
if len(init.float_data) > 0:
print("前10个浮点值:", init.float_data[:10])
else:
print("数据存储于raw_data中,字节数:", len(init.raw_data))
这个案例展示了如何从二进制文件中提取结构化的模型信息。在实际工程中,我们通常使用onnx.shape_inference.infer_shapes来推断中间张量的形状,以便进行后续的算子融合或内存优化。
graph.input中的所有元素都视为模型的输入。实际上,如果某个输入名称同时出现在graph.initializer中,那么它只是一个权重占位符,并非真正的运行时输入。真正的输入是那些在initializer中不存在、且未被任何节点作为输出产生的张量。
2.5 常见误区与总结复盘
在理解ONNX格式时,以下几个误区值得特别注意:
- 误区一:认为.onnx是文本文件。虽然我们可以通过
onnx.load将其转换为Python对象,但文件本身是二进制格式。直接使用文本编辑器打开会看到乱码。 - 误区二:忽略opset版本兼容性。不同版本的算子集(opset)可能对同一算子有不同的定义或新增属性。例如,
Resize算子在opset 10和opset 11中的坐标变换模式不同。部署时需确保推理引擎支持的opset版本不低于模型导出时的版本。 - 误区三:混淆Node与Initializer。Node代表计算操作,Initializer代表常量数据。权重数据不会直接出现在Node的输入中,而是通过名称引用Initializer。
- 误区四:忽视ValueInfo中的形状信息。ONNX模型中的张量形状可能是动态的(如batch维度为"None")。在结构检查时,需要关注
dim_param字段,它表示该维度是符号化的,而非固定整数。