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.node 和 model.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 做三件事:
- 快速检查模型结构:看看输入输出对不对,有没有多余的节点。
- 定位算子支持情况:如果某个算子显示为红色或灰色,说明你的推理引擎可能不支持它。
- 查看权重信息:点击某个节点,可以查看它的属性、输入输出张量的形状和数据类型。
举个例子,你导出一个 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,以及常见的坑有哪些。