3、ONNX基础:ONNX格式介绍、PyTorch模型导出ONNX、TensorFlow模型导出ONNX、ONNX模型可视化工具

各位同学,欢迎来到第三章。

这一章我们聊聊ONNX。说实话,ONNX是我在部署工作中打交道最多的中间格式。你想想看,PyTorch训练好的模型,TensorFlow训练好的模型,它们就像两个说不同方言的人。ONNX就是那个翻译官,让它们能在同一个舞台上交流。

3.1 ONNX格式介绍

ONNX全称是Open Neural Network Exchange,说白了就是微软和Facebook牵头搞的一个开放标准。它的核心目标就一个:让不同框架训练的模型能互相转换,最终部署到各种硬件上。

我个人习惯把ONNX看作一个「中间表示层」。它既不是训练框架,也不是推理引擎,它就是个桥梁。嗯,这个比喻很贴切。

ONNX的模型结构其实很简单,就两个核心部分:

  • 计算图(Graph):描述网络的结构,包括节点(算子)和边(张量流动)。
  • 权重参数(Initializer):存储训练好的权重值。

我在项目中遇到过一个问题:ONNX的算子集是有限的。有些PyTorch里的骚操作,比如自定义的F.pad模式,ONNX可能不支持。这时候就得手动替换成ONNX兼容的算子。所以,导出前先检查算子兼容性,能省掉后面一堆麻烦。

核心要点:ONNX不是万能的,但它解决了90%的跨框架部署问题。剩下的10%,靠经验和手动调优。

3.2 PyTorch模型导出ONNX

PyTorch导出ONNX,官方提供了torch.onnx.export接口。这个接口用起来很简单,但坑也不少。我刚开始用的时候,经常导出失败,后来总结了一套标准流程。

先看一个最简单的例子:

import torch
import torchvision.models as models

# 加载一个预训练模型
model = models.resnet18(pretrained=True)
model.eval()

# 创建一个虚拟输入
dummy_input = torch.randn(1, 3, 224, 224)

# 导出ONNX
torch.onnx.export(
    model,               # 模型
    dummy_input,         # 输入张量
    "resnet18.onnx",     # 输出文件名
    export_params=True,  # 导出权重
    opset_version=11,    # ONNX算子集版本
    do_constant_folding=True,  # 常量折叠优化
    input_names=['input'],
    output_names=['output'],
    dynamic_axes={
        'input': {0: 'batch_size'},
        'output': {0: 'batch_size'}
    }
)

这里有几个关键点,我重点说一下:

  • opset_version:我建议用11或更高版本。版本太低,很多新算子不支持。版本太高,有些老旧推理引擎又兼容不了。11是个比较稳妥的选择。
  • dynamic_axes:这个参数很实用。如果你需要动态batch size,就把它加上。否则导出的模型只能接受固定大小的输入。
  • do_constant_folding:建议开启。它能提前计算一些常量表达式,减少推理时的计算量。

小技巧:导出后,建议用onnx.checker.check_model验证一下模型完整性。我曾经因为模型里有个未初始化的权重,导致推理时直接崩溃。这个检查能帮你提前发现问题。

如果你遇到导出失败的情况,别慌。大多数时候是因为模型里有动态控制流(比如if-else、循环)。ONNX要求计算图是静态的,所以这些动态结构需要提前展开。我的做法是:用torch.jit.trace先追踪一遍,看看能不能成功。如果trace都过不了,那ONNX导出基本也没戏。

3.3 TensorFlow模型导出ONNX

TensorFlow导出ONNX,主要靠tf2onnx这个工具。它支持TF1.x的冻结图和TF2.x的SavedModel格式。

先看TF2.x的导出方式:

import tensorflow as tf
import tf2onnx

# 加载一个TF模型
model = tf.keras.applications.MobileNetV2(weights='imagenet')

# 指定输入签名
spec = (tf.TensorSpec((None, 224, 224, 3), tf.float32, name="input"),)

# 导出ONNX
output_path = "mobilenetv2.onnx"
model_proto, _ = tf2onnx.convert.from_keras(
    model,
    input_signature=spec,
    opset=13,
    output_path=output_path
)

如果是TF1.x的冻结图(.pb文件),流程稍微复杂一点:

import tf2onnx

# 从冻结图导出
input_names = ["input:0"]
output_names = ["output:0"]

onnx_model = tf2onnx.tfonnx.process_tf_graph(
    tf_graph,
    input_names=input_names,
    output_names=output_names,
    opset=13
)

# 保存为ONNX
with open("model.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

这里有个坑,我踩过好几次:TensorFlow的算子命名和ONNX的算子命名不完全一致。比如TF里的FusedBatchNorm,在ONNX里对应的是BatchNormalization。tf2onnx会自动做映射,但偶尔会映射失败。这时候你需要手动指定算子映射表。

警告:TensorFlow的SavedModel格式里可能包含多个SignatureDef(签名定义)。导出时一定要指定正确的签名,否则导出的ONNX可能不是你想要的推理图。我见过有人导出了一个训练图,里面全是优化器节点,推理时直接报错。

3.4 ONNX模型可视化工具

模型导出后,怎么看它长什么样?可视化工具就派上用场了。

我常用的工具有三个:

工具名称 特点 适用场景
Netron 轻量级,支持多种格式,网页版和桌面版都有 日常快速查看模型结构
ONNX GraphSurgeon 可以修改和优化ONNX图,支持Python API 需要手动调整计算图时
ONNX Runtime 自带工具 集成在ORT中,可以打印图信息 调试时快速查看节点信息

Netron是我最常用的。你直接把.onnx文件拖进去,它就能展示出完整的计算图。节点、边、输入输出形状,一目了然。我记得有一次排查一个模型精度问题,就是用Netron发现了一个多余的Reshape节点,删掉后精度就恢复了。

如果你需要编程方式查看图结构,可以用onnx库自带的API:

import onnx

model = onnx.load("resnet18.onnx")
graph = model.graph

# 打印所有节点
for node in graph.node:
    print(f"Node: {node.name}, Op: {node.op_type}")
    print(f"  Inputs: {node.input}")
    print(f"  Outputs: {node.output}")

# 打印所有输入
for input_tensor in graph.input:
    print(f"Input: {input_tensor.name}, Shape: {input_tensor.type.tensor_type.shape}")

# 打印所有输出
for output_tensor in graph.output:
    print(f"Output: {output_tensor.name}, Shape: {output_tensor.type.tensor_type.shape}")

这个方式虽然不如Netron直观,但在自动化脚本里非常有用。比如你想批量检查一批ONNX模型的输入输出是否一致,用代码遍历就比手动一个个看快得多。

避坑指南:我曾经用Netron打开一个很大的ONNX模型(几百MB),结果浏览器直接卡死了。后来发现Netron的网页版对大模型支持不太好。建议大模型用桌面版,或者用ONNX GraphSurgeon先做一次简化,去掉一些冗余节点。

好了,这一章的内容就到这里。ONNX作为中间格式,它的核心价值在于「一次导出,多处部署」。下一章我们会深入ONNX的算子集和自定义算子,到时候再聊。