如果你手头有一个训练好的PyTorch模型,计划把它从GPU开发机搬到CPU服务器、移动端或者一块套着散热片的嵌入式板子上,那大概率绕不开“导出ONNX”这一步。最近我在做语音合成项目,正好走到了Phase A·Step 3:模型导出ONNX。这个过程里,PyTorch转ONNX、用ONNX Runtime做精度校验、再接着摸INT8量化和边缘端kmodel的适配,每一步都踩了不少坑。这篇就按我的实际流程来写,给同样卡在这个环节的朋友一些参考。
我这次导出的目标比较明确:让一个TTS(语音合成)模型能够被sherpa-onnx这类推理引擎直接加载,然后为后续量化、边缘端转换留好接口。整个链路不是“导出完就结束”,而是“导出完才开始”。如果你只是把图像分类模型转个ONNX做演示,可能不需要看完全文;但如果你想走PyTorch → ONNX → INT8/kmodel这一套,那这篇文章里的每个细节都值得认真读一遍。
1. 项目背景与ONNX导出这件事的本质
1.1 为什么项目里要专门做一个“模型导出”步骤
训练阶段的模型是个Python对象,内部带着优化器状态、梯度信息,甚至还有一堆只在训练时用到的辅助分支。比如我们的TTS模型里除了生成音频的主干,还有一个训练用的判别器辅助头,这部分在部署时完全不需要。直接拿训练完的模型到处跑,既不安全也不高效。更麻烦的是,如果只保存权重,部署端还要重建一模一样的模型类、再手动写推理逻辑,只要代码版本对不上,就会出现一堆诡异问题。
导出成ONNX,本质上是把计算图、权重、算子定义全部固化到一个独立文件里。这个文件不依赖PyTorch的运行时,任意支持ONNX的runtime都能加载。具体到TTS项目,模型里有文本编码器、说话人embedding、注意力模块、声码器等多个子模块,如果不用ONNX而是直接用LibTorch做C++部署,需要重新编译、对接大量自写算子,工程量会大很多。导出ONNX后,sherpa-onnx这类现成推理引擎直接就能加载,省去了大量工程适配。
所以我把“模型导出”专门拆成独立步骤,放在Phase A的Step 3,目的就是为了提前暴露部署问题。很多人训练完才想起导出,结果发现算子不支持、输入输出设计不合理,再回头改模型重新训练,成本极高。提前导出一版,哪怕先用一个中间checkpoint,也能帮你把整个部署链路的前置条件理清楚。
1.2 ONNX能帮我们解决什么,不能解决什么
ONNX是一个开放的神经网络交换格式,它的核心价值在于图描述能力。它记录的是张量如何在算子之间流动,不绑定任何单一框架。理论上,从PyTorch导出后,TensorFlow、JAX、C++、移动端、嵌入式端都能通过各自runtime来消费。实际中最常用的是ONNX Runtime,它提供了一个跨平台的统一推理接口,后面再接TensorRT、OpenVINO、ONNX Simplifier、量化工具等,生态非常完整。
但ONNX不是银弹。它最大的限制是算子覆盖度。PyTorch里一些自定义扩展、动态控制流、复杂shape操作,在ONNX里可能没有对应表达,或者映射出来的算子性能很差。特别是语音模型里常见的F.pad、F.interpolate、repeat_interleave这些操作,不同opset版本下语义和性能差异很大。另一个问题是:ONNX Runtime是通用执行引擎,某个模型在PyTorch里跑得快,不代表ONNX Runtime也跑得快,尤其是没有针对某个硬件后端优化时。
我常用一个类比:ONNX是通用插座,PyTorch模型是一个特殊插头。导出过程就是在做适配。适配得好,插上就能用;适配不好,要么加转接头,要么干脆换一个插头形状。
1.3 这次导出的目标与适用场景
这次导出有三个明确目标:第一,让sherpa-onnx TTS引擎能够加载我们的模型,输入文本ID序列,输出可播放的音频;第二,为后续做INT8量化留好接口;第三,保证ONNX模型可以由工具链进一步转成边缘设备上的kmodel。
因此每个导出决策都要考虑下游。比如输入节点必须用语义化命名,方便推理引擎对接;输入维度要么固定,要么在明确位置设动态轴;不要使用太冷门的算子,否则量化器或NPU编译器会直接不认。我在导出前就把这些约束写进了项目Checklist,后面省了大量返工时间。
如果你现在只是想把一个PyTorch分类模型转成ONNX跑个demo,那很多细节可以跳过。但如果你和我一样,走的是“PyTorch → ONNX → 量化/kmodel → 嵌入式推理”这条完整链路,那下面这些原理和参数配置,建议你仔细看完。
2. PyTorch到ONNX的核心原理与关键参数
2.1 torch.onnx.export到底做了什么
torch.onnx.export最常用的是tracing模式。工作方式是:给模型一组真实的张量输入,沿着执行路径把碰到的PyTorch算子逐一记录下来,转换成ONNX算子,并把中间张量之间的连接关系固化成计算图。因为是“照着实际输入走一遍”,所以模型里的if、for这种依赖张量取值或长度的控制流,tracing只能记录当前路径,不可能覆盖所有分支。这时需要改用torch.jit.script把模型脚本化,或者直接把控制流部分改写成固定结构。
语音合成模型的推理逻辑通常比较固定:文本ID进编码器、拿到注意力向量、再进声码器,过程中没有太多数据依赖的控制流,所以tracing完全够用。另外,torch.onnx.export不只是记录算子,它还会在do_constant_folding=True时做常量折叠:把推导结果不会变化的子图提前计算并固化下来,比如某些固定mask的Padding操作,运行时就不需要重复计算了。
还有一点容易被忽略:导出时模型一定要在eval模式。如果模型里有Dropout或BatchNorm,在train模式下导出,Dropout会随机丢弃节点,BatchNorm会继续用mini-batch统计量,导出的ONNX模型在这个状态下跑出来的结果跟预期会差很远。这个问题我在后面精度排查时还会细说。
2.2 必须盯住的几个参数
torch.onnx.export的参数很多,但真正需要盯住的就这几个:opset_version、dynamic_axes、input_names、output_names、do_constant_folding。
| 参数 | 作用 | 我的建议 |
|---|---|---|
opset_version | 代表ONNX算子集版本,决定哪些算子可用 | 13是相对稳妥的选择 |
input_names | 给输入节点命名,部署端会按名字引用 | 用语义化名字,如text、speaker |
output_names | 给输出节点命名 | 用audio之类易懂的名字 |
dynamic_axes | 声明哪些维度是动态的 | 只声明必要维度,宁缺毋滥 |
do_constant_folding | 是否做常量折叠 | 设为True |
export_params | 是否导出权重 | 默认True,不要关 |
opset_version这个参数很关键。版本越高,支持的算子越多,但runtime对高版本opset的兼容进度可能滞后。比如onnxruntime某些版本对opset 17以后的新算子支持不完整。我常用13,因为绝大多数部署引擎都能兼容。如果导出时报“某个算子不支持”,第一个尝试就是把opset升到15、17,有些问题确实升级就能解决。
dynamic_axes的使用也很有讲究。不要把每个维度都标成动态。动态维度一多,runtime的shape推断压力变大,性能优化也不充分。比如TTS模型,文本长度是动态的,那就只把text的第1维设为动态;说话人ID一般是固定维度,batch固定为1,就不需要动态batch。这样既支持变长文本,又不会给模型增加太多额外开销。
2.3 静态图与动态图:为什么需要显式声明动态维度
ONNX文件里每个输入都有shape。比如导出时输入text的shape是[1, 4],表示batch=1、文本长度=4。如果部署时来了一条长度是10的文本,静态shape模型会直接拒绝,因为输入shape不匹配。解决办法就是在导出时把某个维度标记为动态,例如将第二个维度命名为"seq_len",这样runtime就知道这一维度可以变化。
动态维度的代价是推理引擎无法针对固定shape做极致的缓冲区复用和算子融合,运行时有少量额外开销。但对于TTS任务,输入长度天然不固定,这个开销完全可接受。真正要注意的是:动态轴会影响shape推导。模型里如果有一连串的reshape、transpose、pad操作,某些runtime在动态shape下可能出现ShapeInferenceError,报错信息还很难看懂。遇到这种情况,我一般先降级:把动态轴减少到最少,或者直接用固定shape导出,看看是不是某个算子导致的。
3. 实操:把PyTorch语音模型导出为ONNX
3.1 导出前的模型清理与预处理
在写导出脚本前,我习惯先花10分钟做下面这些检查。这套流程看起来繁琐,但能避免后面反复调试。
第一步,确认模型结构是推理版。很多模型训练时带有额外的判别器、辅助分类头或者特征金字塔,导出的ONNX只需要生成音频的主干部分。如果直接对整个模型导出,不仅文件变大,还会引入无用算子。第二步,确认模型处于eval模式。如果你加载的checkpoint里还保留着BatchNorm的running_mean和running_var,eval模式会正确使用这些归一化参数。第三步,用一组正式推理时最可能用到的输入,先跑一遍前向,确认输出shape和数值范围。这样能提前发现输入dtype不对、维度顺序写错这类低级问题。
TTS模型的输入有讲究。比如文本ID序列的长度在推理时不确定,所以token_ids的seq_len要设置为动态。speaker_id通常来自固定集合,维度固定为1。如果模型内部用mask来忽略padding,mask是根据输入长度动态计算的,不用担心,tracing会把这种依赖输入值的计算路径记录清楚,只要输出shape不依赖具体文本内容就行。
另外要注意,导出过程必须包在torch.no_grad()里,并且把输入数据的requires_grad设为False。这一步不是可有可无,如果不做,计算图里会混入梯度节点,导出的ONNX文件可能无法在ONNX Runtime里执行,或者执行结果出现莫名其妙的偏差。
3.2 一次完整的导出代码及参数说明
这里给出一份我在TTS模型上实际用过的导出代码。不同模型的输入输出结构可能不一样,但流程完全可复用。
import torch import torch.onnx from model import MyTTSModel # 1. 加载模型权重并进入推理模式 model = MyTTSModel() checkpoint = torch.load("checkpoint.pth", map_location="cpu") model.load_state_dict(checkpoint["model"]) model.eval() # 2. 准备一组代表性输入 # 假设模型输入:token_ids(batch=1, seq_len), token_lens(batch=1), speaker_id(batch=1) token_ids = torch.tensor([[5, 12, 8, 1]], dtype=torch.long) token_lens = torch.tensor([4], dtype=torch.long) speaker_id = torch.tensor([0], dtype=torch.long) # 3. 配置动态轴 dynamic_axes = { "token_ids": {1: "seq_len"}, "token_lens": {0: "batch"}, "audio": {2: "audio_len"}, } # 4. 导出 with torch.no_grad(): torch.onnx.export( model, (token_ids, token_lens, speaker_id), "tts_model.onnx", input_names=["token_ids", "token_lens", "speaker_id"], output_names=["audio"], dynamic_axes=dynamic_axes, opset_version=13, do_constant_folding=True, )这里有几个容易踩的坑。args参数必须是tuple,顺序必须和模型forward方法入参顺序一致。如果你在模型里用的是forward(text, length, speaker),那这里也要按这个顺序放。dynamic_axes里的key必须和input_names/output_names里的名字完全一致,否则导出会报错。音频输出如果是[1, 1, audio_len],那么动态轴索引就是2,对应audio_len;如果你的模型输出是[audio_len],那就需要把索引改成0。不同结构差异很大,别照抄。
另外提一句,如果你用的是PyTorch 2.0以上版本,torch.onnx.export里有个dynamo=True参数。这个新的导出器基于Dynamo编译跟踪,对复杂控制流的支持更好,但对算子兼容性要求也更高。我实测下来,传统TorchScript-based导出器更稳,先跑通再考虑试dynamo。
3.3 导出后的第一步验证:用ONNX Runtime跑一遍
导出成功并不意味着万事大吉。我见过太多次导出很顺利,结果用ONNX Runtime一加载就报错。所以我的习惯是导出后立刻跑一个最小验证脚本,对比PyTorch输出和ONNX Runtime输出。
import numpy as np import onnxruntime as ort # 创建ONNX Runtime session session = ort.InferenceSession("tts_model.onnx", providers=["CPUExecutionProvider"]) # 准备和导出时一致的输入 input_data = { "token_ids": token_ids.numpy(), "token_lens": token_lens.numpy(), "speaker_id": speaker_id.numpy(), } ort_outputs = session.run(None, input_data) # 在PyTorch侧再跑一遍 with torch.no_grad(): torch_outputs = model(token_ids, token_lens, speaker_id) # 对比每个输出 for i, (ort_out, torch_out) in enumerate(zip(ort_outputs, torch_outputs)): torch_out_np = torch_out.detach().numpy() diff = np.abs(ort_out - torch_out_np).max() print(f"output {i}: shape={ort_out.shape}, max_abs_diff={diff}")对语音模型来说,PyTorch和ONNX Runtime的输出数值不可能是完全逐位一致,但最大绝对误差通常应该在1e-4级别以下。如果差距到了0.1甚至更大,就要排查。常见原因包括:模型还在train模式、导出权重没有正确加载、某些算子在不同opset下数值细节不同,比如Pad在不同版本里填充值默认值变了。但对于音频,单纯看最大绝对误差不够,我还会把ONNX Runtime的输出直接写成wav,跟PyTorch输出生成的文件对比听感。听感是最终标准,尤其对声码器这种对高频非常敏感的模块。
4. ONNX模型的后续处理:量化、简化与边缘端转换
4.1 用ONNX Runtime和torchao做INT8量化
ONNX模型不是终点,尤其是要部署到嵌入式设备上时,模型体积和推理速度都是硬指标。INT8量化是常用手段,它可以让模型体积缩到原来的四分之一,同时利用底层SIMD或NPU的向量化能力加速推理。
onnxruntime提供了两种量化方式:动态量化和静态量化。动态量化只把权重量化成int8,激活值在推理时仍然以float32计算,不需要校准数据集,词嵌入层、全连接层、LSTM这类结构经常能获得不错效果。静态量化则同时量化权重和激活,需要一个代表性校准数据集,推理速度一般更快,但精度损失可能更大。我的经验是:TTS模型先试动态量化,用quantize_dynamic一行代码即可。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "tts_model.onnx", "tts_model_dynamic_int8.onnx", weight_type=QuantType.QInt8, )如果你的TTS里包含卷积声码器,比如用大量反卷积做上采样,动态量化可能效果不明显,因为瓶颈在卷积计算。静态量化能更直接地加速卷积,但需要准备校准集。所谓校准集,不是标注数据,只是一组有代表性的输入样本,用于统计激活值的范围。校准集太单一,量化后某些输入会产生很大偏差;校准集太杂,模型可能被平均得过于保守,加速效果下降。这个权衡需要多试几轮。
量化后必须重新做输出对比。我遇到过最大绝对误差在0.1级别、但实际上把某个词的高频细节给抹掉的情况。所以不能只看误差数字,尤其语音合成,必须主观听音。如果听感变差了,优先回退到fp16或者动态量化,而不是硬撑着静态量化。
4.2 用onnxsim等工具简化模型
从PyTorch导出的ONNX模型里经常残留大量冗余节点,比如连续两个Transpose、无用的Shape、只做搬运的Identity。这些节点在PyTorch端不影响什么,但到了NPU编译器或者性能敏感的推理引擎里,会拖慢编译时间和推理速度。我一般会在量化前先用onnx-simplifier过一遍。
pip install onnxsim python -m onnxsim tts_model.onnx tts_model_sim.onnx简化器会做常量折叠、去重、删除无用的Identity节点、合并不必要的Reshape等。它还会在输出里打印简化前后的节点数。我实际操作中见过节点数从两千多降到一千五左右,首次推理时间也有明显下降。
但要小心,简化器不是万能的。有些特殊模型结构里,动态轴信息可能会被简化掉,比如某个维度的符号名被替换成具体数值,导致推理时遇到不同长度输入直接出错。所以简化完一定要再用ONNX Runtime做一遍输入验证和精度对比。另外,简化后的模型如果有自定义op或分域问题,onnxsim可能无法处理,那就别依赖它,手动删除几个已知冗余节点也行。
4.3 从ONNX到kmodel或其他边缘格式
如果你的目标板子是嵌入式NPU,最终交付的往往不是ONNX,而是硬件厂商自定义的模型格式,比如kmodel。转换工具通常是厂商提供的编译器,比如tpu-mlir一类,输入ONNX或PyTorch模型,输出kmodel可执行文件。
这一环节经常打回票。我见过最多的问题是:模型里用了NPU不支持的算子;某些Reshape/Transpose虽然能映射但效率极低;量化时没有准备合适的校准集;或者模型里带着动态shape导致编译器无法静态分配内存。所以要顺利转kmodel,导出ONNX时就得提前考虑:
- 输入shape尽量固定。很多NPU编译器只接受静态输入。如果产品需求确实支持变长输入,要么在编译器侧做padding,要么在模型输入侧强制设置一个最大长度,比如512。
- 算子保持简单。用ReLU、Concat、Split、Conv这些基础算子,避免用到复杂的归一化、Hardswish、GELU这些NPU不友好的激活函数。
- 先做简化再做量化。kmodel转换工具往往对模型的规范性要求很高,onnxsim预处理能提高一次通过率。
如果转换失败,不要急着改ONNX,而是先看日志里报错的是哪个节点,回到PyTorch模型里找到对应模块,用等价基础算子重写,再重新导出。这个过程确实枯燥,但属于嵌入式部署的正常节奏。
5. 导出过程中的常见问题与排查实录
5.1 算子不支持或无法映射
在导出过程中,我遇到最多的就是算子不支持报错。典型的提示长这样:
RuntimeError: Exporting operator 'aten::repeat_interleave' to ONNX opset version 13 is not supported.解决思路有几种。第一,尝试升级opset_version,比如从13升到17,很多新算子就补上了。第二,去模型代码里找到这个算子,用更底层的基础操作替代。比如repeat_interleave可以用expand加reshape实现;F.interpolate可以用ConvTranspose2d加PixelShuffle组合替代。第三,如果实在改不了模型,那就用onnx_graphsurgeon直接编辑导出后的ONNX图,把不支持的子图替换成自定义节点。这个方案最灵活,但维护成本也高。
我的建议是能改模型就不要硬挺。我们在TTS模型里曾经有一个F.interpolate,导出后量化工具始终无法正确处理。最后我把声码器中间的上采样模块换成了ConvTranspose2d + PixelShuffle的组合,虽然模型体积变大了一点,但导出、量化和NPU转换全部一次通过。这个取舍在嵌入式部署里很常见。
5.2 输入输出shape对不上
还有一种情况是导出成功,但ONNX Runtime加载时直接报shape错误。比如:
ValueError: Got invalid dimensions for input: token_ids, expected [1, seq_len], got [1, 10]排查思路很明确。第一步,用session.get_inputs()打印每个输入名和shape信息,确认动态轴是否真的写进模型。第二步,检查推理输入numpy数组的dtype。PyTorch的LongTensor对应numpy的int64,如果转成int32,可能不匹配。第三步,确认provider类型。如果你是CUDAExecutionProvider或TensorrtExecutionProvider,某些情况下它们要求重新指定shape,尤其TensorRT对动态shape需要额外配置。
如果实在卡住了,一个保守的绕法是把动态轴全部去掉,用部署时最常见的shape重新导出一版,比如[1, 128]。静态shape虽然不够灵活,但能把问题先跑通,之后再慢慢调动态轴。
5.3 精度波动和性能不如预期
导出后精度波动的原因,我总结了一个自查清单:
- 模型是否处于
eval模式?Dropout和BatchNorm在train模式下的行为会导致巨大差异。 - 加载的权重是否与模型结构对应?有时候只load了state_dict但没严格匹配key,导出后看似正常,实际权重值是乱的。
- 是否使用了过低的opset版本?比如opset 9和opset 13下Softmax的行为不同。
- 是否量化过头了?INT8量化后误差过大时,检查一下量化方式和校准集。
性能不如预期也很常见。ONNX Runtime默认不执行最激进的图优化,你可以显式设置optimized_model_filepath或者用graph_optimization_level调优。CPU机器上用CPUExecutionProvider时,要注意线程数设置是否合理;NVIDIA机器上要正确安装onnxruntime-gpu,并在providers里接入CUDAExecutionProvider。如果TTS推理特别慢,先用profiler看时间和节点分布,再决定是优化模型还是优化runtime配置。
我实际遇到过一种情况:模型推理本身不慢,但Python端把音频切成帧、拼帧、写文件的过程占了总耗时的一半。这种情况就别折腾ONNX了,好好优化工程代码更实际。
如果你也在做类似的模型落地,我个人建议不要等到“全部训练完成”再开始导ONNX。训练到中期就可以把其中一个中间checkpoint导出一版,把部署链路先跑通,后面替换正式模型就只是换权重的问题。我这次就把导出验证前置到了Phase A,后面每一步都顺畅很多。希望这份记录能帮你少踩几个坑。