3、模型导出与ONNX转换:PyTorch模型转ONNX,解决动态轴与算子兼容性问题
说实话,模型训练完了只是第一步。真正要把模型塞进嵌入式设备,你绕不开ONNX这个中间格式。我刚开始做部署时,总觉得PyTorch模型直接转ONNX就是一行代码的事,结果被各种报错折磨得够呛。今天咱们就把这块硬骨头啃下来。
3.1 为什么非得转ONNX?
你想想看,PyTorch模型在PC上跑得飞起,但到了ARM Cortex-M或者NPU上,它根本不认识。ONNX就像个通用翻译器——把PyTorch的"方言"转成业界通用的"普通话"。我个人习惯是:所有模型先转ONNX,再转目标平台的格式。这样万一换平台,只需要改最后一步。
ONNX的好处很明显:
- 跨框架互通:PyTorch训练的模型,可以部署到TensorRT、OpenVINO、Tengine等推理引擎上
- 静态图优化:ONNX是静态图,推理引擎可以做常量折叠、算子融合等优化
- 量化支持:很多量化工具都基于ONNX做后训练量化
核心要点:ONNX不是最终部署格式,而是中间桥梁。嵌入式部署的典型链路是:PyTorch → ONNX → Tengine/MNN/NCNN → 板端运行。
3.2 基础导出:一行代码搞定?
嗯,这里要注意。PyTorch官方提供了torch.onnx.export接口,但绝不是简单传个模型就行。我见过太多人直接复制官方示例,结果导出后推理结果全是错的。
先看一个最基础的导出示例:
import torch
import torch.onnx
# 假设你有一个训练好的轻量化语义分割模型
model = LightSegModel()
model.load_state_dict(torch.load('best_model.pth'))
model.eval()
# 构造一个虚拟输入
dummy_input = torch.randn(1, 3, 256, 256)
# 导出ONNX
torch.onnx.export(
model, # 模型
dummy_input, # 虚拟输入
'lightseg.onnx', # 输出文件名
input_names=['input'], # 输入节点名称
output_names=['output'], # 输出节点名称
opset_version=11, # 算子集版本
do_constant_folding=True # 常量折叠优化
)
print("ONNX导出成功!")
这段代码看着简单,但有几个坑。我刚开始做的时候,opset_version随便填了个13,结果板端推理引擎不支持,又得重导。建议先用11,兼容性最好。
3.3 动态轴:输入尺寸不固定怎么办?
语义分割模型有个特点——输入图像尺寸可能变化。比如你训练时用256x256,但实际部署时可能是320x240或者别的分辨率。这时候就需要动态轴。
为什么会这样?因为ONNX默认把所有维度都固定死了。你导出时传了(1, 3, 256, 256),它就认为输入永远是这个形状。推理时换个尺寸,直接报错。
解决办法是显式指定哪些维度是动态的:
torch.onnx.export(
model,
dummy_input,
'lightseg_dynamic.onnx',
input_names=['input'],
output_names=['output'],
dynamic_axes={
'input': {0: 'batch_size', 2: 'height', 3: 'width'},
'output': {0: 'batch_size', 2: 'height', 3: 'width'}
},
opset_version=11
)
这里dynamic_axes字典的key是节点名称,value是维度索引和别名。我一般把batch、height、width都设成动态,这样部署时灵活很多。
避坑指南:我曾经在动态轴设置上栽过跟头。如果你模型里有nn.Upsample或者F.interpolate,动态尺寸导出时可能会报"size mismatch"错误。解决办法是把size参数改成scale_factor,或者用align_corners=False。
3.4 算子兼容性:最常见的翻车现场
算子兼容性问题,说白了就是PyTorch里有的操作,ONNX不支持。我统计过,80%的导出失败都是这个原因。
常见的"问题算子"包括:
| PyTorch算子 | 问题描述 | 解决方案 |
|---|---|---|
torch.einsum |
ONNX不支持爱因斯坦求和 | 拆成reshape+matmul |
F.adaptive_avg_pool2d |
输出尺寸为1时没问题,动态尺寸可能报错 | 用avg_pool2d配合固定kernel |
torch.topk |
ONNX opset 11以下不支持 | 升级opset或用sort替代 |
nn.Dropout |
推理模式下没问题,但训练模式导出会报错 | 确保model.eval() |
torch.cat + 动态轴 |
某些推理引擎对cat的维度有限制 | 尽量在固定维度上cat |
我记得有一次,模型里用了torch.meshgrid做坐标映射,ONNX死活不支持。最后我手动写了个循环替代,虽然效率低点,但至少能导出。
3.5 验证导出的ONNX模型
导出完别急着部署,先验证一下。我习惯用ONNX Runtime做本地推理,对比PyTorch和ONNX的输出差异:
import onnx
import onnxruntime as ort
import numpy as np
# 1. 检查ONNX模型结构
onnx_model = onnx.load('lightseg.onnx')
onnx.checker.check_model(onnx_model)
print("ONNX模型结构检查通过!")
# 2. 用ONNX Runtime推理
ort_session = ort.InferenceSession('lightseg.onnx')
ort_inputs = {ort_session.get_inputs()[0].name: dummy_input.numpy()}
ort_outputs = ort_session.run(None, ort_inputs)
# 3. 对比PyTorch输出
with torch.no_grad():
torch_output = model(dummy_input)
# 计算误差
diff = np.abs(ort_outputs[0] - torch_output.numpy())
print(f"最大误差: {diff.max():.6f}")
print(f"平均误差: {diff.mean():.6f}")
一般来说,误差在1e-5以内就算正常。如果误差很大,说明导出过程中某些算子被替换了,需要排查。
重要提醒:ONNX Runtime的推理结果和PyTorch不可能完全一致,因为浮点运算顺序不同。但如果你发现误差超过1e-3,那肯定有问题。我曾经遇到过nn.BatchNorm2d在导出时被融合进了卷积,导致精度下降,最后不得不手动拆开。
3.6 轻量化模型的特殊处理
对于轻量化语义分割模型(比如MobileNetV3+LRASPP、STDC、PIDNet等),有几个额外注意点:
- 深度可分离卷积:ONNX对
nn.Conv2d的groups参数支持良好,但某些推理引擎对groups>1的卷积优化不够。我建议导出后检查一下ONNX图,看看有没有被拆成多个普通卷积。 - SE模块:Squeeze-and-Excitation里的
sigmoid和mul操作,ONNX支持没问题。但如果你用了hard_sigmoid,记得用relu6(x+3)/6替代,兼容性更好。 - 上采样层:语义分割模型最后通常有上采样。我建议用
nn.Upsample(scale_factor=4, mode='bilinear', align_corners=False),不要用size参数,否则动态尺寸导出会报错。
3.7 实战经验总结
最后分享几个我踩过的坑:
- 导出前一定要
model.eval()。我刚开始忘了这步,结果导出的ONNX里还带着Dropout,推理结果随机变化。 - 虚拟输入的shape要和训练时一致。如果你训练时用了
torch.cat或者torch.add,输入尺寸变了可能导致shape推导失败。 - opset_version不是越高越好。高版本支持更多算子,但嵌入式推理引擎可能不支持。我一般用11,兼顾功能和兼容性。
- 用
onnx-simplifier简化模型。导出后的ONNX可能包含很多冗余节点,用python -m onnxsim lightseg.onnx lightseg_sim.onnx可以去掉这些冗余,减少模型体积。
嗯,模型导出这块就讲这么多。说白了,ONNX转换就是个"翻译"过程,翻译得对不对,得靠验证说话。下一章咱们聊聊如何用Tengine在ARM板上跑起来这个ONNX模型。