4、模型导出入门:使用torch.onnx.export和tf2onnx完成第一个简单模型的ONNX导出
在完成ONNX Runtime部署、算子兼容性检查与推理优化之前,我们首先需要解决一个最基础也最关键的问题:如何将训练好的PyTorch或TensorFlow模型转换为标准的ONNX格式。这一过程看似简单,实则涉及模型计算图的静态化、动态轴的声明、算子映射的兼容性以及输入输出的元信息定义等多个环节。本章将带领你从零开始,分别使用torch.onnx.export和tf2onnx工具,完成第一个简单模型的ONNX导出,并深入剖析导出过程中的核心原理与常见陷阱。
上图清晰地展示了两种主流深度学习框架导出ONNX模型的整体链路。PyTorch通过torch.onnx.export直接对模型实例进行追踪(Tracing)或脚本化(Scripting)导出;TensorFlow则通过tf2onnx工具将SavedModel或Keras模型转换为ONNX格式。两条路径最终汇合到统一的ONNX中间表示,为后续的跨平台部署奠定基础。接下来,我们将分别深入探讨这两种导出方式的具体操作与核心原理。
4.1 核心原理:从动态图到静态图的转换
理解ONNX导出的本质,是掌握整个自动化流程的基石。无论是PyTorch还是TensorFlow,其训练时的计算图都是动态的、面向自动微分的;而ONNX作为一种推理格式,需要的是静态的、确定性的计算图。这种转换过程涉及几个关键的技术要点。
4.1.1 PyTorch的追踪(Tracing)与脚本化(Scripting)
PyTorch默认使用追踪(Tracing)机制进行导出。其原理是:构造一个虚拟输入(dummy input),让模型真实地执行一次前向传播,同时记录下所有执行过的张量操作,将这些操作序列化为一个静态的计算图。这种方式的优点是简单、自动化程度高,但缺点是无法捕获依赖数据控制流的逻辑,例如if语句、for循环等。如果模型内部包含这类动态控制流,追踪导出会将这些分支固定为追踪时执行的那一条路径,导致导出的模型行为与原始模型不一致。
为了解决这个问题,PyTorch提供了脚本化(Scripting)模式。通过torch.jit.script将模型转换为TorchScript,再导出为ONNX。Scripting会解析模型的Python源码,将其编译为静态图,从而保留控制流逻辑。但Scripting对模型代码的写法有较高要求,需要避免使用Python的动态特性。
核心要点:对于大多数标准的卷积神经网络(CNN)或Transformer模型,使用Tracing模式即可满足需求。如果模型包含if、for等控制流,且这些控制流依赖于输入张量的值,则必须使用Scripting或确保追踪时所有分支都被执行到。
4.1.2 TensorFlow的图冻结与节点映射
TensorFlow模型通常以SavedModel或Keras的H5格式保存。这些格式中包含了完整的训练图、变量、优化器状态等。tf2onnx的核心工作是将TensorFlow的计算图(GraphDef)转换为ONNX的计算图。这个过程包括:
- 图冻结(Freeze Graph):将变量(Variable)转换为常量(Constant),去除训练相关的节点(如梯度计算、优化器更新),得到一个纯粹的推理图。
- 算子映射(Op Mapping):将TensorFlow的算子(如
Conv2D、MatMul、Relu)映射到ONNX标准算子。由于两个框架的算子集并非一一对应,tf2onnx内部维护了一个庞大的映射表,并会插入一些辅助节点(如Transpose、Reshape)来对齐数据布局和维度。 - 图优化(Graph Optimization):在转换过程中,tf2onnx会执行一些常量折叠、冗余节点消除等优化操作,使生成的ONNX模型更加精简。
4.2 操作流程:PyTorch模型导出实战
下面我们通过一个具体的例子,演示如何使用torch.onnx.export导出一个简单的卷积神经网络模型。我们以经典的LeNet-5模型为例,该模型结构简单,适合作为入门演示。
4.2.1 环境准备与模型定义
首先,确保已安装PyTorch和ONNX。然后定义LeNet-5模型:
import torch
import torch.nn as nn
import torch.onnx
class LeNet5(nn.Module):
def __init__(self):
super(LeNet5, self).__init__()
self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2)
self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2)
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = self.pool1(torch.relu(self.conv1(x)))
x = self.pool2(torch.relu(self.conv2(x)))
x = x.view(-1, 16 * 5 * 5)
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = self.fc3(x)
return x
model = LeNet5()
model.eval() # 切换到评估模式,关闭Dropout和BatchNorm的统计更新
4.2.2 构造Dummy输入并执行导出
接下来,构造一个形状为(1, 1, 28, 28)的随机张量作为示例输入,并调用导出函数:
dummy_input = torch.randn(1, 1, 28, 28)
torch.onnx.export(
model, # 要导出的模型
dummy_input, # 示例输入张量
"lenet5.onnx", # 输出文件路径
export_params=True, # 是否导出权重参数
opset_version=13, # ONNX算子集版本
do_constant_folding=True, # 是否执行常量折叠优化
input_names=['input'], # 输入节点名称
output_names=['output'], # 输出节点名称
dynamic_axes={
'input': {0: 'batch_size'}, # 将第0维(batch)设为动态
'output': {0: 'batch_size'}
}
)
print("模型导出成功!")
在上述代码中,dynamic_axes参数非常关键。它允许我们指定哪些维度是动态的。例如,{0: 'batch_size'}表示输入和输出的第0维(即批量大小)是动态的,这样导出的模型就可以接受任意batch size的输入。如果不设置该参数,导出的模型将固定输入形状为(1, 1, 28, 28)。
经验提醒:在设置opset_version时,建议使用较新的版本(如13或更高),因为新版本支持更多的算子,且对动态形状的支持更好。但也要注意,过高的opset版本可能导致旧版ONNX Runtime无法加载。通常,ONNX Runtime 1.10及以上版本支持opset 13。
4.3 操作流程:TensorFlow模型导出实战
对于TensorFlow模型,我们使用tf2onnx工具进行转换。首先需要安装tf2onnx:pip install tf2onnx。然后,我们以一个简单的Keras Sequential模型为例。
4.3.1 创建并保存Keras模型
import tensorflow as tf
import tf2onnx
# 构建一个简单的Keras模型
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)),
tf.keras.layers.MaxPooling2D((2, 2)),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10, activation='softmax')
])
# 保存为SavedModel格式(推荐)
model.save('tf_model', save_format='tf')
print("TensorFlow模型已保存")
4.3.2 使用tf2onnx命令行工具转换
tf2onnx提供了命令行工具和Python API两种方式。这里我们演示命令行方式,它更为直观:
python -m tf2onnx.convert \
--saved-model tf_model \
--output tf_model.onnx \
--opset 13 \
--target onnxruntime
参数说明:
--saved-model:指定输入的SavedModel目录。--output:指定输出的ONNX文件路径。--opset:指定ONNX算子集版本。--target:指定目标运行时,这里选择onnxruntime,tf2onnx会针对该运行时进行特定优化。
如果使用Python API,可以这样写:
import tf2onnx
# 加载SavedModel
model = tf.saved_model.load('tf_model')
# 获取推理函数(signature)
spec = model.signatures['serving_default']
# 转换为ONNX
onnx_model, _ = tf2onnx.convert.from_function(
spec,
input_signature=[tf.TensorSpec([None, 28, 28, 1], tf.float32, name='input')],
output_path='tf_model.onnx',
opset=13
)
print("转换完成")
风险提示:在转换TensorFlow模型时,务必确保输入输出的张量形状和数据类型与训练时一致。特别是当模型包含预处理或后处理逻辑时,建议将这些逻辑移出模型,只转换核心推理部分,以避免不必要的算子映射问题。
4.4 案例拆解:导出后的验证与常见问题排查
导出ONNX模型只是第一步,验证导出的模型是否正确、能否被ONNX Runtime正确加载并产生与原始框架一致的推理结果,是更为关键的环节。下面我们通过一个完整的案例来演示验证流程。
4.4.1 使用ONNX Runtime进行推理对比
我们以刚才导出的PyTorch LeNet-5模型为例,使用ONNX Runtime加载并推理,与PyTorch原始模型的输出进行对比。
import onnxruntime as ort
import numpy as np
# 创建ONNX Runtime推理会话
ort_session = ort.InferenceSession('lenet5.onnx')
# 准备输入数据(与导出时的dummy_input相同分布)
test_input = torch.randn(1, 1, 28, 28)
# PyTorch推理
with torch.no_grad():
torch_output = model(test_input).numpy()
# ONNX Runtime推理
ort_inputs = {ort_session.get_inputs()[0].name: test_input.numpy()}
ort_output = ort_session.run(None, ort_inputs)[0]
# 对比输出差异
diff = np.abs(torch_output - ort_output).max()
print(f"PyTorch与ONNX Runtime最大输出差异: {diff:.6e}")
# 设置一个阈值,判断是否一致
if diff < 1e-5:
print("✅ 模型导出验证通过!")
else:
print("❌ 模型导出存在较大差异,请检查模型结构或导出参数。")
通常,由于浮点数运算的微小差异,两个框架的输出不会完全一致,但最大差异应在1e-5量级以内。如果差异过大,则说明导出过程中出现了问题。
4.4.2 常见问题与排查策略
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
导出时报错 Unsupported operator |
模型使用了ONNX不支持的算子,或opset版本过低 | 升级opset_version;检查是否有自定义算子,需要注册或替换;使用torch.onnx.is_onnx_backend_available()检查支持情况 |
| 导出的模型推理结果与原始框架不一致 | 模型包含动态控制流,Tracing模式未捕获所有分支;BatchNorm或Dropout未正确设置为eval模式 | 使用torch.jit.script进行Scripting导出;确保调用model.eval();检查输入数据的预处理是否一致 |
ONNX Runtime加载失败,提示Invalid Graph |
模型图中存在悬空节点或形状不匹配 | 使用onnx.checker.check_model检查模型完整性;使用onnx.shape_inference.infer_shapes进行形状推断 |
| 动态轴设置后,推理速度变慢 | 动态轴导致ONNX Runtime无法进行一些静态形状优化 | 如果业务场景中batch size固定,建议不要设置动态轴;或者设置多个固定形状的优化配置 |
4.4.3 案例复盘:一个BatchNorm导致的导出陷阱
我们曾遇到一个实际案例:某团队导出一个包含BatchNorm层的ResNet模型,在PyTorch中推理正常,但导出为ONNX后,在ONNX Runtime上的推理结果完全错误。经过排查,发现原因是在导出前没有调用model.eval()。在训练模式下,BatchNorm层会使用当前batch的均值和方差进行归一化,而导出时使用的是dummy input,其统计量是随机的,导致导出的模型权重中包含了错误的BatchNorm参数。
解决方案:在导出前显式调用model.eval(),将BatchNorm层切换到推理模式,使用训练阶段累积的全局统计量。这个案例提醒我们,导出前的模型状态设置是决定导出正确性的第一道关卡。
4.5 常见误区与总结复盘
在模型导出的入门阶段,有几个高频误区需要特别留意,它们往往会导致导出失败或推理结果异常。
误区一:忽略model.eval()与torch.no_grad()。这会导致BatchNorm和Dropout层行为异常,且计算图会包含梯度计算节点,使模型臃肿且行为错误。正确做法是:导出前必须调用model.eval(),并在推理验证时使用torch.no_grad()。
误区二:盲目追求高opset版本。虽然高opset支持更多算子,但可能导致模型无法在旧版ONNX Runtime上运行。建议根据部署环境选择合适的opset版本,并在导出后使用目标环境的ONNX Runtime进行验证。
误区三:忽视输入输出的命名与动态轴。如果导出的模型需要被其他服务调用,输入输出名称必须具有可读性且稳定。动态轴的设置要谨慎,过多动态维度会增加推理引擎的优化难度,影响性能。
总结复盘:完成第一个模型的ONNX导出,核心在于理解“动态图转静态图”的本质。对于PyTorch,要掌握Tracing与Scripting的适用场景;对于TensorFlow,要熟悉SavedModel的导出与tf2onnx的参数配置。导出后的验证环节不可或缺,通过对比原始框架与ONNX Runtime的输出差异,可以快速定位问题。建议在项目初期就建立一套自动化的导出与验证脚本,将模型导出纳入CI/CD流程,避免人工操作带来的不确定性。