第4章 模型转换与中间表示:PyTorch/TF模型导出为ONNX

模型训练好了,怎么塞进嵌入式设备?

直接拿PyTorch的.pt文件去跑?别想了。嵌入式推理引擎不认识这些框架的私有格式。它们只认一个东西——中间表示。而ONNX,就是目前最通用的那个中间人。

我个人习惯把ONNX比作「模型界的通用语言」。你拿PyTorch写个网络,我用TensorFlow搭个结构,最后都转成ONNX,大家就能在同一个平台上对话了。这一章,我们就来聊聊怎么把训练好的模型「翻译」成ONNX,以及翻译过程中容易踩的坑。

4.1 为什么要用ONNX?

说白了,ONNX解决的是「框架锁死」的问题。你想想看,项目做到一半,发现某个算子PyTorch支持但TensorRT不支持,或者反过来——这时候如果没有ONNX,你就得重写模型。我早期做项目时就被这种问题折腾过,后来学乖了,所有模型先过一遍ONNX再说。

特性 原生框架模型 ONNX中间表示
跨框架兼容 仅限本框架 PyTorch/TF/MXNet互通
推理后端支持 有限 TensorRT/OpenVINO/NCNN等主流引擎均支持
算子标准化 框架自定义 统一算子集,便于优化
部署流程 需要框架运行时 轻量级runtime即可

核心观点:ONNX不是推理引擎,它是模型流通的「中间货币」。你最终还是要把它转成目标平台的具体格式(比如TensorRT的.engine或NCNN的.param)。但有了ONNX,你只需要做一次转换工作。

4.2 PyTorch模型导出为ONNX

PyTorch官方提供了torch.onnx.export接口。用法不复杂,但参数调不对,导出来的模型可能跑不动。

4.2.1 基本导出流程

import torch
import torch.onnx

# 假设你有一个训练好的模型
model = YourModel()
model.load_state_dict(torch.load('model.pth'))
model.eval()

# 构造一个 dummy 输入
dummy_input = torch.randn(1, 3, 224, 224)

# 导出 ONNX
torch.onnx.export(
    model,                     # 模型
    dummy_input,               # 示例输入
    'model.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以上。版本太低,很多新算子不支持;版本太高,老推理引擎又认不出来。我在项目中遇到过用opset=13导出的模型,结果客户的NVIDIA驱动太老,TensorRT不认——最后只能降版本重导。

4.2.2 dynamic_axes 的坑

很多新手会忽略dynamic_axes。如果你不设置,导出的ONNX默认输入形状是固定的——比如你dummy_input是1x3x224x224,那模型就只能处理batch_size=1的输入。想跑batch_size=4?不行。

设置dynamic_axes后,ONNX会把这个维度标记为动态。但注意,不是所有算子都支持动态形状。我踩过的一个坑是:模型里有个reshape操作,动态batch下reshape的维度计算错了,推理时直接崩掉。

避坑指南:导出时设置dynamic_axes后,一定要用不同batch_size的输入做一次推理验证。我曾经因为偷懒没验证,结果上线后模型在batch_size=2时输出全为0,排查了两天才发现是某个自定义算子没处理好动态维度。

4.3 TensorFlow模型导出为ONNX

TF模型转ONNX,主流工具是tf2onnx。流程跟PyTorch类似,但细节上有些差异。

4.3.1 使用tf2onnx命令行工具

# 安装
pip install tf2onnx

# 转换
python -m tf2onnx.convert \
    --saved-model ./saved_model \
    --output model.onnx \
    --opset 11 \
    --inputs input:0 \
    --outputs output:0

这里有个容易忽略的点:TF的模型输入输出名称。你用saved_model_cli show --dir ./saved_model --all先看一眼,确认输入输出节点的名字。我见过有人把名字写错,转换出来的ONNX输入输出全是乱序的。

4.3.2 TF特有算子的处理

TF有些算子ONNX原生不支持,比如FusedBatchNorm、TopKV2。tf2onnx内部会做算子映射,但映射不一定完美。我遇到过FusedBatchNorm转成ONNX后精度掉了0.5%,排查发现是epsilon参数没传对。

TF算子 ONNX映射算子 常见问题
FusedBatchNorm BatchNormalization epsilon参数精度丢失
TopKV2 TopK K值必须为常量
ResizeBilinear Resize 坐标变换模式不匹配
SplitV Split split维度索引差异

个人经验:TF转ONNX后,建议用onnxruntime跑一遍推理,跟原始TF模型的输出做数值对比。误差在1e-5以内算正常,超过1e-3就要警惕了。

4.4 ONNX结构解析

ONNX文件本质上是一个protobuf序列化的数据。你可以用onnx库来解析它。

4.4.1 查看模型结构

import onnx

model = onnx.load('model.onnx')
print(model.graph.node)  # 打印所有算子节点
print(model.graph.input) # 输入信息
print(model.graph.output) # 输出信息

# 查看某个节点的属性
for node in model.graph.node:
    if node.op_type == 'Conv':
        print(node.name, node.attribute)

我个人习惯用netron这个可视化工具。它能把ONNX模型画成一张网络图,节点、连接、参数一目了然。你可以在浏览器里打开,拖拽查看。对于排查模型结构问题,比看代码快多了。

4.4.2 修改ONNX模型

有时候你需要手动修改ONNX模型——比如删除某个节点、修改输入形状。可以用onnx库的API来做。

import onnx
from onnx import helper, TensorProto

model = onnx.load('model.onnx')

# 修改输入形状
new_input = helper.make_tensor_value_info(
    'input', TensorProto.FLOAT, [None, 3, 224, 224]
)
model.graph.input[0].CopyFrom(new_input)

# 删除某个节点
node_to_remove = 'node_name'
model.graph.node.remove(
    [n for n in model.graph.node if n.name == node_to_remove][0]
)

onnx.save(model, 'model_modified.onnx')

注意:手动修改ONNX模型风险很高。改错一个维度,整个计算图可能就废了。我建议只在万不得已时用这招,而且改完后一定要做数值验证。

4.5 算子兼容性检查

模型转成ONNX只是第一步。你最终要把它部署到某个推理引擎上——比如TensorRT、NCNN、OpenVINO。这些引擎支持的ONNX算子集各不相同。如果模型里用了某个引擎不支持的算子,转换就会失败。

4.5.1 常见不兼容算子

算子 TensorRT支持 NCNN支持 OpenVINO支持
Resize (nearest) ✓ ✓ ✓
Resize (linear) ✓ (需特定版本) ✓ ✓
NonMaxSuppression ✓ (TRT 8+) ✗ ✓
ScatterND ✗ ✗ ✓
Einsum ✗ ✗ ✗

4.5.2 如何检查兼容性

我一般用三步走:

  1. 用onnxruntime跑一遍——确认ONNX本身没问题。
  2. 用目标引擎的ONNX解析器跑一遍——比如TensorRT的trtexec,看它能不能解析。
  3. 逐算子检查——如果解析失败,用onnx库遍历所有节点,对照目标引擎的算子支持列表,找出不兼容的算子。
import onnx

model = onnx.load('model.onnx')
unsupported_ops = []

# 假设这是TensorRT不支持的算子列表
trt_unsupported = ['Einsum', 'ScatterND', 'NonMaxSuppression']

for node in model.graph.node:
    if node.op_type in trt_unsupported:
        unsupported_ops.append(node.op_type)

if unsupported_ops:
    print(f"不兼容算子: {set(unsupported_ops)}")
else:
    print("所有算子均兼容")

核心思路:算子兼容性检查不是一次性的。你换一个推理引擎版本,支持列表可能就变了。我建议把检查脚本写进CI流程,每次模型更新自动跑一遍。

4.6 本章知识结构图

下面这张图概括了模型转换到ONNX再到推理引擎的完整流程,以及每个环节的关键检查点。

模型转换与ONNX部署流程 模型转换与ONNX部署流程 PyTorch模型 TensorFlow模型 其他框架模型 ONNX中间表示 算子集版本 opset 11+ 算子兼容性检查 逐算子比对支持列表 数值精度验证 误差 < 1e-5 TensorRT NVIDIA GPU NCNN ARM CPU/GPU OpenVINO Intel CPU/VPU ONNX Runtime 跨平台通用 嵌入式设备推理部署

4.7 本章小结

模型转换这件事,说难不难,说简单也不简单。核心就三点:

  • 导出参数要设对——opset版本、dynamic_axes、输入输出名称,一个都不能错。
  • 结构要能看懂——学会用netron和onnx库解析模型,出了问题能定位。
  • 算子要提前查——别等到部署时才发现某个算子不支持,那时候改模型就晚了。

我做了这么多项目,最深的体会是:模型转换花的时间,往往比训练还多。但只要你把这一章的内容吃透了,后面部署到TensorRT、NCNN这些引擎上,就会顺畅很多。