模型导出ONNX这一步,看着简单,翻车概率其实不小。我最近在做Phase A的方案推进,第3步就是把手头的深度模型统一导成ONNX,为后面的推理引擎、int8量化、边缘端kmodel转换做好准备。这篇就把我在这个环节里做的事情完整拆一遍,包括导出流程、验证方法、量化思路和几个非常容易踩的坑。
ONNX全称Open Neural Network Exchange,是一个开放的模型中间表示格式。训练完的模型在PyTorch、TensorFlow里没法直接被大多数推理引擎和AI芯片加载,但几乎所有的推理框架、端侧加速库都支持ONNX。所以它本质上扮演了一个“通用交换语言”的角色。这篇适合正在做模型部署、算法工程化,或者想搞懂ONNX导出链路的人,即便你之前没接触过模型转换,按下面的流程走一遍也能自己完成任务。
1. 模型导出ONNX到底在解决什么问题
1.1 为什么部署链路都围着ONNX转
先聊个基础问题:为什么不能在PyTorch里直接部署推理?
PyTorch是训练框架,它的python前端、autograd机制、动态图带来很大灵活性,但推理时这些灵活性反而变成性能负担。你用torch.inference_mode跑模型,速度相比优化过的后端还是有差距,尤其在低延迟场景下。更关键的是,AI芯片厂商、边缘盒子、车机平台基本不会去适配PyTorch,它们提供的SDK通常只吃ONNX、TFLite这些标准格式。所以模型落地时,必须找个中间格式过渡。
把ONNX当“世界语”比较好理解:PyTorch写在训练侧的代码是中文,TensorRT、OpenVINO、ncnn、kmodel各讲各的方言。ONNX就是那个普通话,大家都能听懂,然后各平台再翻译成本地执行。这样模型只要导出一份ONNX,就能在CPU、GPU、NPU等不同硬件上流转。
从工程效率角度看,围绕ONNX的方案有几个明显优势:一是框架解耦,换训练框架不影响部署链路;二是调试方便,onnxruntime跑出来的结果可以直接和PyTorch逐层对比;三是生态成熟,量化、压缩、可视化工具都优先支持ONNX。我统计过,从PyTorch直接转TensorRT的成功率和从ONNX转的成功率差了很远,后者稳定得多,因为PyTorch转TRT要处理torch自定义算子和动态图边界,而ONNX已经把图固化下来了。
简单列个对比:
| 方案 | 算子兼容 | 部署覆盖 | 调试成本 | 性能控制力 |
|---|---|---|---|---|
| PyTorch直接部署 | 好 | 低 | 低 | 一般 |
| 转ONNX再部署 | 很好 | 高 | 低 | 强 |
| 各平台专属格式直转 | 看运气 | 低 | 高 | 强 |
我个人的结论是:除非你的模型极小、场景极其固定,否则先把模型导成ONNX再往下走,是风险最低、路径最清晰的做法。
1.2 导出前必须想清楚的三件事
导出不是“跑一个脚本就完事”,动手前应该把这三件事定了,不然导出来也是白搭。
第一,模型必须处于eval模式。很多人训练完直接拿训练模式导出,batch normalization和dropout的行为会全部乱掉。因为训练模式下BN用的是当前batch的统计量,常规推理用全局统计量,dropout会随机丢节点,导出的onnx里这些行为会被固化成错误版本。导出的模型训推不一致,部署阶段精度崩了还很难查。正确的做法是model.eval()之后再做一次inference、跑通再导出。
第二,输入输出边界要明确。模型是单输入还是多输入、输出有几个头、中间有没有自定义的可变长结构,这些决定了torch.onnx.export的参数怎么传。比如OCR类模型经常有第二个输入“文字长度”,TTS模型有控制音色的extra输入,这些都要在导出时通过tuple传给dummy input数组,同时在input_names里一一对应。
第三,动态维度要提前规划。同一个ONNX模型可以固定所有输入shape,也可以让某些维度动态。batch是动态的、长宽是不是动态、动态轴叫什么名字,这些在导出时要统一约定。如果后面接TensorRT,动态维度过多会大幅增加引擎构建难度和显存占用,所以没有强需求就尽量只让batch维度动态。
还要提一下opset版本。opset可以理解为ONNX算子标准的一个版本号,版本越高支持的新算子越多,但过高的opset会丢失某些老推理引擎的兼容性。我一般用opset 12或13,既能覆盖绝大多数PyTorch算子的导出,又不至于太新导致部署平台的runtime不认。opset 11之后的Split、Slice行为变化较大,如果碰到算子导出报错,可以先尝试把opset降到11或12,再不行就考虑替换算子。
2. 标准导出流程与YOLO实战
2.1 用torch.onnx.export走一遍标准流程
下面这个流程适用于大多数PyTorch模型,不管是分类、检测还是分割。完整代码大概是这样:
import torch # 1.加载模型和权重 model = MyModel() ckpt = torch.load('best.pt', map_location='cpu') model.load_state_dict(ckpt['model']) model.eval() # 2.构造一个虚拟输入,shape必须和训练时一致 dummy_input = torch.randn(1, 3, 640, 640) # 3.导出 torch.onnx.export( model, dummy_input, 'model.onnx', input_names=['images'], output_names=['output'], dynamic_axes={'images': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=12, do_constant_folding=True, ) print('export done')几个参数逐个说明一下。
model:导出的模型,必须是eval状态。建议在导出前先把模型实际跑一次model(dummy_input),避免某些懒加载层在导出时才初始化,导致导出过程出现奇奇怪怪的报错。load_state_dict之后也不要立刻导出,先确认权重确实加载成功了,两个办法:一是打印模型输出看是否和训练日志一致,二是看strict=False时有没有missing/unexpected key的警告,有的话权重根本没对齐。
dummy_input:样例输入。它的shape决定了模型的输入特征。如果你的模型支持多输入,这里传一个tuple,比如(dummy_image, dummy_length),顺序必须和forward里的参数顺序一致。很多人在这里栽跟头,因为多输入模型里某个输入可能是int型或list,没转成tensor直接报错。
input_names/output_names:给输入输出起名字。这不是可有可无的,因为后面用onnxruntime推理、转TensorRT、转kmodel都要靠名字索引张量,起名要简洁且固定,不要用容易和其他节点混在一起的散乱名字。我习惯统一叫images、output这类明确的名字,多个输出就按语义起,比如output_large、output_mid、output_small。
dynamic_axes:把维度标为动态。上面只把batch设为动态,高度和宽度在训练时如果也是可变的,可以把它们也加进去。但记住:不是所有模型都允许在任意尺寸下推理,很多模型内部有固定尺寸的reshape,加了动态维度导出会直接失败。所以这个参数要结合模型结构谨慎开启,不是为了动态而动态。
opset_version:基本按前面说的选12或13。do_constant_folding=True:让常量折叠跑一下,把不需要计算的常量子图直接算完,能减小模型体积,也能让算子图更干净。对Transformer类模型影响尤其明显。
导出完成之后,先把文件大小看一下。ls -lh model.onnx,如果文件大小和训练好的权重差距过大,比如模型权重500MB,onnx只有几KB,大概率是权重没有被正确绑定,回头检查一下load_state_dict是不是用对了。
2.2 YOLO系列导出:把后处理留在图外
YOLO是目标检测里最常碰到需要导出的模型。YOLOv5和YOLOv8都提供了官方导出脚本,可以直接用。
YOLOv5的导出命令:
python export.py --weights yolov5s.pt --include onnx --opset 12YOLOv8的导出命令:
yolo export model=yolov8n.pt format=onnx opset=12这两个命令不只是调torch.onnx.export,还会做模型简化、输入输出名统一、输出维度检查。所以我在项目里一般优先用官方脚本,而不是自己手写导出代码,这样至少能拿到官方踩过坑以后的标准输出。
但这里有个关键点:官方导出出来的是一个“裸检测头”输出,通常包含3个不同尺度feature map,每个头输出类似(1, 4 + num_classes + 1, N)这样的张量。非极大值抑制(NMS)、anchor decode这些后处理,默认不会进ONNX图。这样设计是有原因的:不同部署框架的NMS实现差异很大,GPU端有TensorRT的EfficientNMS,CPU端有OpenCV DNN的NMS,端侧芯片又各有各的实现,如果在ONNX图里固化了某个NMS实现,反而会限制后续平台的移植。
所以我的做法是:ONNX里只保留网络前向输出,后处理写在推理代码里,并且用numpy或C++按同一个逻辑实现。这样做的代价是部署代码稍多,但换来的是模型可以到处跑。你要是图省事,把NMS也一起导进ONNX,后面换平台基本就得重新导出重来,得不偿失。
YOLO导出还有一个历史坑:老版本YOLOv5的focus层用了自定义的slice拼接,某些算子库不认,但现在的新版本基本都改成了普通slice,问题不大。如果你还在维护老模型,遇到focus算子报错,可以先把模型升级到新版,或者手动把focus结构替换成标准卷积+slice。另外YOLOv8里面用到了DFL(Distance-based Feature Loss)的reshape和softmax组合,导出时如果opset太低也容易报错,所以opset尽量提到12或13。
导出之后不要急着走下一步,先用官方脚本生成的model.onnx和model.pt在相同输入上对比一下输出,确认两边输出值一致。尤其是多输出模型,输出顺序很可能不同,PyTorch里是[pred_small, pred_mid, pred_large],ONNX里可能是反的。不对比直接拿去部署,后面必爆雷。
3. 导出之后的验证与可视化
3.1 用onnxruntime验证推理闭环
模型导出完,第一件事不是去量化、不是去转格式,而是先用onnxruntime跑一遍,确认推理闭环是通的。onnxruntime是微软的跨平台推理引擎,对ONNX支持最完整,查问题用它最顺手。
安装很简单:
pip install onnxruntime如果你有GPU并且想测GPU推理性能,装GPU版:
pip install onnxruntime-gpu然后写一小段推理脚本:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession( 'model.onnx', providers=['CPUExecutionProvider'] ) # 打印输入输出信息 for inp in sess.get_inputs(): print('input:', inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print('output:', out.name, out.shape, out.type) # 构造与导出时一致的输入 fake_input = np.random.randn(1, 3, 640, 640).astype(np.float32) # 推理 outputs = sess.run(None, {'images': fake_input}) print('onnx output shape:', outputs[0].shape)sess.run(None, feed_dict)的第二个参数是输入字典,key必须和导出时的input_names一致。这是最常见的报错点:名字对不上,直接抛异常。None表示返回所有输出,也可以传一个输出名的列表,只取你需要的输出。用名字索引而不是下标,在多输出模型里会省很多事。
接下来是最重要的精度对比。把同一张图(真实数据,不是随机的)分别喂给PyTorch模型和ONNX模型,对输出做逐元素的差值和close判断:
torch_out = model(torch.from_numpy(test_img).float()).detach().numpy() onnx_out = sess.run(None, {'images': test_img})[0] print('max abs diff:', np.abs(torch_out - onnx_out).max()) print('allclose:', np.allclose(torch_out, onnx_out, rtol=1e-4, atol=1e-4))一般FP32模型导出后,最大绝对误差在1e-5以下,用rtol=1e-4, atol=1e-4能通过就行。如果误差大,优先查前处理。常见问题有:PyTorch推理时用了RGB,onnxruntime侧却是BGR;或者PyTorch侧做了resize,部署侧忘了做。这类问题在导出阶段不暴露,在量化、裁剪、边缘端转换阶段会被放大,到时候更难定位。
我还习惯把网络每一层输出都导出对比一遍,但操作起来麻烦,实际项目中用输入输出整体对比就够了。如果整体对不上,再用netron看关键节点,跟踪到中间层去定位。如果整体能对得上,那说明模型结构和权重绑定都没问题,可以放心往下走。
3.2 netron可视化与onnxsim模型简化
导出和验证之后,建议用netron打开模型看一眼。netron是一个ONNX可视化工具,在线版和桌面版都有,直接把.onnx文件拖进去就能看到计算图结构。我主要看几件事:模型输入输出是否和我们预期一致;有没有诡异的extra节点,比如为了同步进程或打印信息引入的记忆/常量节点;dynamic维度是否正确显示,动态轴会被标成对应的名字。
netron看到的是图结构,不是运行结果,它解决“结构对不对”的问题,精度对不对还得靠第3节前面的推理对比。而且netron加载大模型时偶尔会卡,几百MB的模型拖进去需要等一下,桌面版要比网页版流畅不少。
模型简化的工具推荐onnxsim,它能做常量折叠、冗余节点删除、算子合并。用法是:
python -m onnxsim model.onnx model_sim.onnx或者用Python API:
import onnxsim import onnx sim_model, check_ok = onnxsim.simplify( 'model.onnx', dynamic_input_shape=True ) onnx.save(sim_model, 'model_sim.onnx') print('check:', check_ok)onnxsim做完简化后,模型文件通常能小一点,推理速度在个别框架上也能提升。但要注意,如果模型里有自定义算子,onnxsim可能不识别,这时候就不要强行简化,保持原图即可,否则可能把自定义算子相关的关键节点错误合并,导致推理失败。判断标准是简化后重新用onnxruntime跑一遍精度对比,全close才算简化成功。
4. 再往下走:int8量化、kmodel转换与端侧TTS
4.1 ONNX模型做int8量化:收益和精度损失先算清
ONNX导出的模型默认是FP32,很多场景下直接上FP32推理没问题,但如果你要在CPU、移动端、边缘盒子上跑,速度往往不够。int8量化是目前最常用的提速手段之一。结合“onnx量化int8”这个搜索热点,说说实操。
先理解量化为什么有效:FP32的张量用8bit整数表示,模型体积直接缩小到四分之一,计算时整数运算在CPU和NPU上又比浮点快得多。量化公式可以简化为:
real_value = scale * (quant_value - zero_point)其中scale和zero_point每个张量有一组,per-tensor模式共用一组,per-channel模式按输出通道各算一组,后者精度损失更小,但部署侧支持程度不同。
onnxruntime提供了现成的量化工具。静态量化需要先准备一个校准数据集,跑几十到几百张图统计激活值的范围,然后调用quantize_static:
from onnxruntime.quantization import QuantType, QuantFormat, quantize_static, CalibrationMethod from onnxruntime.quantization.calibrate import CalibrationDataReader class MyCalibDataReader(CalibrationDataReader): def __init__(self, dataloader): self.iterator = iter(dataloader) self.input_name = 'images' def get_next(self): batch = next(self.iterator, None) if batch is None: return None return {self.input_name: batch} calib_dataloader = load_calibration_images(count=200) quantize_static( 'model.onnx', 'model_int8.onnx', calibration_data_reader=MyCalibDataReader(calib_dataloader), quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QInt8, weight_type=QuantType.QInt8, calibrate_method=CalibrationMethod.MinMax, )校准数据大概是50到200张图就够了,太少范围估计不准,太多浪费时间。用MinMax是最基础的方法,如果精度掉太多,可以换成Percentile或者Entropy,用熵校准分布,通常精度更好。
量化后一定要做完整的精度评测。我的经验是:分类模型量化后掉0.5到2个点很常见,检测模型有时会掉3个点以上,如果掉得过多,先试per-channel量化,再试激活保留int8但权重用uint8,或者干脆只量化权重不量化激活(W8A32)。不要一股脑追求int8,业务场景精度过不了,速度再快也没用。
实测收益方面,在Intel CPU上用onnxruntime跑int8量化模型,推理延迟通常能降到FP32的50%以下,极端情况下能到30%。如果你们的服务真的被CPU推理性能卡脖子,这条路值得重点评估。不过同样要留意,量化模型的算子支持范围不如FP32模型全,个别算子在量化后性能反而更差,记得在目标硬件上逐个算子实测,不要只看理论加速比。
4.2 从ONNX转kmodel:边缘AI芯片的落地路线
如果你做的是边缘AI盒子、摄像头这类产品,“onnx转kmodel”这个词大概率会出现在你搜索记录里。kmodel是算能平台(比如K510、CV181x这些芯片)使用的模型格式,它并不直接加载ONNX,中间要经过一个工具链转换。
流程基本是:ONNX -> nncase编译(含量化校准) -> kmodel。也就是说,你在onnxruntime里做的量化经验在这里能直接迁移一部分,因为它内部也要做量化。
大致命令形式:
nncase_compile \ --target k510 \ --input model.onnx \ --output model.kmodel \ --quant-type int8 \ --dataset-calibration calibration_images \ --input-shape 1,3,640,640不同SDK版本命令参数有差异,但核心选项就是模型输入路径、目标平台、量化方式、校准数据集。转换过程中最容易爆的几个问题:
- 算子不支持。nncase对某些注意力机制里的reshape、split、gather组合支持有限,遇到报错先看是哪一层,尽量把这一层改成标准算子再转。
- 输入layout不同。PyTorch默认NCHW,但某些芯片SDK期望NHWC,要么在转换参数里指定,要么在量化脚本里加上layout转换节点。
- 校准数据格式。kmodel的量化同样需要校准集,格式要和你后续部署时喂给模型的真实输入完全一致,包括归一化方式、通道顺序、是否除以255。
这些细节会直接影响最终在板子上的推理效果。我的习惯是:在转kmodel之前,先在onnxruntime里把前处理、后处理都写成独立函数,板端代码直接复用这一套逻辑,保证从ONNX验证到kmodel部署,数据处理链路一字不差。
4.3 sherpa-onnx:ONNX在端侧TTS上的典型应用
ONNX不光用在视觉模型上,“sherpa onnx tts engine”这个词你可能也刷到过不少次。sherpa-onnx是一个基于onnxruntime的开源端侧语音推理框架,支持语音识别、语音合成、VAD等方向,它的核心思路就是把模型统一转成ONNX,然后通过一个小巧的引擎跑在手机、树莓派这类设备上。
安装特别简单:
pip install sherpa-onnx以TTS为例,先下载一个支持ONNX导出的语音合成模型(比如vits系列的onnx版本),然后:
import sherpa_onnx tts_config = sherpa_onnx.OfflineTtsConfig( model=sherpa_onnx.OfflineTtsModelConfig( vits=sherpa_onnx.OfflineTtsVitsModelConfig( model='vits_zh_aishell3/model.onnx', tokens='vits_zh_aishell3/tokens.txt', lexicon='vits_zh_aishell3/lexicon.txt', dict_dir='vits_zh_aishell3/dict', data_dir='vits_zh_aishell3/espeak-ng-data', ), ), ) tts = sherpa_onnx.OfflineTts(tts_config) audio = tts.generate('今天天气不错,适合出门跑步。') sherpa_onnx.write_wav('output.wav', audio.samples, audio.sample_rate)这个例子里的模型、词表、词典路径要根据你下载的目录调整,但框架逻辑就这样:模型是ONNX,引擎加载之后直接生成采样点数据,再落成wav。
sherpa-onnx这个项目的思路很值得参考:它不是只跑一两个模型,而是把语音领域常见模型都导成了ONNX形成统一入口,这样在端侧生成、推理、量化都是一套Pipeline。对普通工程师来说,它也是一个很好的学习案例,看看别人怎么把ONNX导出、动态维度、int8量化这些事揉进一个稳定框架里的。
5. 导出与转换路上的常见坑和排查清单
5.1 算子不支持:最容易卡住的一类问题
导出ONNX或者后续转平台时,“算子不支持”是出现频率最高的报错。归纳成一张表,方便排查:
| 报错类型 | 出现阶段 | 常见原因 | 处理方式 |
|---|---|---|---|
| Couldn't export Python operator | torch.onnx.export阶段 | 模型里有自定义Python层 | 改用PyTorch原生算子实现,或用torch.onnx.register_custom_op注册 |
| Unsupported operator: xxx | 转TensorRT/kmodel阶段 | 目标平台不支持某算子 | 替换成等价标准算子;拆分后处理;升级平台SDK |
| Unknown type | 量化阶段 | 模型中混入了fp64或int64类型 | 统一转float32,int64的输入数据转int32/int64按需处理 |
| Some ops are not supported by onnxsim | onnxsim阶段 | 有自定义算子 | 放弃简化或手动指定skip_fusion |
处理这类问题的通用思路:先定位到具体的层,用netron看那个节点的输入输出是什么,在当前算子库里有哪个等价物,尽量不要为了实现一个功能硬造复杂算子组合,因为部署平台的支持列表远不如PyTorch那么宽容。
定位算子位置的小技巧:报错日志一般会给出节点名,像/model/block1/conv1/Conv这种。把名字复制到netron的搜索框里,回车就跳到对应节点了,然后你看它的算子和前后接的是什么,判断能不能换掉。这个操作我在项目里用过很多次,比翻源码快得多。
5.2 动态shape带出来的连锁反应
动态维度这个设计,导出时很灵活,部署时常常出幺蛾子。我遇到过不少次“ONNX导出没问题,转TensorRT失败”的情况,最后定位都是动态维度引起的。
典型例子:某个网络在forward里对输入做了torch.arange(0, h)这类基于shape的计算,如果导出时把h设为动态轴,arange的长度也跟着变,转换器对这类动态控制流支持很差,就会报错。
我的建议是分策略处理:如果模型推理时输入尺寸是固定的,比如分辨率锁定640x640,那就干脆全部固定shape,最简单可靠。如果需求就是多尺寸输入,那就把动态范围缩小到最小集,比如只允许batch为动态,其他全部固定,然后针对不同shape分别导出或转换。不要幻想一个ONNX万能兼容所有尺寸、所有平台,工程上往往得不偿失。
动态shape还有一个隐性成本:动态维度的模型在转TensorRT时,通常要额外设置优化Profile范围(比如min/max shape),显存分配也会预留最大值,导致单次推理显存占用偏高。所以能用静态就静态,能固定就固定,这句建议在很多项目里都被验证过。
5.3 接口名不一致和精度对不齐
最后还有个高频死角:模型接口名不统一。ONNX导出时把输入叫成input.1、输出叫成output0,或者多个输出顺序颠倒了,都会在部署代码里反复踩坑。
排查方法:用ort.InferenceSession.get_inputs()把名字和shape打印出来,跟部署代码里的feed dict逐一核对。多输出时特别注意顺序,很多框架返回的output list顺序和导出时不完全一样。建议在导出时手动指定output_names,比如['output_large', 'output_mid', 'output_small'],用名字索引而不是下标,能少很多坑。
精度对不齐的问题,90%都在前处理。我会专门写一个前处理单元测试,同一张图同时过PyTorch和ONNX链路,检查缩放算法、归一化系数、通道顺序是否一致。这一步做好,后面不管转int8还是转kmodel,排查范围都能收窄很多。剩下10%集中在后处理,比如sigmoid是否被忽略、阈值比较方向反了、置信度和类别索引的位置理解错了,这类问题在写后处理代码时对照netron里最后的输出命名,基本能一眼看出来。
个人体会,模型导出ONNX本身不复杂,真正花时间的是“把验证闭环做扎实”。我在Phase A第3步的主要产出,除了一个能跑的ONNX文件,还有三个固定脚本:PyTorch推理对比脚本、onnxruntime推理脚本、前处理一致性测试脚本。之后每次模型更新,直接跑一遍三个脚本,输出对比都对得上,才敢往量化、转kmodel这些方向推。导出只是开始,验证才是关键,这条经验我在多个项目里反复验证过,希望对你也有用。