3. ONNX模型基础:模型结构、算子集与可视化工具

好,咱们今天聊聊ONNX模型到底长什么样。

说实话,我第一次接触ONNX时,也以为它就是个简单的模型格式转换器。后来在MCU上做部署时才发现——不懂模型结构,你连模型为什么跑不起来都搞不清楚。所以这一节,我带你从根上把ONNX模型的结构摸透。

3.1 计算图:模型的骨架

ONNX模型的核心,说白了就是一个计算图(Computational Graph)。

你可以把它想象成一张流程图。每个节点是一个操作,每条边是数据流动的方向。数据从输入节点流进来,经过一系列运算,最后从输出节点流出去。

我在项目中遇到过一个问题:一个训练好的模型,在PC上推理完全正常,但放到MCU上就报错。后来一查,是计算图里有个Reshape节点的形状推导出了问题。嗯,计算图这东西,你越早理解它,后面踩的坑就越少。

计算图三要素:

  • 节点(Node):代表一个算子操作,比如Conv、Relu、Add
  • 张量(Tensor):代表数据,也就是节点之间的输入输出
  • 边(Edge):连接节点和张量,表示数据流向

举个例子,一个简单的卷积神经网络,它的计算图大概长这样:

输入张量 (1,3,224,224)
    ↓
Conv节点 (权重+偏置)
    ↓
Relu节点
    ↓
Pool节点
    ↓
Flatten节点
    ↓
Gemm节点 (全连接)
    ↓
Softmax节点
    ↓
输出张量 (1,1000)

每个箭头就是一条边,每个方框就是一个节点。数据就这么一步步流过去。

3.2 节点:算子的具体化身

每个节点都对应一个具体的算子。我习惯把节点理解成「指令」,张量理解成「数据」。指令操作数据,就这么简单。

一个ONNX节点通常包含:

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

你看,一个Conv节点在ONNX里长这样:

node {
  input: "input"
  input: "conv1_weight"
  input: "conv1_bias"
  output: "conv1_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里的MaxPool2d,在ONNX里叫MaxPool。我曾经因为没注意这个命名差异,在模型转换时卡了整整一天。

3.3 张量:数据的载体

张量就是多维数组。在ONNX里,每个张量都有明确的类型和形状。

我个人习惯把张量分为两类:

  • 权重张量:模型训练好的参数,比如卷积核、偏置。这些是固定的。
  • 激活张量:推理过程中产生的中间结果。这些是动态的。

在MCU上部署时,最头疼的就是激活张量占用的内存。你想想看,一个(1,64,56,56)的float32张量,就要占64*56*56*4 ≈ 800KB。很多MCU总共才几百KB的RAM,根本放不下。

避坑指南:我曾经在STM32上部署一个分类模型,模型本身只有2MB Flash,但推理时总是内存溢出。后来用Netron一看,中间有个Concat节点把多个特征图拼在了一起,临时张量直接飙到1.2MB。解决办法是把Concat改成了Element-wise Add,内存瞬间降下来。

3.4 ONNX算子集:你的工具箱

ONNX定义了一套标准算子,叫算子集(Opset)。每个版本都有新增或废弃的算子。

目前常用的算子集版本是opset 11到opset 19。版本越高,支持的算子越多,但兼容性可能越差。

算子集版本 主要新增算子 MCU部署建议
opset 11 基础算子(Conv、Relu、Gemm等) 最稳定,推荐使用
opset 13 Softmax、ReduceMax等 大部分MCU推理引擎支持
opset 15 LayerNormalization、GridSample 部分MCU不支持,需谨慎
opset 18+ 复杂算子(如GroupNorm) MCU上基本不可用

为什么我要强调算子集版本?因为MCU上的推理引擎通常只支持有限的算子。你如果用了opset 18的某个冷门算子,很可能在MCU上找不到对应的实现。我建议你在导出ONNX模型时,尽量使用opset 11或opset 13,兼容性最好。

3.5 模型可视化工具:Netron

Netron是我最常用的工具,没有之一。它能把ONNX模型的计算图可视化出来,让你一眼看清模型结构。

怎么用?简单得很:

  1. 打开 netron.app 或者下载桌面版
  2. 把.onnx文件拖进去
  3. 模型结构就出来了,可以点击每个节点查看详细信息

Netron能告诉你什么?

  • 每个节点的输入输出形状
  • 每个张量的数据类型
  • 每个算子的属性参数
  • 模型的总参数量和计算量

我的使用习惯:每次拿到一个ONNX模型,第一件事就是用Netron打开,检查输入输出形状对不对,中间有没有奇怪的算子。尤其是从PyTorch转过来的模型,经常会出现一些ONNX不原生支持的算子,Netron上一眼就能看出来。

举个例子,你可能会看到这样的信息:

输入: input (1, 3, 224, 224)  float32
节点1: Conv (kernel_shape: [3,3], strides: [2,2])
  输入: input, conv1.weight, conv1.bias
  输出: conv1_output (1, 64, 112, 112)  float32
节点2: Relu
  输入: conv1_output
  输出: relu1_output (1, 64, 112, 112)  float32
...
输出: output (1, 1000)  float32

看到没?每个张量的形状都清清楚楚。如果某个中间张量的形状特别大,你就要小心了——它在MCU上可能撑爆内存。

3.6 实战建议:如何快速上手

说了这么多,我总结几条实战建议:

  • 先看计算图:用Netron打开模型,整体浏览一遍,确认节点数量、类型、数据流向
  • 检查算子兼容性:对照你的MCU推理引擎支持的算子列表,把不支持的算子标记出来
  • 关注张量形状:尤其是中间张量的最大值,这决定了你的RAM够不够用
  • 尽量用低版本opset:opset 11是安全牌,opset 13也可以,再高就要小心了

嗯,这一节的内容就到这儿。下一节我会带你实际操作——如何把一个PyTorch模型导出为ONNX,并用Netron验证它的结构。到时候咱们手把手过一遍,你就能真正体会到这些概念是怎么落地的。