2、ONNX基础:ONNX格式概述、模型文件组成(.onnx)、protobuf序列化原理

在深度学习工程化与跨平台部署的实践中,ONNX(Open Neural Network Exchange)已成为事实上的中间表示标准。它并非一种新的训练框架,而是一座连接PyTorch、TensorFlow、PaddlePaddle等训练框架与ONNX Runtime、TensorRT、OpenVINO等推理引擎的“语言桥梁”。理解ONNX的底层文件格式与序列化机制,是进行模型结构检查、节点编辑、量化裁剪以及自定义算子开发的前提。本章将深入剖析.onnx文件的物理组成、protobuf序列化原理,并演示如何通过工具读取模型的结构化信息。

训练框架层 ONNX中间表示层 推理引擎层 PyTorch / TF PaddlePaddle 其他框架 ModelProto (元数据+图) GraphProto (节点+初始器) NodeProto Initializer ValueInfoProto protobuf 序列化 ONNX Runtime TensorRT OpenVINO .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解析的推理引擎,都能读取并执行该文件。这种“一次导出,多处部署”的模式,极大地简化了模型跨平台迁移的流程。

核心价值:ONNX不仅仅是文件格式,更是一套完整的计算图语义定义。它规定了算子的输入输出数量、属性(Attribute)类型、张量数据类型(如FLOAT、INT64)以及维度信息,从而保证了模型在不同框架间转换时的数学等价性。

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标记,从而大幅减小文件体积。
经验提示:在检查模型时,如果发现某个Initializer的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字段,它表示该维度是符号化的,而非固定整数。