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里,张量分为两种:

  1. 输入/输出张量:在计算图中流动的数据
  2. 中间张量:节点之间传递的临时数据

每个张量都有几个关键属性:

属性 说明 示例
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模型的核心结构。下一章,我们会聊聊如何用这些知识来做模型优化。