2. ONNX模型结构解析:计算图、节点(Node)、张量(Tensor)、初始器(Initializer)详解
好,咱们今天来聊聊ONNX模型内部到底长什么样。
很多人把ONNX当成一个黑盒,导出来就直接用了。但我个人习惯,拿到一个模型第一件事就是拆开看看。你想想看,如果连模型结构都不清楚,出了问题怎么定位?
2.1 计算图:模型的骨架
ONNX模型的核心,说白了就是一个计算图(Computation Graph)。
什么是计算图?你可以把它想象成一张流程图。数据从输入节点流入,经过一系列操作节点,最终从输出节点流出。每个节点代表一个数学运算,每条边代表数据的流动方向。
核心要点:ONNX的计算图是有向无环图(DAG)。这意味着数据只能向前流动,不能形成循环。这一点和PyTorch的动态图不同,ONNX是静态图,一旦导出,结构就固定了。
我在项目中遇到过一个问题:用PyTorch训练时模型跑得好好的,转成ONNX后推理结果全错了。后来一查,是因为模型里有个条件分支,在ONNX里被展开了,导致计算路径变了。嗯,这就是静态图的特性——它会把所有可能的路径都展开。
2.2 节点(Node):运算的原子单位
每个节点代表一个具体的运算。比如卷积、ReLU、BatchNorm、Add等等。
来看一个典型的节点结构:
// 一个Conv节点的内部表示
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
}
}
每个节点包含三部分:
- 输入:一个或多个输入张量的名称
- 输出:一个或多个输出张量的名称
- 属性:运算的参数,比如卷积核大小、步长、padding等
小技巧:用 netron 工具打开ONNX模型,可以直观看到每个节点的输入输出。我调试模型时,经常用这个工具来确认节点连接是否正确。
2.3 张量(Tensor):数据的基本单元
张量就是多维数组。在ONNX里,张量分为两种:
- 输入/输出张量:在计算图中流动的数据
- 中间张量:节点之间传递的临时数据
每个张量都有几个关键属性:
| 属性 | 说明 | 示例 |
|---|---|---|
| name | 张量的唯一标识 | "input_1" |
| shape | 张量的维度信息 | [1, 3, 224, 224] |
| dtype | 数据类型 | float32, int64 |
| data | 实际存储的数据(仅初始器有) | 权重数值 |
这里有个容易踩的坑:张量的维度顺序。ONNX默认使用NCHW格式(Batch, Channel, Height, Width)。但有些框架导出时可能用了NHWC。我曾经因为这个问题,在端侧推理时发现输出全是乱的,排查了半天才发现是维度顺序搞反了。
2.4 初始器(Initializer):模型的“记忆”
初始器就是模型中的常量数据。比如卷积层的权重、BatchNorm的均值和方差、偏置项等。
这些数据在推理过程中不会改变,所以被单独存放在初始器列表中。这样做的好处是:
- 推理引擎可以提前加载这些数据到内存
- 方便进行常量折叠优化
- 减少运行时内存分配的开销
注意:初始器中的数据是只读的。如果你在推理过程中试图修改初始器的值,会导致未定义行为。我见过有人为了做模型微调,直接在ONNX上改权重,结果推理结果完全不可控。
来看一个初始器的实际例子:
// 一个权重初始器
initializer {
dims: 64
dims: 3
dims: 3
dims: 3
data_type: 1 // float32
name: "conv1.weight"
raw_data: "\x00\x00\x80\x3f..." // 二进制权重数据
}
2.5 它们之间的关系
我习惯用一个简单的比喻来理解:
- 计算图 = 工厂的流水线布局图
- 节点 = 流水线上的每个工位(做什么操作)
- 张量 = 在工位之间传送的零件(数据)
- 初始器 = 工位上的固定工具(不会变的数据)
举个例子,一个简单的卷积层:
// 伪代码展示关系
Graph {
// 输入张量
Tensor input: shape=[1,3,224,224], dtype=float32
// 初始器(权重和偏置)
Initializer weight: shape=[64,3,3,3], dtype=float32
Initializer bias: shape=[64], dtype=float32
// 节点(卷积运算)
Node Conv(input, weight, bias) -> output
// 输出张量
Tensor output: shape=[1,64,222,222], dtype=float32
}
2.6 实战:如何解析ONNX模型
用Python解析ONNX模型其实很简单:
import onnx
# 加载模型
model = onnx.load("model.onnx")
# 获取计算图
graph = model.graph
# 遍历所有节点
for node in graph.node:
print(f"节点类型: {node.op_type}")
print(f" 输入: {list(node.input)}")
print(f" 输出: {list(node.output)}")
# 查看所有初始器
for init in graph.initializer:
print(f"初始器: {init.name}, 形状: {list(init.dims)}")
# 查看输入张量
for input_tensor in graph.input:
print(f"输入: {input_tensor.name}, 形状: {[dim.dim_value for dim in input_tensor.type.tensor_type.shape.dim]}")
# 查看输出张量
for output_tensor in graph.output:
print(f"输出: {output_tensor.name}, 形状: {[dim.dim_value for dim in output_tensor.type.tensor_type.shape.dim]}")
我的经验:拿到一个新模型,我通常会先打印出所有节点的op_type,看看模型用了哪些算子。如果发现有不支持的算子(比如一些自定义算子),就得提前做算子替换或融合。这一步做在前面,能省去后面很多调试时间。
2.7 常见问题与避坑
Q: 为什么我的ONNX模型这么大?
A: 检查一下初始器。有时候模型里会包含一些没用的常量,比如调试用的中间结果。用 onnx-simplifier 可以清理掉这些冗余数据。
Q: 模型推理结果不对,怎么排查?
A: 我一般会从输出节点往回查。先看最后一个节点的输出对不对,如果不对,再看它的输入。这样一层层往前推,很快就能定位到问题节点。
避坑指南:我曾经遇到过一个模型,在PC上推理完全正确,但部署到手机上结果就变了。后来发现是端侧推理引擎对某些算子的实现有精度差异。解决办法是:在导出ONNX时,把一些复杂算子拆成基础算子组合,这样兼容性更好。
好了,这一章的内容就到这里。理解了计算图、节点、张量和初始器这四个概念,你就掌握了ONNX模型的核心结构。下一章,我们会聊聊如何用这些知识来做模型优化。