做模型部署的同学应该都有这种体验:PyTorch模型训练时怎么改都行,一旦到了要上线推理,第一件事就是把模型转成ONNX。尤其当你的模型forward里有多个输入参数,而且期望支持动态batch或者变长序列时,torch.onnx.export这行命令看似简单,实际配置起来有不少讲究。这篇文章就把整个流程摊开讲:为什么需要动态输入、多参数模型怎么声明、导出后怎么验证、常见的坑有哪些,适合正在做推理服务或者刚接触ONNX的同学。
说实话,早期我也觉得ONNX导出就是一行命令的事。真正开始做多输入模型的部署之后才发现,形状固化、输入顺序错乱、控制流被“拍死”这些问题一个接一个冒出来。如果你也有类似困扰,这篇东西应该能帮你少走不少弯路。下面从原理讲起,再给完整可用的代码和排查思路。
1. 先搞清楚:ONNX导出的底层逻辑
1.1 追踪式导出与“数据驱动”的本质
torch.onnx.export默认走的是torch.jit.trace这条路径。你可以理解为:PyTorch拿一组真实的样例输入,把模型的forward从头到尾实际跑一遍,同时记录每一个在Tensor上发生的操作,最后把这些操作翻译成ONNX的算子图。
这个机制带来的直接影响是:模型里凡是“依赖具体数据”的逻辑,比如if x.shape[0] > 10、while循环、for循环根据某个Tensor长度展开,都会在trace那一刻被固化成当前这次运行得到的分支。换句话说,trace看到的不是一个抽象的“程序”,而是“这一份输入走出来的执行路径”。
所以导出前第一件事就是把模型切成eval模式,并且准备一组尽可能接近真实部署场景的dummy输入。数值范围、维度、dtype都和线上一致,导出结果才靠谱。我见过有人用随机小数跑NLP模型,导出来精度对不上,最后发现是dummy输入范围太离谱导致某些数值分布差异太大,推理结果自然失真。
1.2 动态输入为什么必须在图里显式声明
如果不配置任何动态轴,ONNX里所有输入输出的shape都是固定死的。比如你用(1, 64)的输入导出,导出的模型就只认batch为1的输入。线上推理时如果来了一批batch=8的请求,runtime直接报错,因为你给的输入shape和图里记录的shape不匹配。
要让模型接受变化的维度,必须在导出时通过dynamic_axes参数告诉导出器:某个输入的第几维是动态的。这一步的效果,是在ONNX图中把对应维度标记为符号维度。runtime加载图之后,会依据实际输入推断出这个符号维度的具体值,并完成后面所有算子的动态形状传播。
很多新手容易忽略一个细节:动态轴不能只给输入配置。只要某个输出和输入共享同一批维度,输出侧也要一并标注。比如双塔模型的输出维度是[batch, 1],如果只把两个输入的第0维设为动态,而输出忘了标注,导出器在shape推断时依然可能把输出batch维当成固定值,后续接其他模型时就会对不上。
1.3 多参数模型在真实场景中的典型形态
“多参数”这个词在ONNX话题下,十有八九指的是模型forward接收多个输入Tensor。最常见的几类:
- NLP模型:
input_ids、attention_mask、token_type_ids一起进网络。 - 多模态模型:图像特征和文本特征分别作为两个输入,中间层再融合。
- 推荐排序模型:用户特征、物品特征、上下文特征并行输入。
- 双塔/孪生网络:两个分支各自吃一路输入,最后计算相似度。
这些模型如果打算上线,导出时就得把每一个输入都列进input_names,并且dummy_inputs的顺序必须和forward的参数顺序完全一致。顺序错一个,导出不报错,但runtime里拿到的张量可能张冠李戴,推理结果直接崩。
还有一种“非Tensor参数”也值得一提。比如forward里有num_layers=2这种Python整数,或者return_all=False这种布尔开关。trace机制下,这些值会作为常量固化到图里,运行时不支持动态修改。如果确实需要运行时传标量,一个可行的做法是把它做成Tensor,然后在模型内部用torch.where之类的方式参与计算,让它真正成为图里的一个输入节点。
2. 导出前的准备工作:检查清单与关键参数
2.1 模型结构自检三件事
导出前我会按下面三条过一遍模型:
第一个是确认model.eval()。Train模式下Dropout会随机mask,BatchNorm会用到当前batch的统计量,导出出的图要么包含随机性,要么bn参数被错误固化。我自己吃过亏,模型忘了切eval,导出倒是成功,但线上推理结果和测试集差了一截,查了半天才发现是Dropout的噪声节点被trace进图里了。
第二个是清理forward里的非必要返回值。有些项目为了训练方便,forward里返回了loss、aux_logits等一堆训练辅助信息。导出ONNX时这些返回值也会成为图的额外输出,既增加图大小,也可能引入训练阶段才存在的算子。建议单独写一个forward_export方法,或者用一个只含推理逻辑的包装类包一层。
第三个是构造dummy输入时注意dtype。PyTorch默认浮点是float32,但NLP模型的input_ids通常是int64。你用float的随机数去trace,虽然也能导出,但和真实输入类型不同,后续在onnxruntime里容易踩dtype不匹配的坑。正确做法是和线上完全一致:文本token用torch.randint生成int64,图像用torch.randn生成float32。
2.2 opset版本怎么选才不容易踩雷
opset决定了导出器可以用哪些ONNX算子来表达PyTorch操作。选太老,很多操作无法表示,比如torch.isfinite在opset 10之前的图里得靠别的算子拼;选太新,某些runtime(尤其是嵌入式设备、特定芯片的工具链)可能还没实现对应算子。这里没有一个绝对正确的答案,但有一个可复制的经验:
先查目标runtime文档里声明的最高opset支持,然后往下退一两个稳定版本。比如目标runtime最高支持opset 17,那导出时用opset 13或者15通常比较稳。纯粹做CPU/GPU服务端推理,opset 13基本够用;如果要在资源受限的盒子上跑,建议先从opset 11试起,算子兼容面更广,图也更“朴素”。
| opset版本 | 特点与建议 |
|---|---|
| 9 | 兼容性最广,适合老架构设备,但很多动态shape场景支持不佳 |
| 11 | 稳定常用,多数服务端runtime都支持,动态轴场景也能跑 |
| 13 | 对动态shape和部分新算子支持更好,建议默认从这个版本开始 |
| 17+ | 算子最全,但依赖runtime及时跟上,新平台慎用 |
还有一个参数叫do_constant_folding,默认是True。它在导出时会做常量折叠,把一些只依赖常量计算的子图直接算成常量。对固定shape模型很友好,但对动态shape模型,偶尔会把本应保持符号维度的计算提前折叠,导致动态维被意外固化。遇到“导出成功但形状不对”的怪问题时,把它临时改成False试试,往往是排查的突破点。
2.3 输入名、输出名与命名规范
input_names和output_names不是摆设。它们会成为ONNX图中输入输出节点的名字,也是onnxruntime里feed数据时用的key。命名时注意几点:
- 不要用带点号、斜杠、空格的名字,Netron里看着乱,有些工具还会解析出错。
- 尽量和模型代码里的变量名保持一致,比如
input_ids、attention_mask,方便同事看代码时对应上。 - 如果你打算把多个模型串成一个pipeline,输出名也会成为下游模型的输入名,提前约定好规范能省很多事。
默认情况下,如果没有配置名字,ONNX图的输入会叫0、1、2这种序号,输出叫output或者onnx::MatMul_0之类的自动名。自动名在复杂图里可读性极差,排查问题很不方便。所以只要不是临时实验,我都会习惯性配上名字。
3. 实际操作:一段代码搞定多参数与动态输入
3.1 定义一个带两个输入的演示模型
这里用一个非常简单的双塔模型做例子。它有两个输入q和kv,分别经过两个线性层投影,最后算点积相似度。这个结构虽然简单,但“多输入+动态batch”这两个核心问题都覆盖到了。
import torch import torch.nn as nn import torch.nn.functional as F class TwoTowerModel(nn.Module): def __init__(self, q_dim=64, kv_dim=64, hidden_dim=64): super().__init__() self.q_proj = nn.Linear(q_dim, hidden_dim) self.kv_proj = nn.Linear(kv_dim, hidden_dim) def forward(self, q, kv): q = F.normalize(self.q_proj(q), dim=-1) kv = F.normalize(self.kv_proj(kv), dim=-1) score = torch.sum(q * kv, dim=-1, keepdim=True) return score注意forward里有两个Tensor参数:q和kv。二者第一维都是batch,我们希望这个维度动态。后面导出时,这两个参数会分别对应图里的两个输入节点。
3.2 错误姿势:不配置动态轴的固定shape导出
先看一个反面教材。直接把模型导出,不配置任何动态轴:
model = TwoTowerModel() model.eval() dummy_q = torch.randn(1, 64) dummy_kv = torch.randn(1, 64) torch.onnx.export( model, (dummy_q, dummy_kv), "two_tower_fixed.onnx", input_names=["q", "kv"], output_names=["score"], opset_version=13 )这段代码能跑通,导出的ONNX也没问题。但打开Netron看一眼输入节点,会发现shape全是[1, 64]。之后在onnxruntime里一旦传入[8, 64]的batch,直接报错。很多初学者在这里就卡住了,以为是runtime问题,其实根源是导出时把shape写死了。这个例子说明一个关键点:固定shape导出不是“不能用”,而是“不灵活”,只适合线上输入维度永远不会变的场景。
3.3 正确姿势:dynamic_axes与input_names的配合
改法其实很直接:加上dynamic_axes,把需要变化的维度列出来。
torch.onnx.export( model, (dummy_q, dummy_kv), "two_tower_dynamic.onnx", input_names=["q", "kv"], output_names=["score"], dynamic_axes={ "q": {0: "batch"}, "kv": {0: "batch"}, "score": {0: "batch"}, }, opset_version=13 )这里的dynamic_axes是一个字典。key对应的是input_names或output_names里的某个名字;value是一个字典,表示“第几维”对应“什么符号名”。{0: "batch"}的意思就是把第0维标记为动态,并且给它起名叫batch。同一个名字的维度会被runtime视为同一个动态维度,这能帮助runtime正确推导跨输入输出的一致性。
如果你的模型还有序列长度这个维度,比如NLP模型输入是[batch, seq_len],写法就变成:
dynamic_axes={ "input_ids": {0: "batch", 1: "seq_len"}, "attention_mask": {0: "batch", 1: "seq_len"}, "logits": {0: "batch", 1: "seq_len"}, }还有一种简写方式也常见:dynamic_axes={"q": [0], "kv": [0], "score": [0]}。这种写法只声明“第0维动态”,不给符号维命名。优点是代码短,缺点是图里动态维读起来不直观,排查时不如命名版本清晰。我自己的习惯是优先用带名字的写法,尤其是模型复杂的时候,符号名能帮你快速定位维度传播链条。
3.4 tuple输出和多输出怎么处理
有些模型forward里不止返回一个东西,比如返回(logits, hidden_states),甚至返回一个dict。ONNX导出器对返回值类型有一定要求:最好是单个Tensor、Tensor元组、Tensor列表,或者Tensor字典。但实测下来,tuple在某些框架版本里容易触发导出警告甚至报错,稳妥做法是单独写一个推理出口方法,把要输出的Tensor们显式整理一次。
如果你希望ONNX图里同时输出多个Tensor,可以直接返回一个tuple。比如:
def forward(self, q, kv): q = F.normalize(self.q_proj(q), dim=-1) kv = F.normalize(self.kv_proj(kv), dim=-1) score = torch.sum(q * kv, dim=-1, keepdim=True) return score, q, kv导出时配置三个输出名:
torch.onnx.export( model, (dummy_q, dummy_kv), "two_tower_multiout.onnx", input_names=["q", "kv"], output_names=["score", "q_emb", "kv_emb"], dynamic_axes={ "q": {0: "batch"}, "kv": {0: "batch"}, "score": {0: "batch"}, "q_emb": {0: "batch"}, "kv_emb": {0: "batch"}, }, opset_version=13 )如果返回的是dict,导出器也能处理,但output_names的配置会麻烦一点。我记得不同PyTorch版本对dict输出的支持不太一致,有的版本会报“dict is not supported as output”。我的建议是:训练代码随便写,导出前统一改成Tensor或Tensor元组,省去兼容性烦恼。
真正麻烦的是内部逻辑里出现tuple、嵌套结构被当成中间变量。比如某个自定义层返回了(x, cache),后面又要x + cache这种操作。trace过程可能能处理,但导出来的图可能带有奇怪的Sequence类型中间节点,部分runtime对Sequence支持很差。遇到这种情况,优先重构模型内部逻辑,把中间结构拆开,而不是指望runtime去兼容。
3.5 动态与静态维度混用的细节
真实场景里,并不是所有维度都需要动态。比如图像分类模型,[batch, 3, 224, 224]通常只有batch是动态的,通道、高、宽都是固定的。此时dynamic_axes只声明batch维度即可,不要多余地把宽高也设成动态。动态维越多,runtime需要做的形状推断越复杂,图体积和推理耗时都可能上升。
反过来,如果模型内部有根据输入尺寸计算位置编码、生成mask的逻辑,而宽度高度又是固定的,那这些逻辑在trace时会被固化成常量图。这没问题,只是会让你失去“用同一份ONNX处理多种分辨率”的能力。如果确实需要支持多种分辨率,所有参与计算的输入和输出维度都要配齐动态轴,并且模型内部不能有对固定尺寸的硬编码假设,比如预设了shape是[224, 224]的常量buffer。
我的经验是:先按照最小需求配置动态维。比如上线初期只要求batch可变,那就只动batch。等到业务真的要求多尺寸输入,再重新导出也不迟。一开始就全部动态,排查问题的复杂度会指数上升。
4. 导出了不算完:如何用onnxruntime做完整验证
4.1 基础一致性校验的完整流程
导出成功只是第一步,线上能不能跑、跑得对不对,必须用onnxruntime实际推理验证。下面这段代码是最基础的校验流程:
import onnxruntime as ort import numpy as np import torch # 用PyTorch原模型计算参考输出 model.eval() with torch.no_grad(): ref_q = torch.randn(1, 64) ref_kv = torch.randn(1, 64) ref_score = model(ref_q, ref_kv) # 加载ONNX模型 sess = ort.InferenceSession( "two_tower_dynamic.onnx", providers=["CPUExecutionProvider"] ) # 看看图里的输入输出信息 for inp in sess.get_inputs(): print(f"input: name={inp.name}, shape={inp.shape}, type={inp.type}") for out in sess.get_outputs(): print(f"output: name={out.name}, shape={out.shape}, type={out.type}") # 喂数据时注意:key必须和input_names一致 ort_score = sess.run( ["score"], {"q": ref_q.numpy(), "kv": ref_kv.numpy()} )[0] # 对比结果 print(np.allclose(ref_score.numpy(), ort_score, atol=1e-6))有几个细节值得强调。第一,sess.run的输入feed是字典,key必须和导出时的input_names一致。如果你导出时叫q和kv,feed里也必须用这两个名字。第二,torch Tensor要先转成numpy,并且dtype要匹配。torch.randn默认float32,numpy也是float32,没有问题。如果模型输入是int64,记得用input_ids.numpy().astype(np.int64)。第三,atol的设置要看模型输出尺度。如果输出是0到1的概率,1e-6没问题;如果输出是loss量级上百,建议用rtol配合,比如np.allclose(..., rtol=1e-4, atol=1e-5)。
4.2 动态维度变换下的第二轮验证
静态shape验证通过,不等同于动态shape没问题。必须换不同维度的输入再试一遍。比如先用batch=1跑通,再改用batch=8:
q8 = torch.randn(8, 64) kv8 = torch.randn(8, 64) score8_ort = sess.run(["score"], {"q": q8.numpy(), "kv": kv8.numpy()})[0] print(score8_ort.shape)如果shape输出是(8, 1),说明动态batch生效了。如果是(1, 1),说明输出维度被固化了,回去检查dynamic_axes配置。对于NLP模型,还需要同时验证序列长度的变化,比如seq_len从10变成50,输出shape和数值都要合理。
这里我习惯上会做一个“循环压力测试”:写一个脚本,连续跑一批不同shape的输入,监控有没有报错、有没有shape异常、有没有nan。动态shape问题很多时候是间歇性的,某个特定shape组合才会触发图里的隐式错误,多试几组不同组合能提高发现概率。
4.3 工具链整理:查看模型结构的三件套
除了onnxruntime,还有三个工具我基本每次导出都会用:
第一个是onnx.checker,做基础合法性检查:
import onnx model = onnx.load("two_tower_dynamic.onnx") onnx.checker.check_model(model) print("model checked")这个检查能发现图结构层面的问题,比如节点连接错误、缺失输入输出定义之类。但它不检查数值正确性,所以不能替代上面的runtime对比。
第二个是onnx.shape_inference,查看经过shape推断后的各节点中间shape:
from onnx import shape_inference inferred = shape_inference.infer_shapes(model) for value_info in inferred.graph.value_info: shape = [dim.dim_value if dim.dim_value else dim.dim_param for dim in value_info.type.tensor_type.shape.dim] print(value_info.name, shape)如果某个中间Tensor的shape里出现dim_param(字符串),说明这个维度是动态的;如果全是数字,就是静态。这个工具在排查“为什么动态维没有传导到某个子图”时特别有用。
第三个是Netron。直接在浏览器里打开ONNX文件,可视化看整个图。排查复杂模型时,Netron能让你快速定位某个算子的输入输出名字,找到是哪一步把shape写死的。Netron还支持搜索节点,模型大到几百层时这个功能几乎是必备。
5. 避坑实录:我这几年遇到的典型报错与解决思路
5.1 控制流和动态shape:trace的天然痛点
最常见的一类问题是模型内部有Python控制流依赖Tensor信息。举个例子,有人写了一个这样的forward:
def forward(self, x): if x.shape[0] > 10: x = self.branch_a(x) else: x = self.branch_b(x) return x在trace时,dummy输入的batch如果是1,那x.shape[0] > 10为False,整个branch_a就不会出现在ONNX图里。线上来了batch=16的请求,branch_a根本不存在,模型行为完全错误。更坑的是这个错误不一定报错,可能只是精度悄悄下降。
解决办法有两个方向。第一,把依赖Tensor形状的逻辑改成与形状无关的形式。比如用torch.where做条件选择,让两个分支都参与计算再按条件组合。第二,把控制流放到Python侧、模型外部去处理。比如服务代码里根据batch大小选择不同ONNX模型,或者在预处理阶段就决定走哪条逻辑。
TracerWarning这个日志特别值得注意。导出时如果看到TracerWarning: Converting a tensor to a Python boolean might cause the trace to be incorrect,基本可以断定模型里有控制流被trace固定了,一定要去看代码,不要忽略警告。
5.2 不支持的自定义算子:替换策略与onnx-simplifier
第二种高频问题是模型里用了ONNX没有对应映射的算子。常见的有torch.topk、torch.unique、torch.nonzero、某些第三方库实现的自定义Op。报错信息一般长这样:Unsupported operator: NonZero或者No symbolic registered for: aten::unique_dim。
遇到这种情况,我的处理顺序是:
- 先尝试升级opset版本,有些算子在新opset里有了ONNX标准表示。
- 如果还不行,在模型内部用等价算子替换。比如
topk可以尝试用sort加slice组合;unique在大部分部署场景下其实可以用其他统计方式替代。 - 最后手段是注册自定义symbolic,告诉导出器遇到某个op时如何映射成ONNX节点。这个方案有一定门槛,而且runtime端也要有对应kernel支持,能用前两种方法解决就不要走这条路。
onnx-simplifier这类工具可以帮上忙,但不要神话它。它能做的只是对已有算子图做融合和常量折叠,解决不了“ONNX本身没有这个算子”的问题。换句话说,它能把图从“胖”变“瘦”,但不能把“不存在”变成“存在”。
5.3 导出了但精度对不上:排查顺序
导出过程没有报错,onnxruntime也能跑,但输出和PyTorch对不上。这种问题最磨人,我的排查顺序基本固定:
先确认模型状态。是不是eval训练不一致导致的dropout/bn差异。再确认输入的预处理是否一致。PyTorch里常用ToTensor做归一化除以255,而onnxruntime接收numpy时也要手动除以255,漏一步结果就差一大截。然后确认dtype和数值范围。NLP模型的padding位置在mask=0的地方,如果mask没有正确传入,计算结果自然不同。最后再对比默认值:检查是否使用了fp16,fp16下精度对不上通常表现为低位数误差,用atol=0.01这类阈值去比更合理。
还有一个容易被忽略的点:模型里是否有随机采样操作,比如torch.multinomial,即使模型在eval模式,每次推理结果也可能不同。这种操作一般不应该出现在推理图中,部署前最好移除或改成确定性的argmax。
5.4 实用检查清单:每次导出前过一遍
我把这些年踩坑的经验浓缩成了一份检查清单,每次导新模型前过一遍,能挡住绝大部分基础问题:
- 模型是否已切到eval模式。
- forward函数里是否有Python控制流依赖Tensor取值或shape。
- dummy输入的shape、dtype、数值范围是否和线上一致。
- 多输入时,dummy元组顺序是否和forward参数顺序完全一致。
- input_names/output_names是否清晰且无特殊字符。
- dynamic_axes是否覆盖了所有需要动态的输出维度。
- opset版本是否在目标runtime支持范围内。
- 导出后是否用onnxruntime跑过至少两种不同shape的输入。
- 是否用onnx.checker和shape_inference做过静态检查。
- 数值对比用的allclose阈值是否符合模型输出尺度。
这份清单看起来琐碎,但每一条背后都有真实事故。A组输入顺序搞反导致线上推理全错、因为没切eval导致结果随机、因为漏配动态输出导致下游模型shape对不上,这些都是我实际经历过的坑。
6. 最后分享一个小经验
做了这么久ONNX导出,我自己的体会是:不要等到模型完全定稿才开始做导出验证。每完成一个关键模块,就试着导出一次,哪怕只是固定shape,也能及早发现算子兼容问题。动态shape相关的配置也尽量在一开始就留好,等模型规模变大之后再回头改造,改动成本远高于一开始就规划好。
还有一个习惯也推荐给大家:把导出和验证流程写成脚本放进项目仓库。脚本里固定好opset版本、输入输出命名、动态轴配置,团队里任何人拿到模型都能一键导出,避免每个人各自写一套临时命令,最终线上跑的版本都和本地对不上。ONNX模型本身只是个“中间产物”,如何稳定、可复现地生成它,才是部署链路里真正值得花精力打磨的地方。