ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch转ONNX再转TensorFlow:跨框架模型转换全流程实践

PyTorch转ONNX再转TensorFlow:跨框架模型转换全流程实践

1. 折腾前,先搞清楚这条路到底有多难走

先说结论:PyTorch模型转ONNX,再转成TensorFlow格式,这套链路整体可行,但绝不是“一条命令搞定”的省心事。我最近在做一个跨平台部署的模拟项目X,团队那边要求最终产物必须用TensorFlow Serving来承载推理服务,而现有算法组交付的底模清一色PyTorch训练产物。为了不重写模型,我硬着头皮把整个转换链路走了一遍,踩坑数量比想象中多了一倍。

这个标题下的真实需求,绝大多数人都是一样的:训练在PyTorch,部署在TensorFlow生态。要么因为服务端基础设施锁死,要么因为业务方要求统一模型管理平台。不管哪种原因,转换的目标只有一个——在尽可能不打折扣的前提下,把“能训练的模型”变成“能上线跑的模型”。需要强调的是,这里面最大的风险从来不是转换工具本身,而是转换之后的数值一致性。看着脚本跑完,模型文件生成了,一载入推理,输出和原模型对不上,这种坑最折磨人。

适合看这篇文章的人,我建议是两类。一类是刚接触模型转换,想找一条比较省力的路径直接复现的;另一类是已经试过转换但卡在某个精度异常、算子报错的地方,想找排查思路的。下面我按转换阶段来写,每一段都是实际跑通的记录和改过的坑。

2. PyTorch转ONNX的核心细节与实操要点

2.1 export参数这样配,少踩一半坑

PyTorch官方提供了torch.onnx.export这个接口,表面上是把模型结构加权重导出成一个文件,实际上它内部执行的是符号级追踪(symbolic tracing),也就是用一组假输入把模型完整地跑一遍,把计算路径里的算子记录下来,映射成ONNX算子。理解这一点之后再配参数,心里就有底了。

我最常用的一套参数组合大概长这样:

import torch def export_onnx(model, dummy_input, onnx_path="model.onnx"): model.eval() with torch.no_grad(): torch.onnx.export( model, dummy_input, onnx_path, export_params=True, opset_version=13, do_constant_folding=True, input_names=["input_0"], output_names=["output_0"], dynamic_axes={ "input_0": {0: "batch_size", 2: "height", 3: "width"}, "output_0": {0: "batch_size"}, }, ) print(f"exported to {onnx_path}")

几个参数逐个说下我的理解。

export_params=True就是把权重一起导进去,一般默认就是True,但如果你加载的是预训练权重之后又改了部分层,一定要确认权重已经加载完毕再导出,否则会出现“结构对、权重错”的诡异问题。

opset_version是这个环节最关键的选择。ONNX算子集版本决定了解释器支持的算子范围,版本太低会导致某些算子无法映射,版本太高则后续在ONNX转TensorFlow时可能遇到新的兼容性问题。我在实践中倾向于用13,而不是追求最新。原因很简单:兼容性够用、生态成熟、各种转换工具的适配度最高。如果你用了比较新的模型结构,提示缺少某个算子,再试着升到15或17,但那是例外情况,不是常态。

do_constant_folding=True建议开着,它会把模型里那些在推理时固定不变的运算(比如某些reshape操作、常量拼接)在导出时就计算掉,让最终图更干净。这个地方要注意一个副作用,后面我会专门讲,常量折叠在某些场景下会意外地把输入节点也“吃掉”,导致转换后模型无法接收外部输入。

2.2 动态轴一定要调好,否则服务化部署直接卡住

dynamic_axes是我这次踩得最深的一个坑。最初我以为把batch设为动态就够了,结果转出来的模型在ONNX Runtime本地推理时一切正常,但换到TensorFlow Serving做服务化部署,同一套输入,输出的shape稳定为固定值,一旦请求里的batch size不是训练时设定的那个数,直接报shape不匹配。

原因就是我在导出时只固定了batch为1,其他维度也没设动态。实际业务里,图片尺寸在预处理阶段是可变的,尤其做检测或分割类任务,输入分辨率经常因为性能调整而改变。如果你只设了batch动态,宽高还是固定的,那换个分辨率就得重新导出模型,这个代价在项目阶段几乎不可接受。

正确做法是在dynamic_axes里把需要改变的维度全部标出来。上面代码里我把第0维(batch)、第2和第3维(高和宽)都标成动态了。中间那个通道维尽量别设为动态,部署时极少会动态改变通道数,设为动态只会增加转换和推理的复杂度。

dynamic_axes还有一点容易误会:它只是声明了哪些维度允许变化,并不意味着转换后的模型会自动推理出最优的dynamic shape策略。转换工具最后生成的是一个带动态维度的计算图,推理框架会按照实际输入来动态分配内存。所以在验证阶段,务必用至少两种不同shape的输入去测转换后的模型,确认真的能跑,而不是只在导出的dummy input上自嗨。

2.3 算子不兼容时的常规处理思路

PyTorch转ONNX的另一个高频问题是算子映射失败。常见报错长这样:

RuntimeError: Failed to export an operation...

这种报错信息往往不说清楚是哪个算子出了问题,非常劝退。实际排查时,我总结出一条比较快的路径:

先定位是哪一层。在导出时把torch.onnx.export包在try-except里,或者直接打开torch.onnx.utils._model_to_graph这一类内部调试接口,打印出当前导出的算子信息。更粗暴的办法是直接把模型拆成几段,分别导出,找到问题层。

找到问题层之后,看它用了什么算子。比较常见的几个原因:用了自定义的autograd.Function、用了torch.einsum的某些特殊写法、或者用了torchvision.ops里的高级算子。这类功能在ONNX里没有直接对应物,符号注册表里没有映射函数,自然导出失败。

面对这种情况,我的优先级是这样的:

第一,换一种等价的实现方式。比如某个自定义注意力模块里用了复杂的mask操作,换成标准的矩阵乘法加mask加法能表达同样逻辑,那优先改模型代码。这是最省事的办法。

第二,升级opset版本。有些算子在高版本里才有对应的ONNX定义,升级后能解决一部分问题。

第三,实在不行,给该算子注册自定义符号映射(symbolic function),用已有ONNX算子的组合去模拟它。这个方案工程量最大,而且会直接影响后面TensorFlow转换的兼容性风险,所以放在最后。

3. ONNX转TensorFlow的全过程踩坑实录

3.1 为什么在工具链里选中onnx2tf

目前ONNX转TensorFlow的可用方案其实不少。老牌的有onnx-tf,后来社区又出现了onnx2tf。两种我在不同项目里都摸过一遍,最终固定在onnx2tf上。

一个重要原因是onnx-tf的发展节奏太慢,对高版本opset的支持明显落后,很多ONNX模型转出来要么缺算子要么报错。而onnx2tf是由某研究团队持续维护的,内置了算子转换层,支持ONNX opset到十几个版本,同时能直接输出TensorFlow的SavedModel目录结构,这对服务化部署特别友好,省掉自己拼saved_model的步骤。

当然,它也有自己的脾气。最大的一个点是,转换过程里如果遇到不支持的TF算子,它不一定直接报错,而是可能直接跳过或者映射成一个不正确的替代算子,导致输出结果悄悄变偏。这种“静默错误”比直接报错更难定位,比例比较高的问题集中在某些具备状态性的操作、或者一些自定义的高阶算子上。

所以我的习惯是,转换完第一件事不是急着跑推理,而是检查转换日志里的warning信息。onnx2tf会在终端打印WARNING,里面列出它落掉的算子和替换逻辑。这一步看着麻烦,但能在早期拦下大量精度问题。

3.2 转换命令和参数,按这个来基本不翻车

onnx2tf的基础用法比较简单:

onnx2tf -i model.onnx -o output_dir

但我建议加几个参数,让流程更可控:

onnx2tf \ -i model.onnx \ -o output_saved_model \ --output_signaturedefs \ --copy_onnx_input_output_names_to_tf \ --overwrite

--output_signaturedefs会生成TensorFlow Serving需要的SignatureDef配置,这个在做服务化部署时几乎是必需的。如果不加,后面在Serving里调用时还要手动拼签名信息,非常麻烦。

--copy_onnx_input_output_names_to_tf会把ONNX里的输入输出名直接带到TensorFlow模型里。默认情况下,onnx2tf会生成类似input_1、input_2这种名字,有时候跟原模型的命名对不上。加上这个参数之后,输入名会沿用ONNX里的定义,比如input_0、output_0,这在我们对接后续推理代码时省了很多改名字的步骤。

另外有个参数--not_use_onnxsim,默认是自动用onnxsim做简化。onnxsim会对计算图做优化合并,大多数情况下是好事,但偶尔会把某些算子融合掉,导致数值出现微小变化。如果你要求转换前后完全一致,可以先禁用onnxsim,等转换完再对比精度,对比没问题了就不需要它。

转换完成之后,输出目录里会包含saved_model.pb和variables目录,这是标准的SavedModel格式,可以直接tf.saved_model.load加载,或者丢给TensorFlow Serving。

3.3 转换后结果不对,别急着怀疑工具,先查这几件事

转换后模型跑出来的结果和原PyTorch模型差别很大,这个坑我在项目里反复遇到。起初我也本能地怀疑是转换工具的问题,后来发现自己动手排查的话,大部分根因根本不在转换过程里。

第一个要检查的是预处理环节。PyTorch训练的模型,预处理经常直接写在torchvision.transforms里,比如先缩放到固定尺寸,再转换成tensor,再做标准化(减均值除方差)。这些步骤并不会被写进模型本身,转换工具也不会自动帮你搬到TensorFlow侧。如果你在TensorFlow推理时直接把原始图片喂进去,精度崩掉太正常了。

正确的做法是,在TensorFlow的推理代码里手动实现一遍相同的预处理逻辑。比如原模型用的是

mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225]

归一化方式,那在TensorFlow里就要先加载图片,转成float32,再除以255,然后先减去mean再除以std。这里有个常见的顺序错位:先减均值再除方差,和先除方差再减均值,虽然只是顺序差异,但结果完全不同,而且这类错误通常在肉眼观察输出时根本看不出来。

第二个要检查的是输入tensor的布局。PyTorch默认是NCHW,TensorFlow默认为NHWC。onnx2tf在转换时已经做了自动处理,但如果你在推理侧手动拼接输入的时候不留意,用了错误的layout,输出肯定是乱的。建议在推理代码里加一句shape断言,确认输入是(batch, height, width, channels)再送进模型。

第三个要检查的点比较冷门,但一旦遇上就很致命:模型导出时的状态。PyTorch模型如果是从训练流程里直接拿过来的,有些BatchNorm层或者Dropout层还可能处于“训练模式”,导出时使用的统计量和推理模式不同,生成的计算图在推理时行为会偏。所以在导出ONNX之前,必须确保调用过model.eval()以及with torch.no_grad(),这一点看起来基础,但我自己就犯过两次。

如果上面三件事都检查过,数值依然对不上,那再回头审视转换工具的输出日志。把warning里面标记过的算子拎出来,逐个对比原PyTorch算子的计算结果,这样定位起来反而更快。

4. 整体流程的通用问题与排查技巧实录

4.1 输出不一致时,两步定位到底偏在哪一层

跨框架转换最大的痛点就是出了偏差不知道是哪个环节引入的。我在实践中建立了一套固定的验证流程,建议你也这么干。

第一步,先把整条链路断成三截:PyTorch原始模型算一遍结果,ONNX Runtime加载ONNX模型算一遍结果,TensorFlow加载转换后的SavedModel再算一遍结果。三个结果放在一起,两两对比,第一轮就能知道偏差出在PyTorch到ONNX这段,还是ONNX到TensorFlow这段。这一步最关键,能省掉大量瞎猜的时间。

第二步,找到有问题的那段之后,再细分定位。如果是ONNX到TensorFlow的问题,就用控制变量法,把输入改成完全相同的随机tensor,确保预处理环节没有干扰,然后再逐层检查输出。onnx2tf转换后是图结构,可以用TensorFlow的tf.compat.v1模式逐层打印中间tensor做对比,但这个方式比较费时,一般我只在前一步确认问题确实出现在转换环节时才用。

数值对比的指标,我推荐两个:最大绝对误差(MaxAbsErr)和余弦相似度。用最大绝对误差能直观看到单点偏移,比如两个输出向量的最大值差了0.17,那就说明某个算子的计算行为发生了明显变化。用余弦相似度则能衡量整体方向是否一致,只要余弦相似度在0.99以上,基本可以认为推理语义没坏,只是浮点精度级别差异,这种差异在float32部署里通常可以接受。我自己的经验标准是:MaxAbsErr小于1e-4且余弦相似度大于0.999,视为验证通过。

4.2 常量折叠引发的输入消失问题

这个坑在很多团队里都被误解过,我这里单独拎出来讲。

前面提到导出ONNX时开了do_constant_folding=True,它会提前计算一些不依赖输入的操作。但如果模型里存在某些被误判为“常量”的输入,比如一个空转的reshape、或者一个带着固定索引的gather操作,折叠之后,这个原本属于外部输入的张量可能会被写死成常量,导致ONNX图里的输入节点消失,甚至后续转换到TensorFlow时,推理入口的输入签名对不上。

我遇到的一次实际情况是,模型里有个分支做的是“固定取特征图左上角区域”的操作,这段逻辑在训练时不会走,但推理时被触发。由于导出追踪时该分支的输入是一个固定shape的tensor,常量折叠直接把它处理成了固定tensor,最终ONNX模型的输入数量从1个变成了0个。

排查时最明显的特征是,用onnxruntime跑模型时,打印输入名发现完全对不上,或者模型结构里输入节点数量变少。

解决思路有两个。一个是在导出时关闭常量折叠:do_constant_folding=False。代价是图会更大,推理时会多一些重复计算,但安全性提升明显。另一个是在模型代码里,把这类看起来像常量的动态输入真正改成依赖输入tensor的计算方式,从底层避免被折叠。我个人更推荐前者,因为改动模型的逻辑容易引入新的不确定性,而动一发牵全身。

4.3 算子缺失时的应急处理:拆、换、绕

转换过程中最令人头疼的就是某个算子在新框架里不存在。比如PyTorch里某些高级索引操作,在ONNX里能表达,但ONNX转TensorFlow时,对应的TensorFlow算子类别可能已经废弃或实现方式不同。这种情况下,onnx2tf的日志里通常会提示某个算子被替换成了一个近似实现,或者干脆报错。

应急手段排序,我的经验从高到低分别是:

第一,拆。把算子拆成多个基础算子的组合。比如某个操作本质是reshape + transpose + matmul的复合,直接映射到TensorFlow时可能会迷失方向,但你把它拆成三步独立的计算,每一步都有明确对应算子,转换成功率就高得多。

第二,换。用原模型里的等价替代层。比如某个自定义注意力用einsum实现,换成普通matmul组合之后,很多转换问题自动消失,因为基础算子几乎没有兼容性问题。

第三,绕。如果以上都不行,可以在TensorFlow侧手动预计算这部分逻辑。把模型里特殊的后处理逻辑从网络里拿出去,放到推理代码里用TensorFlow原生API重新实现。严格来说这不是“绕”,而是把计算边界往后推到了服务代码里。好处是稳定可控,坏处是推理链路上多了一层人工维护逻辑,要小心和模型输出的对应关系保持一致。

这三招解决了我遇到的绝大多数算子兼容性问题。剩下那些实在绕不开的,基本是模型结构本身选型的问题,比如用了太多自定义的高阶算子,这种模型我一般会建议算法侧重构实现,而不是硬改转换链。

4.4 batch size与动态维度的终局处理思路

关于动态维度,转换之后还会有一个必须面对的终局问题:TensorFlow Serving加载模型成功后,模型本身是支持动态batch的,但推理框架在实际分配显存时可能还是会按照某个默认shape去固定。如果线上请求的batch size变化很频繁,你会看到内存不断波动,甚至偶发OOM。

处理动态维度,我自己最后的思路是,不在模型层面把dynamic shape能力做得太宽,而是固定几种常见尺寸:比如推理时统一缩放到416×416或512×512,batch固定为1或4,预处理在服务入口统一完成。这样模型转换时可以设定dynamic_axes里的batch和尺寸为固定的几个值,虽然灵活性低了一些,但换来的是稳定性和转换路径的更短可控。对于绝大多数生产场景,这个取舍是划算的。

5. 一条实测稳定的完整转换流程

前面把原理和坑都讲完了,这里给一套可以照着跑的完整流程,是我在项目X里最终沉淀下来的版本。

整个流程分五个步骤:

步骤一:准备模型和样本

确保模型是eval模式的torch模型,准备一个真实形状的dummy input,最好是用测试集里的一张真实图片预处理后的tensor,而不是随意生成的高斯噪声,因为某些算子在特定数值分布下行为会不同。

dummy_input = torch.randn(1, 3, 416, 416) # 结构确认用

步骤二:导出ONNX并记录初始验证

用前面给的export_onnx函数导出,然后立刻用onnxruntime加载同一个dummy input,对比PyTorch输出。这一步验证通过的标志是两者输出一致到浮点精度级别。

import onnxruntime as ort sess = ort.InferenceSession("model.onnx") onnx_out = sess.run(None, {"input_0": dummy_input.numpy()})[0]

步骤三:转换SavedModel

用上面给的onnx2tf命令,跑完第一件事看日志里的warning。没有关键warning之后,用TensorFlow加载模型做一次推理对比。

import tensorflow as tf loaded = tf.saved_model.load("output_saved_model") infer = loaded.signatures["serving_default"] tf_out = infer(tf.constant(dummy_input.numpy()))["output_0"].numpy()

步骤四:用测试集样本做批量化验证

用测试集里的多张图片,分别走PyTorch管道和TensorFlow管道,对比输出的MaxAbsErr和余弦相似度,记录报告。注意图片的预处理流程必须两套管道保持一致。

步骤五:接入TensorFlow Serving

确认数值一致后,把output_saved_model丢给Serving服务,按服务接口的签名配置好入参格式,再用同一批测试数据在线调用一次,确保服务化后的输出与本地推理一致。

到这一步,整条链路就算真正跑通了。

最后分享一点个人体会

这套转换链路我前后折腾了大约一周。回头去看,最消耗时间的事情反而不是工具本身用不熟,而是“以为转换完就万事大吉”的心态导致在验证环节偷了懒。如果一开始就老老实实按三段式链路,每一段都做数值对比,至少能省一半的时间。

另外有个小技巧值得分享:在整个转换链路调试期间,最好把三段输出的对比代码固定成一个脚本,任何一步改动之后都自动跑一遍,全部通过再往下走。我吃过一次亏,改了一个预处理参数之后忘了重新验证,结果整个下午都在查一个根本不存在的转换bug。这个脚本我现在还留着,后面每个模型转换都会复用。

关于这套流程的扩展,目前我在尝试的一步是让模型在完成转换之后保持动态shape输入,同时通过服务端的显存调度来支撑更大的并发。模型转换本身已经解决,后面的优化重心已经转移到了推理引擎的配置和业务侧的载荷结构。这个方向如果跑通了,后面有机会再单独写一篇补完。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进