把PyTorch训练好的模型搬上手机端,几乎绕不开“Pytorch转ONNX再转MNN”这条路。很多搞部署的朋友第一次跑这条链路时,多半会在导出ONNX、模型简化、MNN转换这几个环节里卡上几天。这篇文章我就把这条链路完整捋一遍,包括每一步为什么这么做、核心参数怎么选、踩过的坑怎么排,尽量让还没入门的同学少走弯路。
这条链路解决的痛点很直接:PyTorch模型训练起来很顺手,但对移动端、嵌入式设备并不友好;ONNX是当前生态里最通用的模型中间格式之一,相当于一座“高架桥”;MNN则是阿里巴巴开源的端侧推理框架,对Arm等硬件平台做了深度优化,在手机上跑模型首选的方案之一。适合的人群是:正在做模型端侧部署的算法工程师、搞App集成的移动开发,以及想系统了解模型转换原理的学生。文章会从环境准备讲到算子排查,全程用可复现的例子说话。
1. 为什么是ONNX和MNN:转换链路背后的部署逻辑
1.1 PyTorch模型部署的天然短板
PyTorch的设计重心在训练,而不是部署。训练阶段我们看重的是灵活的动态图机制、便捷的自动求导、丰富的算子层,但这些特性到了端侧就成了负资产。手机上没有CUDA,没有动辄几十GB的内存,也不方便装一整套Python环境。即便强行把PyTorch的LibTorch库集成进App,包体积和启动开销也够喝一壶的。更关键的是,PyTorch模型文件的组织方式、内部图结构,在硬件加速器上很难直接跑起来,需要一层一层翻译适配。
我见过不少团队第一步就想直接把.pt或.pth文件扔给端侧框架用,结果发现要么不被支持,要么只能回调CPU算子,性能完全达不到预期。所以在工业界,模型转换时通常会把训练框架产出的模型先转成一种“中间表示”,再交给具体的推理后端。ONNX就是这个“中间表示”里的主流选择。
1.2 ONNX凭什么当“中间翻译官”
ONNX的全称是Open Neural Network Exchange,它定义了一套与具体框架无关的计算图描述格式。你可以把它理解成视频领域里的MP4:原始素材可能是各种设备拍的,编码格式五花八门,但只要统一转成MP4,几乎所有播放器、剪辑软件、平台都能用。ONNX之于模型,就是这个MP4。
PyTorch侧提供了torch.onnx.export这个官方导出接口,能把训练好的模型固化成静态计算图,导出成.onnx文件。这个文件里记录了算子的类型、张量的形状、权重参数、图结构等关键信息。之后无论你想转MNN、TensorRT还是OpenVINO,只要对方支持ONNX解析,就能顺着这条路走通。相比之下,如果每个框架之间都直接互转,复杂度会呈指数增长,而引入ONNX这个“统一中转站”,就把多对多转换简化成了多对一、一对多。
1.3 MNN:端侧推理的落地实处
ONNX解决了“表达能力”和“通用性”的问题,但它偏重格式标准,不会针对某一款手机芯片做极致优化。真正承担移动端推理性能的是MNN这类推理引擎。MNN的典型特点是:提供了图优化、算子融合、内存复用、多线程调度等一系列后端优化手段,并且对Armv7、Armv8、x86等CPU指令集有针对性优化,也能跑在部分GPU后端上。
从实际链路来看,PyTorch是源头,ONNX是枢纽,MNN是终点。最终交付出去的产物是一个.mnn模型文件和配套的推理代码,App在运行时再也不用关心模型当初到底是用什么框架训练的。
2. 环境准备与工具链:把版本兼容性摆在第一位
2.1 需要安装的核心组件
这条链路涉及的工具比较多,但核心就围绕四项:
| 组件 | 作用 | 获取方式 |
|---|---|---|
| PyTorch | 训练模型、导出ONNX | pip install torch |
| onnx | 解析和检查ONNX模型 | pip install onnx |
| onnxsim | 模型简化工具,去掉冗余算子 | pip install onnx-simplifier |
| onnxruntime | 验证导出的ONNX在推理引擎中的行为 | pip install onnxruntime |
| MNN | 端侧推理框架和模型转换工具 | git clone MNN仓库 + pip相关组件 |
建议把所有工具装在同一个Python环境里,避免多个Python版本切换带来的混乱。我自己习惯用Anaconda单独建一个虚拟环境专门做转换实验,系统环境尽量保持干净,这样可以大幅减少依赖冲突的排查时间。
2.2 版本组合的重要性
转换这件事,版本匹配往往比代码本身更容易影响成败。PyTorch导出的ONNX算子版本用opset_version这个参数来控制,而MNN在解析ONNX时会有自己支持的opset范围。
我常用的稳定组合是:Python 3.8、PyTorch 1.10~2.0、onnx 1.12以上、MNN 2.x。一般导出ONNX时选择opset_version=11,这个版本在MNN上的兼容性最好,算子覆盖面已经足够广。如果PyTorch版本较新,默认opset可能是14甚至更高,除非模型里用了高版本才支持的算子,否则我建议手动降回11,能少踩很多解析器的坑。
提示:把“版本”当作转换链路里的第一等公民对待。升级PyTorch或MNN之后,老模型如果转换逻辑出现诡异报错,优先怀疑版本不匹配,而不是模型写错了。
3. PyTorch导出ONNX实操:核心参数逐个拆解
3.1 导出前的模型处理:eval模式不是小事
导出ONNX前,必须把模型切换到评估模式,也就是调用model.eval()。这一个操作影响非常大,模型里的Dropout和BatchNorm层在训练模式和评估模式下行为完全不同:Dropout在评估模式下会直接变成恒等映射;BatchNorm会改用训练阶段统计得到的running_mean和running_var,而不是当前batch的统计数据。如果不切eval直接导出,得到的ONNX计算图里可能带着训练态的行为,部署后推理结果就会出现莫名其妙的偏差。
用代码表示就是:
import torch import torchvision.models as models model = models.resnet18(pretrained=True) model.eval()很多新手漏了这步,导出倒是没报错,但一对比精度就发现对不上,最后排查半天,根源只是少了这一行。这是我想强调的第一个实操细节。
3.2 torch.onnx.export的完整参数说明
核心导出代码如下:
import torch dummy_input = torch.randn(1, 3, 224, 224) input_names = ["input"] output_names = ["output"] torch.onnx.export( model, dummy_input, "resnet18.onnx", export_params=True, opset_version=11, do_constant_folding=True, input_names=input_names, output_names=output_names, dynamic_axes=None )这段代码里,model就是准备导出的模型;dummy_input是“占位输入”,它的形状决定了ONNX计算图中张量的静态形状,所以dummy_input的形状必须和真实推理时的输入一致。如果实际输入图片是224x224,这里就用(1, 3, 224, 224),batch先按1处理。
export_params=True表示把模型权重也一并导出,这个参数一般保持默认True;opset_version控制算子集版本,前面提到建议11;do_constant_folding=True开启常量折叠,会把那些只依赖常量就能算出结果的子图提前算好,减少推理时的计算量;input_names和output_names给输入输出张量起名字,后续在MNN和推理代码中会用到这些名字。
3.3 动态维度dynamic_axes怎么用
上面的示例里dynamic_axes设成了None,意味着输入输出形状完全固定。但实际部署中,经常遇到batch大小不定或输入分辨率变化的情况。比如检测模型在一个batch里处理不同数量的图片,或者输入图片尺寸不固定。这时需要用dynamic_axes把某一个维度标记成动态。
写法如下:
dynamic_axes = { "input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size", 2: "height", 3: "width"} } torch.onnx.export( model, dummy_input, "resnet18_dynamic.onnx", opset_version=11, input_names=["input"], output_names=["output"], dynamic_axes=dynamic_axes )其中0、2、3指维度下标,后面的字符串是给这个维度起的别名。看起来方便,但动态维度在转换MNN时是会引入额外处理逻辑的,性能上可能比静态图差一点。我的习惯是:凡能用固定形状解决的场景,绝不开动态维度;只有真正需要多变输入时,才启用dynamic_axes。
4. ONNX模型后处理与数值验证
4.1 导出成功后别急着转MNN,先做结构检查
得到一个.onnx文件才完成了一半路程。我强烈建议在转换MNN之前,先用Netron可视化工具打开模型看一眼结构,确认输入输出shape是否符合预期,算子连接是否合理。Netron是纯网页工具,直接把.onnx文件拖进去就能看图,强烈推荐每次都过一遍。
除了人眼观察,还要用onnx官方检查器做一次自动校验:
import onnx model = onnx.load("resnet18.onnx") onnx.checker.check_model(model) print("模型结构合法")check_model会逐项检查模型的基础结构、图拓扑、节点属性是否有明显问题。如果这段没报错,说明ONNX文件本身在语法层面是合法的。这时候再用onnxruntime跑一次推理,确认数值层面也没问题。
4.2 onnxsim模型简化
原始PyTorch导出的ONNX模型往往会带着一些冗余节点,比如Identity、Shape、Gather等小算子,它们对推理没有实质贡献,却会在转换MNN时增加不必要的解析负担,甚至触发MNN里某些算子的兼容性问题。解决方案就是用onnxsim做简化。
命令很简单:
python -m onnxsim resnet18.onnx resnet18_sim.onnx --input-shape 1,3,224,224onnxsim做了常量折叠、冗余节点消除、输入输出重写等工作,能让模型结构更干净,更贴近推理引擎真正需要的计算图。我实测过不少模型,简化之后MNN转换的成功率和运行速度都有提升。
简化完成后,还要再做一次数值比对,确保简化前后模型行为一致。
4.3 用onnxruntime跑一次推理对比精度
onnxruntime是微软开源的ONNX推理引擎,功能上相当于“用ONNX格式跑一次推理”。在这里用它做对照实验,主要看PyTorch模型和ONNX模型的输出是否一致。
import numpy as np import onnxruntime as ort import torch # 构造输入 input_data = np.random.randn(1, 3, 224, 224).astype(np.float32) # PyTorch推理结果 with torch.no_grad(): pt_output = model(torch.from_numpy(input_data)).numpy() # ONNX Runtime推理结果 sess = ort.InferenceSession("resnet18_sim.onnx", providers=["CPUExecutionProvider"]) ort_output = sess.run(None, {"input": input_data})[0] # 对比 diff = np.abs(pt_output - ort_output).max() print("最大绝对误差:", diff)如果最大绝对误差在1e-4、1e-5这个量级,说明导出过程没有破坏数值语义。如果误差达到小数级别甚至整批结果面目全非,大概率是模型没有切eval模式、某个算子映射异常,或者模型本身存在训练态依赖。
5. ONNX转MNN实操:从命令行到推理验证
5.1 获取MNN转换工具
MNN的转换工具目前集成在MNN仓库中。最省事的方式是直接从GitHub克隆MNN仓库,仓库里的tools/converter目录下有转换脚本和源码。也可以选择直接编译MNNConvert工具,编译出来之后是一个命令行程序。我建议第一次做的时候用源码方式跑通一次,后续如果需要批量转换再考虑编译MNNConvert。
仓库克隆下来后,转换脚本位于tools/converter/python目录。用Python运行MNNConvert.py即可。新版MNN也提供了打包好的转换工具,通常直接命令行里输入mnnconvert就能用。
5.2 MNNConvert命令全解析
以onnxsim简化后的模型为例,一条完整转换命令如下:
python MNNConvert.py \ -f ONNX \ --modelFile resnet18_sim.onnx \ --MNNModel resnet18.mnn \ --bizCode resnet_demo \ --fp16参数含义:
-f ONNX:指定输入模型格式为ONNX--modelFile:输入ONNX模型路径--MNNModel:输出MNN模型的保存路径--bizCode:业务标识码,相当于给这个模型打一个业务标签,可随意命名--fp16:把模型权重保存为FP16半精度,可以显著减小模型体积,但可能带来少量精度损失
如果要进一步压缩模型体积,MNNConvert还支持--weightQuant权重量化选项,将权重从FP32压到低比特,模型能再小一圈。具体用不用这两个选项,取决于模型对精度的敏感程度和端侧内存的紧张程度。像分类模型对量化相对宽容,检测和分割模型则要更多地实测对比。
需要注意,MNNConvert对ONNX的版本支持是有边界的。如果转换时遇到不支持的算子报错,可以试着把opset_version调低,或者先手动修改PyTorch模型,把那几个不支持的算子替换成等价实现。
5.3 在Python里验证MNN模型
转换完成后,必须回到MNN推理环境做一次端到端验证,确认.mnn模型能跑出和ONNX一致的结果。MNN提供Python API,转换环境里可以直接跑验证脚本:
import numpy as np import MNN # 创建解释器 interpreter = MNN.Interpreter("resnet18.mnn") session = interpreter.createSession() # 获取输入张量 input_tensor = interpreter.getSessionInput(session) print("输入名称:", input_tensor.getName(), "形状:", input_tensor.getShape()) # 构造输入数据 input_data = np.random.randn(1, 3, 224, 224).astype(np.float32) input_tensor.copyFrom(input_data) # 运行会话 interpreter.runSession(session) # 获取输出张量 output_tensor = interpreter.getSessionOutput(session) output_data = output_tensor.getNumpyData() print("输出形状:", output_data.shape)如果这一步能成功拿到输出,且和ONNX结果对比的误差在合理范围,说明这条转换链路已经打通。接下来把.mnn文件和相应的MNN库集成进App就行。
注意:MNN的Python包名和推理APIs随着版本演进会有变化。用2.x版本时,Interpreter和Session这套API通用性很好,但如果你用的是MNN 1.x或更早版本,API命名可能会略有区别,建议先对着仓库里的示例脚本调通再跑自己的模型。
6. 转换过程中的高频问题和一手排查经验
6.1 算子不支持:最典型的卡壳点
PyTorch的算子体系非常庞大,而ONNX和MNN支持的算子集合是有限的。导出ONNX时如果碰到PyTorch新算子,torch.onnx.export会直接报错,提示某个ATen算子找不到对应ONNX映射;转MNN时如果遇到ONNX里有但MNN没实现的算子,MNNConvert也会中途失败。
解决思路分三档。第一档,升级PyTorch和MNN版本,新版通常覆盖更多算子。第二档,改写模型,把不支持的算子拆解成基础算子组合,比如用卷积替换某些自定义矩阵运算。第三档,自己实现MNN的自定义算子并注册进去,这个成本最高,不到万不得已不建议碰。
实际操作中,这类问题通常集中在一些较新的激活函数、特殊池化方式、可变长循环等结构上。我在项目里遇到最多的是动态控制流相关的算子,比如torch.where配合循环条件,建议在模型设计阶段就尽量避免这类结构。
6.2 dynamic_axes带来的转换坑
ONNX里标记成动态的维度,到了MNN解析时往往会被转换成一种特殊的“可变输入”处理方式。如果转换时MNN版本较老,遇到动态维度直接报“不支持的输入shape”。即便转换成功,运行时的性能也可能不如固定shape版本。
我的经验是:端侧模型尽量固定输入分辨率,如果确实需要多尺寸输入,请在MNN侧做输入数据的resize和padding,把可变逻辑放在预处理阶段,而不是寄托于模型的动态维度。
6.3 精度误差过大排查思路
转换之后如果MNN和PyTorch输出差异过大,按以下顺序排查。
第一,确认模型在导出前切了eval。第二,用onnxruntime做中间对照,定位误差出现在“PyTorch到ONNX”这一步还是“ONNX到MNN”这一步。第三,检查有没有使用fp16或权重量化,这两个选项会显著影响精度。第四,检查输入数据预处理方式是否一致,包括均值方差、缩放因子、通道顺序。第五,检查模型里是否有某些算子在高版本opset下被变换成了数值不稳定的等价实现。
这五步走下来,绝大多数精度异常都能定位。
6.4 模型体积陡增或者变化
有时ONNX转换后模型体积比原始PyTorch模型大不少,原因是PyTorch模型文件里可能只存了参数,而ONNX把计算图和权重都完整保存了,同时常量折叠会把一些可预计算的中间结果也写进文件。用onnxsim简化、开启fp16或权重量化后,体积基本都会明显下降。
6.5 常见问题速查表
| 问题现象 | 常见原因 | 解决方向 |
|---|---|---|
| export时报ATen算子不匹配 | PyTorch算子没有ONNX映射 | 升级PyTorch、改写模型结构 |
| ONNX文件能生成,MNN转换失败 | ONNX模型冗余/不支持的算子 | 先ONNX简化,再逐个排查算子 |
| 推理结果完全错误 | Model没切eval、输入预处理不一致 | 先切eval,再对齐预处理 |
| 精度误差在0.1以上 | 使用了fp16或量化,或算子数值溢出 | 转回FP32验证,再开关量化项 |
| 动态输入模型转换失败 | dynamic_axes与MNN版本不匹配 | 改固定shape,预处理统一尺寸 |
| 模型转换成功但手机端耗时高 | 未启用线程优化、算子未融合到位 | 开启MNN推理优化、配置线程数 |
以上这些坑,我基本都在真实项目里踩过一遍。尤其是“把eval和输入预处理放在最高优先级”,这两条听起来基础,但翻车概率最高。如果能把版本匹配、静态shape、早期验证这三件事养成习惯,后面迁移模型到MNN基本就是一路绿灯。这条路本身不复杂,复杂的是细节。希望这篇复盘能帮后来的人把链路一次走通。