模型导出与ONNX转换:从PyTorch到嵌入式的第一步
说实话,模型量化部署这件事,很多人一上来就盯着量化算法调参。但我个人的经验是——导出这一步要是没走稳,后面全是白搭。ONNX就像一座桥,桥没搭好,再好的车也过不去。
今天咱们就把这座桥怎么搭、怎么验、怎么调,一次性说清楚。
为什么非要用ONNX?
PyTorch模型在PC上跑得欢,但NPU不认识它。NPU只认自己的一套指令集。ONNX的作用就是做一个中间表示——把PyTorch的计算图翻译成NPU能理解的语言。
我在项目中遇到过好几次,模型在PyTorch里精度99%,转成ONNX后直接崩了。原因五花八门:算子不支持、动态轴没设对、Opset版本太老……所以这一步,咱们得认真对待。
核心原则:ONNX是中间桥梁,不是终点。导出质量直接决定后续量化和部署的成败。
PyTorch模型转ONNX:torch.onnx.export 实战
导出代码其实就几行,但参数设置才是关键。我直接给一个我常用的模板:
import torch
import torch.onnx
# 加载你的YOLO模型
model = torch.load('yolov8n.pt', map_location='cpu')
model.eval()
# 构造一个虚拟输入
dummy_input = torch.randn(1, 3, 640, 640)
# 导出ONNX
torch.onnx.export(
model, # 模型
dummy_input, # 输入张量
'yolov8n.onnx', # 输出文件名
export_params=True, # 导出参数
opset_version=11, # Opset版本
do_constant_folding=True, # 常量折叠优化
input_names=['input'], # 输入名称
output_names=['output'], # 输出名称
dynamic_axes={
'input': {0: 'batch_size', 2: 'height', 3: 'width'},
'output': {0: 'batch_size'}
}
)
print("ONNX导出完成!")
这里有几个坑,我一个个说。
1. Opset版本:选对才能跑
Opset版本说白了就是ONNX的“方言版本”。版本越高,支持的算子越多,但NPU不一定认识。
| Opset版本 | 特点 | 适用场景 |
|---|---|---|
| 10 | 基础算子,兼容性最好 | 老旧NPU、ARM CPU |
| 11 | 支持更多切片、拼接操作 | 大部分NPU,推荐首选 |
| 12 | 支持GatherElements等 | 新NPU,需验证 |
| 13 | 支持Softmax优化 | 特定NPU加速库 |
我的建议:先从Opset 11开始试。如果NPU报错说某个算子不支持,再降级到10。别一上来就追新版本,稳定第一。
2. 动态轴与静态轴:别让模型“僵住”
你想想看,训练时batch size可能是16,但部署时可能一次只处理1张图。如果导出时把batch size固定死了,推理时就只能按16来跑——浪费资源。
动态轴就是告诉ONNX:这个维度是可变的。我一般这样设:
dynamic_axes = {
'input': {0: 'batch_size', 2: 'height', 3: 'width'},
'output': {0: 'batch_size'}
}
但注意,不是所有NPU都支持动态轴。有些NPU要求所有维度固定,这时候就得用静态轴。我在一个项目里就吃过这个亏——NPU只支持固定640x640输入,我设了动态height/width,结果编译报错。后来改成静态才通过。
避坑指南:导出前先查NPU的文档,确认它支持动态轴还是静态轴。别想当然。
ONNX模型结构验证:导出不等于成功
导出完ONNX文件,你以为就完事了?我曾经有一次,导出很顺利,但加载到NPU上推理结果全是NaN。后来一查,是某个算子在ONNX里被替换成了不兼容的实现。
所以,验证这一步不能省。我一般做三层验证:
- 语法验证:用onnx.checker检查模型结构是否合法
- 形状验证:用onnx.shape_inference确认输入输出形状正确
- 数值验证:用onnxruntime跑一遍,对比PyTorch输出
代码示例:
import onnx
import onnxruntime
import numpy as np
# 1. 语法验证
onnx_model = onnx.load('yolov8n.onnx')
onnx.checker.check_model(onnx_model)
print("语法验证通过!")
# 2. 形状验证
inferred_model = onnx.shape_inference.infer_shapes(onnx_model)
onnx.save(inferred_model, 'yolov8n_inferred.onnx')
print("形状验证通过!")
# 3. 数值验证
ort_session = onnxruntime.InferenceSession('yolov8n.onnx')
ort_inputs = {ort_session.get_inputs()[0].name: np.random.randn(1, 3, 640, 640).astype(np.float32)}
ort_outputs = ort_session.run(None, ort_inputs)
print(f"ONNX输出形状: {ort_outputs[0].shape}")
print("数值验证通过!")
关键点:数值验证时,用同一张输入图对比PyTorch和ONNX的输出。误差在1e-3以内算正常,超过1e-1就要排查了。
Opset版本兼容性问题排查
这是最让人头疼的部分。你导出的ONNX在PC上跑得好好的,一上NPU就报错。常见的错误有:
- Unsupported Operator:NPU不认识某个ONNX算子
- Version Mismatch:Opset版本与NPU编译器不匹配
- Shape Inference Failed:动态轴导致形状推导失败
我的排查流程是这样的:
- 先看错误日志,定位到具体算子
- 查NPU的算子支持列表,看这个算子是否被支持
- 如果不支持,尝试用onnx-simplifier简化模型
- 还不行,就手动替换算子(比如把Softmax换成简化版本)
- 最后一步:降Opset版本
举个例子,YOLO的NMS(非极大值抑制)在ONNX里是个复杂算子,很多NPU不支持。我一般会在导出前去掉NMS,把检测框输出交给后处理来做。
# 去掉NMS的导出方式
class YOLOWrapper(torch.nn.Module):
def __init__(self, model):
super().__init__()
self.model = model
def forward(self, x):
# 只输出原始检测结果,不做NMS
return self.model.model(x)
wrapped_model = YOLOWrapper(model)
torch.onnx.export(
wrapped_model,
dummy_input,
'yolov8n_no_nms.onnx',
opset_version=11,
# ...
)
小技巧:用Netron工具可视化ONNX模型,一眼就能看出哪些算子可能出问题。我每次导出完都会打开Netron看一眼结构。
本章知识结构
下面这张图总结了模型导出与ONNX转换的核心流程:
嗯,这张图把整个流程串起来了。从PyTorch模型开始,经过导出、验证,最后部署到NPU。每一步都有坑,但每一步也都有解法。
我个人习惯是:导出前先查NPU文档,导出后做三层验证,遇到问题先降Opset版本。这套流程帮我省了不少时间。
最后提醒一句:ONNX导出不是一次性的。模型结构变了、NPU换了、Opset版本升级了,都得重新走一遍这个流程。别偷懒。