2、ONNX模型结构解析:计算图、节点(Node)、张量(Tensor)、初始器(Initializer)与图(Graph)

好,咱们直接进入正题。ONNX模型说白了就是一个「计算图」。你想想看,深度学习模型本质上就是一堆数学运算的组合,而ONNX用了一种非常直观的方式把这些运算组织起来。我刚开始接触ONNX时,第一反应是:这不就是个有向无环图吗?嗯,确实如此,但细节上还是有不少门道的。

2.1 计算图:模型骨架

计算图是ONNX模型的骨架。它描述了数据从输入到输出的流动路径。每个节点代表一个算子(比如Conv、Relu),每条边代表张量数据。

我个人习惯把计算图想象成「工厂流水线」。原材料(输入张量)进入工厂,经过一道道工序(节点),最终产出成品(输出张量)。中间会有一些半成品(中间张量),也会有一些预先准备好的零件(初始器)。

核心要点:ONNX的计算图是静态图。这意味着图的结构在推理前就完全确定了。这和PyTorch的动态图不同,但好处是便于优化和部署。

2.2 节点(Node):运算单元

节点是计算图的基本运算单元。每个节点都包含:

  • op_type:算子类型,比如"Conv"、"Relu"、"Add"
  • inputs:输入张量名称列表
  • outputs:输出张量名称列表
  • attributes:算子属性,比如卷积的kernel_shape、strides

举个例子,一个卷积节点大概长这样:

node {
  input: "input_tensor"
  input: "conv_weight"
  input: "conv_bias"
  output: "conv_output"
  op_type: "Conv"
  attribute {
    name: "kernel_shape"
    ints: 3
    ints: 3
  }
  attribute {
    name: "strides"
    ints: 1
    ints: 1
  }
  attribute {
    name: "pads"
    ints: 1
    ints: 1
    ints: 1
    ints: 1
  }
}

我在项目中遇到过一个问题:同一个算子在不同框架中属性命名不一致。比如PyTorch的padding参数是"padding",而ONNX里是"pads"。这种细节很容易踩坑。

小技巧:用Netron可视化工具打开ONNX模型,点击任意节点就能看到它的完整属性。这是排查算子兼容性问题最直接的方法。

2.3 张量(Tensor):数据载体

张量就是多维数组,是ONNX中数据的基本形式。每个张量都有:

  • name:唯一标识符
  • shape:形状,比如[1, 3, 224, 224]
  • data_type:数据类型,比如FLOAT、INT64
  • raw_data:原始数据(如果是常量张量)

张量分为两种:

  1. 输入/输出张量:在推理时动态传入或传出
  2. 中间张量:节点之间的临时数据

说白了,张量就是数据在计算图中的「快递包裹」。每个节点收到包裹,处理一下,再传给下一个节点。

注意:ONNX中张量的数据类型是严格定义的。我曾经遇到一个模型,PyTorch里用float32训练,导出ONNX时某个中间张量变成了float64,结果在推理引擎上直接报错。所以导出时一定要检查数据类型一致性。

2.4 初始器(Initializer):预定义常量

初始器就是模型中的常量张量。比如卷积层的权重、偏置,BatchNorm的均值和方差。它们被存储在模型文件中,推理时直接加载,不需要额外输入。

初始器在ONNX中是这样表示的:

initializer {
  name: "conv1.weight"
  data_type: FLOAT
  dims: 64
  dims: 3
  dims: 3
  dims: 3
  raw_data: "..."
}

我个人习惯把初始器理解为「预制菜」。模型训练好之后,这些参数就固定下来了。推理时直接拿来用,不用再重新计算。

这里有个容易混淆的点:初始器虽然也是张量,但它们不属于计算图的「数据流」。它们更像是挂在图上的「附件」。你想想看,权重不会在节点之间传递,而是被节点直接引用。

关键区别:输入张量在推理时由外部提供,初始器则内嵌在模型文件中。两者在节点中都以input形式出现,但来源完全不同。

2.5 图(Graph):一切的总和

图就是把上面所有东西组合起来的容器。一个ONNX模型包含一个计算图,图里包含:

  • node:所有运算节点
  • initializer:所有常量张量
  • input:模型输入张量的定义
  • output:模型输出张量的定义
  • value_info:中间张量的类型和形状信息(可选)

用代码加载一个ONNX模型,看看它的结构:

import onnx

model = onnx.load("model.onnx")
graph = model.graph

print(f"节点数量: {len(graph.node)}")
print(f"初始器数量: {len(graph.initializer)}")
print(f"输入数量: {len(graph.input)}")
print(f"输出数量: {len(graph.output)}")

# 遍历所有节点
for node in graph.node:
    print(f"算子: {node.op_type}, 输入: {node.input}, 输出: {node.output}")

嗯,这里要注意:value_info不是必须的。有些模型导出时会省略中间张量的形状信息,这会导致推理引擎无法进行内存预分配。我建议在导出ONNX时尽量保留value_info,方便后续调试。

2.6 实战:手撕一个简单ONNX模型

光说不练假把式。咱们用代码手动构建一个ONNX模型,加深理解:

import onnx
from onnx import helper, TensorProto

# 创建输入张量
input_tensor = helper.make_tensor_value_info(
    "input", TensorProto.FLOAT, [1, 3, 224, 224]
)

# 创建权重初始器
weight = helper.make_tensor(
    "weight", TensorProto.FLOAT, [64, 3, 3, 3],
    [0.1] * (64 * 3 * 3 * 3)  # 随便填点数据
)

# 创建卷积节点
conv_node = helper.make_node(
    "Conv",
    inputs=["input", "weight"],
    outputs=["conv_output"],
    kernel_shape=[3, 3],
    strides=[1, 1],
    pads=[1, 1, 1, 1]
)

# 创建Relu节点
relu_node = helper.make_node(
    "Relu",
    inputs=["conv_output"],
    outputs=["output"]
)

# 创建图
graph = helper.make_graph(
    [conv_node, relu_node],
    "simple_cnn",
    [input_tensor],
    [helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 64, 224, 224])],
    [weight]
)

# 创建模型
model = helper.make_model(graph, producer_name="my_tool")

# 保存
onnx.save(model, "simple_cnn.onnx")
print("模型构建完成!")

这段代码构建了一个「Conv + Relu」的简单模型。你仔细看:weight既是初始器,又是卷积节点的输入。这就是初始器和张量之间的关系——它们通过名称连接。

调试技巧:当你遇到ONNX模型加载失败时,先用onnx.checker.check_model(model)检查模型完整性。这个函数会验证图结构、节点属性、张量形状等是否合规。我每次导出模型后都会跑一遍这个检查,能省去很多排查时间。

2.7 常见坑点总结

坑点 表现 解决方案
初始器未注册 推理时报"找不到张量xxx" 检查初始器列表是否包含所有常量
张量名称冲突 两个节点输出同名张量 导出时设置unique_name策略
形状信息缺失 推理引擎无法分配内存 导出时设置keep_initializers_as_inputs=False
数据类型不匹配 算子执行时类型转换失败 统一使用float32,避免混合精度

我曾经在一个项目中,模型导出后死活跑不通。查了两天才发现,有个BatchNorm层的初始器忘记添加到图里了。推理引擎找不到均值和方差,直接崩溃。从那以后,我每次导出都会用onnx.savesave_as_external_data参数,把初始器单独保存,方便检查。

好了,这一章的内容就到这里。记住一句话:ONNX模型就是「图 + 节点 + 张量 + 初始器」的组合。理解了这个结构,后续的算子兼容性问题排查就会轻松很多。