4、ONNX模型导出与转换:从PyTorch/TensorFlow导出ONNX模型、ONNX模型结构分析、模型验证工具。

好,咱们进入实战环节。前面聊了那么多ONNX Runtime的底层原理,现在终于要动手了。这一章,我带你走一遍模型导出的完整流程。说白了,就是把你在PyTorch或TensorFlow里训好的模型,变成ONNX格式。这一步要是没走对,后面所有的优化都是白搭。

4.1 从PyTorch导出ONNX模型

PyTorch导出ONNX,我个人觉得是最顺手的。它原生支持`torch.onnx.export`接口,几乎不需要额外装什么库。但这里有个坑——动态轴的处理。

先看一个最简单的例子。假设你有一个分类模型,输入是固定尺寸的图片:

import torch
import torchvision.models as models

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

# 创建一个虚拟输入,batch_size=1,3通道,224x224
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'},   # 动态batch
        'output': {0: 'batch_size'}
    }
)
print("导出成功!")

嗯,这里要注意`dynamic_axes`参数。我在项目中遇到过,很多同学忘了设置这个,结果导出的模型只能跑固定batch size。你想想看,部署到ARM设备上,有时候一次只推理一张图,有时候想批量处理,没有动态轴就尴尬了。

避坑指南: 我曾经在导出YOLOv5模型时,忘了设置动态轴。结果在树莓派上推理时,只要输入batch size不等于1,直接报错。后来排查了半天,才发现是导出时没加dynamic_axes。所以,除非你确定永远只用固定batch,否则一定要加上。

4.2 从TensorFlow导出ONNX模型

TensorFlow导出ONNX稍微麻烦一点。你需要用`tf2onnx`这个工具。我个人习惯用SavedModel格式作为中间桥梁。

步骤其实不复杂:

  1. 先把TensorFlow模型保存为SavedModel格式
  2. 然后用tf2onnx命令行工具转换

看代码:

# 第一步:保存TensorFlow模型为SavedModel
import tensorflow as tf

# 假设你有一个训练好的模型
model = tf.keras.applications.MobileNetV2(weights='imagenet')
model.save('mobilenetv2_savedmodel')

# 第二步:用tf2onnx转换
# 命令行执行:
# python -m tf2onnx.convert \
#     --saved-model mobilenetv2_savedmodel \
#     --output mobilenetv2.onnx \
#     --opset 11

这里有个小技巧。我建议你在转换前,先用`onnxruntime`的Python接口跑一下原始模型,确认输入输出的shape和dtype。为什么?因为TensorFlow的图有时候会包含一些奇怪的算子,转换时容易出问题。

我的经验: 如果转换报错说某个算子不支持,别慌。先查一下ONNX算子集版本。opset 11兼容性最好,opset 13支持更多新算子。实在不行,可以试试opset 10。我在RK3588上部署时,就遇到过opset 13导出的模型在NPU上跑不了,降到opset 11就正常了。

4.3 ONNX模型结构分析

模型导出来之后,别急着部署。先看看它长什么样。我常用的工具是`Netron`和`onnxruntime`自带的模型分析接口。

Netron是个可视化工具,直接拖拽ONNX文件进去就能看到网络结构。但说实话,在ARM设备上开发时,我更习惯用命令行分析:

import onnx

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

# 查看模型基本信息
print("IR版本:", model.ir_version)
print("生产者:", model.producer_name)
print("图名称:", model.graph.name)

# 查看输入输出
for input in model.graph.input:
    print(f"输入: {input.name}, shape: {input.type.tensor_type.shape}")

for output in model.graph.output:
    print(f"输出: {output.name}, shape: {output.type.tensor_type.shape}")

# 统计节点数量
print(f"节点总数: {len(model.graph.node)}")

# 查看算子类型分布
op_types = {}
for node in model.graph.node:
    op_type = node.op_type
    op_types[op_type] = op_types.get(op_type, 0) + 1
print("算子分布:", op_types)

你想想看,这一步能帮你发现很多问题。比如,我曾在导出的模型里发现了一个`Identity`节点,它什么都不做,但白白增加了推理延迟。这种冗余节点,在ARM设备上就是性能杀手。

分析项 说明 常见问题
IR版本 ONNX中间表示版本 版本过低可能不支持新算子
输入输出 检查shape和dtype 动态轴未设置、数据类型不匹配
算子分布 统计各类算子数量 出现不支持的自定义算子
节点总数 网络中的操作节点数 冗余节点过多影响性能

4.4 模型验证工具

模型结构没问题了,接下来要验证精度。说白了,就是确保ONNX模型的推理结果和原始框架一致。

我常用的验证流程分三步:

  1. 数值一致性检查:用同样的输入,对比ONNX Runtime和原始框架的输出
  2. 精度损失评估:在验证集上跑一遍,看精度下降了多少
  3. 性能基准测试:在目标ARM设备上测推理延迟

先看数值一致性检查的代码:

import onnxruntime as ort
import numpy as np
import torch

# 原始PyTorch模型推理
model.eval()
with torch.no_grad():
    torch_output = model(torch.from_numpy(dummy_input)).numpy()

# ONNX Runtime推理
ort_session = ort.InferenceSession("resnet18.onnx")
ort_inputs = {ort_session.get_inputs()[0].name: dummy_input}
ort_output = ort_session.run(None, ort_inputs)[0]

# 对比结果
diff = np.abs(torch_output - ort_output)
print(f"最大绝对误差: {diff.max():.6f}")
print(f"平均绝对误差: {diff.mean():.6f}")

# 检查是否在容忍范围内
if diff.max() < 1e-3:
    print("✅ 数值一致性通过!")
else:
    print("❌ 数值差异过大,请检查模型导出过程")
重要提醒: 误差容忍度要根据模型类型来定。分类模型通常1e-3就够了,但目标检测模型对坐标回归的精度要求更高,我一般设到1e-5。另外,记得把模型设成eval模式,BatchNorm和Dropout在推理时行为不同。

精度损失评估这一步,我建议用你训练时的验证脚本,把模型替换成ONNX版本跑一遍。我在项目中遇到过,ONNX模型在ImageNet上top-1精度只掉了0.1%,但换到自己的业务数据集上,直接掉了2%。原因是什么?数据分布不同,某些算子对特定数据更敏感。

最后是性能基准测试。在ARM设备上,我一般测100次取平均:

import time

# 预热
for _ in range(10):
    ort_session.run(None, ort_inputs)

# 正式测试
times = []
for _ in range(100):
    start = time.time()
    ort_session.run(None, ort_inputs)
    times.append(time.time() - start)

print(f"平均推理时间: {np.mean(times)*1000:.2f} ms")
print(f"标准差: {np.std(times)*1000:.2f} ms")

嗯,这里要注意。ARM设备上CPU频率可能会动态调整,导致测试结果不稳定。我习惯先锁定CPU频率,或者跑足够多次取中位数。另外,内存带宽也是瓶颈,多线程推理时尤其明显。

我的小技巧: 验证时别只用一个输入。准备10-20张不同的图片,分别对比输出。因为有些算子对特定输入值敏感,比如Sigmoid在饱和区误差会放大。多测几张,心里更有底。

好了,模型导出、结构分析、验证这三步走完,你的ONNX模型就可以放心部署到ARM设备上了。下一章,我会讲怎么用ONNX Runtime的Session Options做性能调优,到时候咱们再细聊。