3. 模型格式基础:ONNX模型结构解析、Protobuf序列化原理、模型可视化工具(Netron)

做嵌入式端侧推理,你第一个要打交道的,就是模型文件本身。我见过不少新手,拿到一个 .onnx 文件就直接往引擎里塞,结果跑不起来,或者精度不对,然后一脸懵。说白了,你不懂它的结构,出了问题根本不知道从哪下手。

这一章,我们就来把 ONNX 模型扒开看看。我会从它的底层序列化原理讲起,再到具体的结构解析,最后教你用 Netron 这个神器快速定位问题。嗯,这些都是我踩过坑之后总结出来的经验。

3.1 Protobuf:ONNX 的“骨架”

ONNX 模型文件,本质上就是一个 Protobuf 序列化后的二进制文件。你想想看,一个深度学习模型,动辄几百兆,里面全是权重、张量、算子信息。如果直接用文本存,那文件体积会爆炸,解析速度也慢得离谱。

Protobuf 就是 Google 搞出来的一套序列化协议。它比 XML、JSON 小得多,解析速度也快得多。我个人习惯把 Protobuf 想象成一个“数据打包工具”:你先定义好数据结构(.proto 文件),然后它帮你把数据压缩成二进制,用的时候再解包。

核心要点:ONNX 的 .proto 文件定义了模型的所有数据结构。你看到的每一个算子、每一个张量,在 .proto 文件里都有对应的 message 定义。

举个例子,ONNX 的核心数据结构是这样的:

// 简化版的 ONNX proto 定义
message ModelProto {
  int64 ir_version = 1;           // ONNX 的版本号
  repeated OperatorSetIdProto opset_import = 2;
  string producer_name = 3;       // 生成这个模型的框架,比如 PyTorch
  string producer_version = 4;
  string domain = 5;
  int64 model_version = 6;
  string doc_string = 7;
  GraphProto graph = 8;           // 核心:计算图
}

message GraphProto {
  repeated NodeProto node = 1;    // 所有的算子节点
  string name = 2;
  repeated TensorProto initializer = 5; // 权重参数
  repeated ValueInfoProto input = 11;   // 输入信息
  repeated ValueInfoProto output = 12;  // 输出信息
}

看到没?整个模型就是一个 ModelProto,里面包含了一个 GraphProto。而 GraphProto 里,最重要的就是 node(算子列表)和 initializer(权重列表)。

我的小技巧:当你怀疑模型结构有问题时,直接用 Python 的 onnx 库加载模型,然后打印 model.graph.nodemodel.graph.initializer。这比任何可视化工具都直接。

3.2 ONNX 模型结构:从 Node 到 Graph

好,现在我们知道了 Protobuf 是骨架,那 ONNX 模型具体长什么样?

一个 ONNX 模型,说白了就是一个有向无环图(DAG)。图的节点是算子(Conv、Relu、Add 等),边是张量(Tensor)。每个节点有输入、输出,还有属性(比如卷积的 kernel size、stride)。

我曾经在项目中遇到过一个问题:一个用 PyTorch 导出的模型,在 ONNX Runtime 上跑的结果和 PyTorch 不一致。排查了半天,最后发现是某个算子的属性被导错了。嗯,从那以后,我每次导出模型都会用 Netron 看一眼。

我们来看一个典型的 Node 定义:

// 一个 Conv 节点的例子
node {
  input: "input.1"       // 输入张量名
  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
  }
}

这里要注意几个关键点:

  • op_type:算子类型,决定了这个节点做什么运算。
  • input / output:张量的名字,用来连接不同的节点。
  • attribute:算子的参数,比如卷积的 kernel 大小、步长、padding。

而权重(initializer)则是这样存的:

initializer {
  dims: 64    // 输出通道
  dims: 3     // 输入通道
  dims: 3     // kernel height
  dims: 3     // kernel width
  data_type: 1  // FLOAT
  name: "conv.weight"
  raw_data: "\x00\x00\x80\x3f..."  // 二进制权重数据
}

注意:ONNX 的 data_type 是用整数表示的。1 是 float32,2 是 uint8,3 是 int8,等等。搞混了类型,推理结果会完全不对。我曾经就因为把 int8 当 float32 解析,浪费了一整天。

3.3 模型可视化工具:Netron 实战

说实话,光看代码和 proto 定义,你很难直观地理解一个复杂模型的结构。这时候就需要 Netron 出场了。

Netron 是一个轻量级的模型可视化工具。它支持 ONNX、TensorFlow、PyTorch 等多种格式。你只需要把模型文件拖进去,它就能自动解析并展示出完整的计算图。

我个人习惯用 Netron 做三件事:

  1. 快速检查模型结构:看看输入输出对不对,有没有多余的节点。
  2. 定位算子支持情况:如果某个算子显示为红色或灰色,说明你的推理引擎可能不支持它。
  3. 查看权重信息:点击某个节点,可以查看它的属性、输入输出张量的形状和数据类型。

举个例子,你导出一个 MobileNetV2 模型,用 Netron 打开后,应该能看到类似这样的结构:

输入: [1, 3, 224, 224]
  │
  ├─ Conv (kernel=3x3, stride=2) → BatchNormalization → Relu6
  │
  ├─ InvertedResidual (expand=6, stride=1) × 2
  │
  ├─ InvertedResidual (expand=6, stride=2) × 3
  │
  ├─ ... (更多层)
  │
  └─ Conv (kernel=1x1) → GlobalAveragePool → Flatten → Softmax
输出: [1, 1000]

如果你发现某个 InvertedResidual 块里多了一个没见过的算子,那就要警惕了。很可能是在导出时,PyTorch 的某些操作没有被正确映射到 ONNX 算子。

避坑指南:我曾经在导出 Transformer 模型时,Netron 显示有一个 Gather 算子,但我的端侧引擎不支持。后来发现是 PyTorch 的 torch.index_select 被映射成了 Gather。解决办法是手动改写模型,用 torch.matmul 替代。

3.4 实战:用 Python 解析 ONNX 模型

光说不练假把式。我们来看看怎么用 Python 代码来解析一个 ONNX 模型。这在你做模型转换或调试时非常有用。

import onnx

# 加载模型
model = onnx.load("mobilenetv2.onnx")

# 打印模型基本信息
print(f"IR version: {model.ir_version}")
print(f"Producer: {model.producer_name} {model.producer_version}")
print(f"Graph name: {model.graph.name}")

# 遍历所有算子
print("\n=== 算子列表 ===")
for i, node in enumerate(model.graph.node):
    print(f"Node {i}: {node.op_type}")
    print(f"  Inputs: {list(node.input)}")
    print(f"  Outputs: {list(node.output)}")
    # 打印属性
    for attr in node.attribute:
        print(f"  Attr: {attr.name} = {attr.ints if attr.ints else attr.f}")

# 查看权重信息
print("\n=== 权重列表 ===")
for init in model.graph.initializer:
    print(f"Name: {init.name}, Shape: {list(init.dims)}, DataType: {init.data_type}")

# 查看输入输出
print("\n=== 输入输出 ===")
for inp in model.graph.input:
    print(f"Input: {inp.name}, Shape: {[dim.dim_value for dim in inp.type.tensor_type.shape.dim]}")
for out in model.graph.output:
    print(f"Output: {out.name}, Shape: {[dim.dim_value for dim in out.type.tensor_type.shape.dim]}")

这段代码会输出模型的完整结构。你想想看,当你拿到一个别人给的模型,先用这个脚本跑一遍,心里就有底了。

3.5 总结与建议

这一章我们聊了三个核心点:

  • Protobuf 是 ONNX 的底层序列化协议,它让模型文件变得小巧且高效。
  • ONNX 模型是一个有向无环图,由算子节点和张量边组成。权重以 initializer 的形式存储。
  • Netron 是调试模型的利器,能帮你快速定位结构问题、算子兼容性问题。

最后给你一个建议:每次导出模型后,先用 Netron 看一眼,再用 Python 脚本解析一遍。这两个步骤加起来不超过 5 分钟,但能帮你避免后面几小时的排查时间。嗯,这是我用真金白银换来的教训。

下一章,我们会深入模型转换的细节,聊聊怎么把 PyTorch、TensorFlow 的模型转成 ONNX,以及常见的坑有哪些。