4、ONNX与PyTorch的关系:PyTorch原生导出ONNX的机制

聊到ONNX,就绕不开PyTorch。这两者的关系,说白了就是「亲兄弟」。

PyTorch现在是AI框架里的顶流,而ONNX是它官方支持的模型交换格式。我刚开始接触ONNX时,总觉得这玩意儿就是个「中间人」,后来才发现——它其实是PyTorch生态里不可或缺的一环。

4.1 PyTorch为什么选择ONNX?

你想想看,PyTorch模型训练好了,总不能永远待在Python环境里吧?

生产环境可能是C++、Java,甚至是移动端或嵌入式设备。PyTorch自己有一套TorchScript,但说实话,它的生态覆盖面和ONNX比,还是有差距的。

我个人习惯是:训练用PyTorch,部署用ONNX。这样既享受了PyTorch的灵活,又拿到了ONNX的跨平台能力。

核心逻辑: PyTorch负责「造」,ONNX负责「送」。两者分工明确,配合默契。

4.2 原生导出机制:torch.onnx.export

PyTorch从1.2版本开始,就内置了ONNX导出功能。你不需要装任何第三方库,直接调 torch.onnx.export 就行。

来看一个最简单的例子:

import torch
import torch.nn as nn

class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(10, 5)
    
    def forward(self, x):
        return self.fc(x)

model = SimpleModel()
dummy_input = torch.randn(1, 10)

torch.onnx.export(
    model,               # 模型
    dummy_input,         # 示例输入
    "simple_model.onnx", # 输出路径
    input_names=['input'],
    output_names=['output'],
    dynamic_axes={'input': {0: 'batch_size'}}
)

这段代码我写过不下百次。嗯,这里要注意几个关键点:

  • dummy_input 必须和实际输入的形状一致,否则导出会报错
  • dynamic_axes 用来指定动态维度,比如batch_size可以变化
  • input_names/output_names 给张量起个名字,方便后续推理时引用

4.3 导出背后的原理:Tracing vs Scripting

PyTorch导出ONNX,底层用的是 Tracing(追踪) 机制。

什么意思呢?就是给模型一个假输入,让它跑一遍,然后记录下所有操作。说白了,就是「看你怎么算,我就怎么记」。

但这里有个坑——控制流。如果模型里有if-else或者循环,Tracing只会记录当前分支的路径。比如:

class MyModel(nn.Module):
    def forward(self, x):
        if x.sum() > 0:
            return self.fc1(x)
        else:
            return self.fc2(x)

如果你给的dummy_input是正数,导出的ONNX里就只有fc1那条路。部署时遇到负数输入,模型就傻了。

我曾经踩过这个坑: 有一次导出BERT模型,因为输入长度不同导致控制流分支,结果ONNX推理结果和PyTorch对不上。排查了半天才发现是Tracing只走了其中一条路径。

解决办法有两个:

  • torch.jit.script 先把模型转成TorchScript,再导出ONNX
  • 或者手动保证dummy_input覆盖所有分支

4.4 动态轴与静态轴

ONNX默认是静态图,所有维度都是固定的。但实际部署时,输入batch_size经常变化。

PyTorch的 dynamic_axes 参数就是干这个的。我建议你养成习惯,只要不确定输入大小,就加上这个参数。

参数 作用 示例
dynamic_axes 指定哪些维度可以动态变化 {'input': {0: 'batch_size'}}
opset_version 指定ONNX算子集版本 opset_version=17
do_constant_folding 是否做常量折叠优化 do_constant_folding=True

这里有个小技巧:opset_version 尽量选新版本。新版本支持更多算子,兼容性更好。我一般用17或18。

4.5 算子映射:PyTorch → ONNX

PyTorch的每个操作,都要映射到ONNX的算子。大部分常见算子(Conv、ReLU、BatchNorm)都有对应关系。

但总有些「漏网之鱼」。比如 torch.einsum,ONNX早期版本就不支持。

我的经验: 如果导出时报错说某个算子不支持,可以试试用 torch.onnx.register_custom_op 注册自定义算子。或者干脆换个实现方式,比如把einsum拆成矩阵乘法。

PyTorch官方维护了一个算子映射表,我建议你导出前先查一下。特别是用了比较新的API时,比如 torch.nn.functional.scaled_dot_product_attention,在旧版ONNX里可能找不到对应算子。

4.6 验证导出结果

导出ONNX后,千万别直接拿去用。先验证一下:

import onnx
import onnxruntime as ort
import numpy as np

# 检查模型结构
onnx_model = onnx.load("simple_model.onnx")
onnx.checker.check_model(onnx_model)

# 对比推理结果
ort_session = ort.InferenceSession("simple_model.onnx")
input_data = np.random.randn(1, 10).astype(np.float32)
ort_output = ort_session.run(None, {'input': input_data})
torch_output = model(torch.from_numpy(input_data)).detach().numpy()

# 检查误差
np.testing.assert_allclose(ort_output[0], torch_output, rtol=1e-3, atol=1e-5)

我个人的习惯是:误差控制在1e-5以内。如果偏差太大,八成是算子映射出了问题。

4.7 常见问题与避坑

  • 动态图问题: PyTorch是动态图,ONNX是静态图。导出时会把动态图「拍平」成静态图,有些动态行为会丢失。
  • 数据类型: ONNX默认用float32。如果你的模型用了float16或double,导出时要注意类型转换。
  • BatchNorm和Dropout: 导出前记得调用 model.eval(),否则BN和Dropout的行为会不一致。
我曾经犯过的错: 导出时忘了设 model.eval(),结果ONNX模型里还带着Dropout,推理结果每次都不一样。排查了半天才发现是训练模式和推理模式没切换。

4.8 总结一下

PyTorch和ONNX的关系,就像「作者」和「出版社」。PyTorch负责创作,ONNX负责分发。

导出机制本身不复杂,核心就三步:

  1. 准备模型和示例输入
  2. 调用 torch.onnx.export
  3. 验证导出结果

但细节决定成败。动态轴、算子映射、验证精度——这些环节一个都不能省。

PyTorch → ONNX 导出流程 PyTorch 模型 训练好的 .pt/.pth torch.onnx.export 导出过程 Tracing 追踪 算子映射 常量折叠 ONNX 模型 .onnx 文件 验证步骤 onnx.checker.check_model → onnxruntime 推理对比 → 精度验证 关键参数:dynamic_axes | opset_version | input_names | output_names ⚠️ 注意:先调用 model.eval() 控制流需要特殊处理

好了,这一章就到这里。记住一句话:导出不是终点,验证才是关键

专注资料整理