3. ONNX模型结构解析

ONNX这玩意儿,说白了就是AI模型的"通用语言"。你训练好的PyTorch模型、TensorFlow模型,想部署到不同硬件上?得先转成ONNX。我刚开始接触ONNX时,总觉得它就是个黑盒子——输入输出一接,完事。直到有一次在嵌入式设备上跑YOLOv5,死活推理不对,才逼着我把它扒了个底朝天。

这一章,咱们就把ONNX模型拆开来看。它到底长什么样?节点、张量、图这些概念怎么理解?怎么用Netron可视化?搞懂了这些,后面做算子适配、模型优化才能心里有数。

3.1 ONNX的protobuf格式

ONNX文件本质上是一个protobuf序列化文件。protobuf是Google搞的一套数据交换格式,比XML、JSON紧凑得多。你想想看,一个YOLOv5s的ONNX模型也就几十MB,换成JSON可能得几百MB。

ONNX的protobuf定义在onnx.proto里。核心结构就三层:

层级 对应结构 作用
模型层 ModelProto 包含模型元信息、算子集版本、图结构
图层 GraphProto 包含所有节点、张量、输入输出定义
节点层 NodeProto 每个算子的具体定义

我习惯用Python直接解析ONNX文件,看看里面到底有啥:

import onnx

model = onnx.load("yolov5s.onnx")
print(f"IR版本: {model.ir_version}")
print(f"算子集版本: {model.opset_import[0].version}")
print(f"生产者: {model.producer_name}")

graph = model.graph
print(f"节点数: {len(graph.node)}")
print(f"张量数: {len(graph.initializer)}")
print(f"输入: {[i.name for i in graph.input]}")
print(f"输出: {[o.name for o in graph.output]}")
小技巧:用 onnx.shape_inference.infer_shapes(model) 可以补全模型中缺失的张量形状信息。我在做YOLOv5转ONNX时,经常遇到某些中间节点的shape是未知的,跑一步shape inference就全出来了。

3.2 节点(Node)——模型的基本运算单元

ONNX里的节点,对应的是一个个算子。每个节点包含:

  • op_type:算子类型,比如Conv、Relu、Add
  • input:输入张量名称列表
  • output:输出张量名称列表
  • attribute:算子属性,比如卷积的kernel_shape、strides
  • name:节点名称(可选)

举个例子,YOLOv5s里的一个卷积节点:

node {
  input: "images"
  input: "model.0.conv.weight"
  input: "model.0.conv.bias"
  output: "model.0.conv.output"
  name: "Conv_0"
  op_type: "Conv"
  attribute {
    name: "kernel_shape"
    ints: 6
    ints: 6
  }
  attribute {
    name: "strides"
    ints: 2
    ints: 2
  }
  attribute {
    name: "pads"
    ints: 2
    ints: 2
    ints: 2
    ints: 2
  }
}

这里要注意,ONNX的卷积输入顺序是:输入特征图、权重、偏置。跟PyTorch的nn.Conv2d完全一致。但TensorFlow的卷积输入顺序是反的,所以做转换时得调一下。

踩坑提醒:我曾经在转换一个自定义算子时,忘了把属性名从PyTorch的"padding"改成ONNX的"pads"。结果模型加载直接报错。ONNX的属性名是大小写敏感的,而且有些属性名跟框架里不一样,一定要对照ONNX算子文档来写。

3.3 张量(Tensor)——数据流动的载体

ONNX里的张量分两种:

  • ValueInfoProto:描述张量的名称、数据类型、形状,用于输入输出定义
  • TensorProto:包含实际数据,用于存储权重、偏置等常量

看个例子,YOLOv5s的输入张量定义:

input {
  name: "images"
  type {
    tensor_type {
      elem_type: 1  # FLOAT
      shape {
        dim { dim_value: 1 }
        dim { dim_value: 3 }
        dim { dim_value: 640 }
        dim { dim_value: 640 }
      }
    }
  }
}

数据类型用整数表示,我列个对照表:

类型值 ONNX类型 对应Python类型
1 FLOAT numpy.float32
2 UINT8 numpy.uint8
3 INT8 numpy.int8
6 INT32 numpy.int32
7 INT64 numpy.int64
10 FLOAT16 numpy.float16

权重张量存在graph.initializer里。我经常直接读出来做量化分析:

import numpy as np

for init in graph.initializer:
    if "conv.weight" in init.name:
        weight = numpy_helper.to_array(init)
        print(f"{init.name}: shape={weight.shape}, "
              f"min={weight.min():.4f}, max={weight.max():.4f}")

3.4 图(Graph)——整个计算流程

ONNX的图结构,说白了就是一个有向无环图(DAG)。节点是算子,张量是边。数据从输入节点流入,经过一系列算子处理,最终从输出节点流出。

图的核心组成部分:

  • input:模型输入,比如YOLOv5的"images"
  • output:模型输出,比如YOLOv5的"output"
  • node:所有算子节点,按执行顺序排列
  • initializer:权重、偏置等常量张量
  • value_info:中间张量的类型和形状信息

我画了一张ONNX图结构的示意图,帮你理解数据怎么流动:

ONNX图结构示意图 输入 images[1,3,640,640] Conv_0 kernel=6x6 Sigmoid_0 激活函数 Conv_1 kernel=3x3 Relu_0 激活函数 输出 output[1,85,8400] 权重张量 图结构说明 • 节点=算子 • 边=张量 • 数据从左到右流动

遍历图结构,我常用这个代码片段来理解模型的计算流程:

for i, node in enumerate(graph.node):
    inputs = ", ".join(node.input)
    outputs = ", ".join(node.output)
    print(f"[{i:3d}] {node.op_type:15s} "
          f"in: {inputs:40s} "
          f"out: {outputs}")

3.5 使用Netron可视化

Netron是我最常用的ONNX可视化工具。它能把模型的计算图画得清清楚楚。我个人习惯在模型转换后,第一件事就是用Netron打开看看:

  • 检查结构:确认输入输出形状对不对
  • 定位问题:如果推理报错,看哪个节点有问题
  • 优化参考:看哪些算子可以合并或替换

Netron的使用方式:

  1. 在线版:打开 netron.app,直接拖入ONNX文件
  2. 本地版:pip install netron,然后 netron yolov5s.onnx
  3. Python API:netron.start("yolov5s.onnx")
实战经验:我在做YOLOv5s转ONNX时,用Netron发现模型里多了很多Reshape节点。这些节点是PyTorch自动插入的,对推理没用,但会增加计算开销。后来我加了torch.onnx.export的dynamic_axes参数,Reshape节点就少了很多。

Netron里看节点信息,重点关注:

  • 节点颜色:不同颜色代表不同算子类型,比如卷积是蓝色,激活函数是绿色
  • 连线粗细:粗线代表多维张量,细线代表标量或低维数据
  • 属性面板:点击节点,右侧会显示所有属性,包括kernel_shape、strides等
注意:Netron显示的节点顺序不一定是实际执行顺序。ONNX图是拓扑排序的,但Netron为了布局美观,可能会调整节点位置。真要分析执行顺序,还是得用代码遍历graph.node。