4. PyTorch模型导出ONNX:torch.onnx.export详解、动态轴与静态轴设置
模型训练完了,总不能一直窝在PyTorch里跑吧?尤其是做YOLO推理,咱们得把它弄到ONNX里,再转成TensorRT,才能真正跑起来。这一步,说白了就是给模型换个“壳”,让它能在不同的引擎里跑。
我个人习惯,导出ONNX是整套流程里最容易踩坑的一步。你想想看,PyTorch里模型跑得好好的,一导出就报错,或者导出来精度不对,这种事我遇到过太多次了。今天咱们就把torch.onnx.export这个函数掰开揉碎了讲清楚。
4.1 torch.onnx.export 核心参数
这个函数,参数不少,但真正关键的其实就几个。我列个表,你一看就明白。
| 参数名 | 类型 | 作用 | 我的建议 |
|---|---|---|---|
model |
torch.nn.Module | 要导出的PyTorch模型 | 记得先调成eval()模式 |
args |
tuple | 模型的输入张量 | 形状要和实际推理时一致 |
f |
str | 输出ONNX文件路径 | 用.onnx后缀 |
export_params |
bool | 是否导出模型权重 | 默认True,别改 |
opset_version |
int | ONNX算子集版本 | YOLO建议用11或更高 |
do_constant_folding |
bool | 是否做常量折叠优化 | 推理用True,训练用False |
input_names |
list[str] | 输入节点名称 | 随便起,但要有意义 |
output_names |
list[str] | 输出节点名称 | TensorRT里会用到 |
dynamic_axes |
dict | 定义动态轴 | 这是重点,下面细说 |
核心要点:导出前,模型一定要调成model.eval()模式。我见过有人忘了这步,导出来的ONNX里还带着Dropout和BatchNorm的训练行为,推理结果完全不对。
4.2 静态轴 vs 动态轴
这个区别,我刚开始也迷糊过。说白了,静态轴就是固定死的,动态轴就是可以变的。
拿YOLOv8举例,输入一般是[1, 3, 640, 640]。这里的1是batch size。如果你只做单张图片推理,那batch size固定为1就行,这就是静态轴。但如果你要一次处理多张图片,比如[4, 3, 640, 640],那batch size就得是动态的。
为什么会这样?因为ONNX在导出时,如果指定了静态轴,它会针对那个固定形状做很多优化。动态轴则保留灵活性,但优化空间会小一些。
我的经验:做YOLO部署,我一般只把batch size设为动态轴。其他维度,比如高度、宽度,尽量固定。因为YOLO的输入尺寸通常是固定的(640x640),没必要动态。动态维度越多,导出的ONNX越复杂,后续转TensorRT也越容易出问题。
4.3 dynamic_axes 配置详解
这个参数是个字典,结构稍微有点绕。我给你拆开看。
# 假设输入输出名称
input_names = ['images']
output_names = ['output0']
# 动态轴配置
dynamic_axes = {
'images': {0: 'batch_size'}, # 输入的batch维度动态
'output0': {0: 'batch_size'} # 输出的batch维度动态
}
# 导出调用
torch.onnx.export(
model,
dummy_input,
'yolov8n.onnx',
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=11
)
你看,dynamic_axes的key是节点名称,value是另一个字典。那个字典的key是轴索引(从0开始),value是给这个轴起的名字。名字随便起,但最好有意义。
如果你有多个输出,每个输出的动态轴都要单独配。我曾经漏配了一个输出头的动态轴,结果推理时batch size一变,那个头就报错。
避坑指南:我曾经在导出YOLOv5时,把三个输出头的batch维度都设成了动态,但忘了把其中一个头的num_detections维度也设成动态。结果推理时,只要检测到的目标数量变化,模型就崩。后来排查了半天才发现是这个问题。
4.4 完整导出示例(YOLOv8)
咱们直接上代码。这是我常用的YOLOv8导出脚本,你拿去改改就能用。
import torch
import torch.onnx
def export_onnx(model_path, output_path, img_size=640, batch_size=1, dynamic=True):
# 加载模型
model = torch.load(model_path, map_location='cpu')['model'].float()
model.eval()
# 创建虚拟输入
dummy_input = torch.randn(batch_size, 3, img_size, img_size)
# 定义输入输出名称
input_names = ['images']
output_names = ['output0']
# 配置动态轴
if dynamic:
dynamic_axes = {
'images': {0: 'batch_size'},
'output0': {0: 'batch_size'}
}
else:
dynamic_axes = None
# 导出
torch.onnx.export(
model,
dummy_input,
output_path,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=11,
do_constant_folding=True,
export_params=True
)
print(f'导出成功: {output_path}')
# 使用示例
export_onnx('yolov8n.pt', 'yolov8n.onnx', dynamic=True)
注意:这里我用map_location='cpu'把模型加载到CPU上导出。因为ONNX导出最好在CPU上做,避免GPU上的随机性影响图结构。另外,model['model']这种取法是因为YOLO官方权重通常包在一个字典里。
4.5 导出后的验证
导出来不代表就对了。我每次导出完,都会用ONNX Runtime跑一遍,和PyTorch的结果对比一下。
import onnx
import onnxruntime as ort
import numpy as np
# 检查ONNX模型结构
onnx_model = onnx.load('yolov8n.onnx')
onnx.checker.check_model(onnx_model)
print('ONNX模型结构检查通过')
# 用ONNX Runtime推理
ort_session = ort.InferenceSession('yolov8n.onnx')
input_name = ort_session.get_inputs()[0].name
output_name = ort_session.get_outputs()[0].name
# 准备输入数据
dummy_input = np.random.randn(1, 3, 640, 640).astype(np.float32)
# 推理
ort_outputs = ort_session.run([output_name], {input_name: dummy_input})
# 对比结果(这里简化了,实际要比较数值差异)
print(f'ONNX Runtime输出形状: {ort_outputs[0].shape}')
这一步不能省。我见过有人导出的ONNX精度掉了0.5个点,就是因为某个算子不兼容。早点发现,早点排查。
4.6 常见问题与排查
导出过程中,你可能会遇到这些问题。我列个表,方便你对照。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 算子不支持 | PyTorch算子没有对应的ONNX实现 | 升级opset版本,或替换算子 |
| 动态轴报错 | 动态轴配置与模型实际结构不匹配 | 检查所有输入输出的轴索引 |
| 精度下降 | 常量折叠或算子精度问题 | 关闭do_constant_folding试试 |
| 导出卡死 | 模型太大或内存不足 | 减小输入尺寸,或分批导出 |
| 形状不匹配 | 动态轴导致输出形状变化 | 在模型中固定某些维度 |
小技巧:如果导出报错,先试试opset_version=17。新版本支持的算子更多,很多老版本不支持的操作在新版本里都能搞定。但要注意,TensorRT对高版本opset的支持可能不完整,需要权衡。
嗯,到这里,ONNX导出的核心内容就差不多了。你只要把torch.onnx.export的参数搞明白,动态轴和静态轴的区别弄清楚,导出这一步基本不会出大问题。下一节,咱们就要开始碰TensorRT了,那才是真正考验耐心的地方。