PyTorch官方移动端推理方案的完整pipeline:Export(torch.export转无Python计算图)、Lower(to_edge_transform_and_lower转换到Edge方言)、Serialize(to_executorch生成.pte文件),附带C++ runtime集成方式。
ExecuTorch 是 PyTorch 回答"如何在手机上运行这个模型"的方案,且不走 ONNX 路线。运行时很小,API 很短。几乎所有难点都集中在一个步骤上——而且是第一步。
工具链的形态
三个阶段,值得分开理解,因为错误信息来自不同的层级:
Export。torch.export.export 将你的 nn.Module 追踪为一个不含任何 Python 代码的图。这是标准 PyTorch,不是 ExecuTorch,也是模型最容易出问题的地方。
Lower。to_edge_transform_and_lower 将该图转换为 Edge dialect,并把可以加速的部分交给后端分区器(partitioner)。
Serialise。.to_executorch() 生成一个程序,写入 .pte 文件——这是一个包含图和权重的单一制品,正是要打包进你的应用里的东西。
加载 .pte 的运行时是一个 C++ 库,链接到 Android 或 iOS 应用中。设备上没有 Python,你添加到二进制里的运行时大小而不是框架大小——模型文件本身就是移动端 App 中打包量化模型那一节要讨论的部分。
Export,也就是出问题的地方
torch.export 需要一个能在推理时不运行 Python 就能捕获的图。数据依赖的控制流——基于 tensor 值的 if、循环次数依赖输入的 loop、基于阈值的 early return——无法被捕获,export 会抛出异常而不是静默特殊化。这是一个特性:替代方案是生成一个仅对你追踪时用的示例输入才正确的图。
两个实际后果。第一,形状:默认情况下导出的图会对示例输入的形状做特殊化,如果需要一个维度可以变化,必须通过动态形状指定来声明,而不是指望它自己支持。第二,修复方法几乎总是在模型侧,而不是在导出器侧——把基于 tensor 数据的 Python if 替换为 torch.where,把配置分支从 forward 里提出来放到构造阶段,把可变长度循环替换为带掩码的固定长度循环。
Lowering 到后端
在干净环境中安装工具链:pip install executorch。
将模型置于 eval() 模式,准备一个包含目标形状的示例输入元组。
Export,用分区器 lower 到目标后端,然后序列化为 .pte。
将 .pte 加载回来,与 eager 模式的 PyTorch 对比输出。
import torch
from executorch.exir import to_edge_transform_and_lower
from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
model = MyModel().eval()
sample_inputs = (torch.randn(1, 3, 224, 224),)
exported = torch.export.export(model, sample_inputs)
et_program = to_edge_transform_and_lower(
exported,
partitioner=[XnnpackPartitioner()],
).to_executorch()
with open("model.pte", "wb") as f:
f.write(et_program.buffer)
分区器是后端选择,也是唯一一条在不同目标间会变化的代码。XnnpackPartitioner 面向 Arm 和 x86 CPU,是可移植的默认选择;Apple 设备有 Core ML 分区器,高通驱动的 Android 手机有 QualcommPartitioner。当前列表见 PyTorch 的 ExecuTorch 入门指南。
部分 lowering 是正常的
分区器拿走它能加速的部分,剩余的交给可移植 CPU 内核。这不是失败,但确实意味着后端声明的图比例是值得关注的数字——一个分区器从四十个算子中只拿走了两个的模型,表现出来会是一个带额外传输开销的 CPU 模型,原因与 Hexagon 页面描述的相同。
在接触设备之前,先在 Python 里把文件加载回来,与 eager 模式模型对比。在这个阶段犯错是在笔记本上花几分钟;在手机上犯错就是一下午。
from executorch.runtime import Runtime
runtime = Runtime.get()
program = runtime.load_program("model.pte")
method = program.load_method("forward")
out_et = method.execute([sample_inputs[0]])[0]
out_eager = model(*sample_inputs)
print(torch.allclose(out_et, out_eager, atol=1e-4, rtol=1e-4))
print((out_et - out_eager).abs().max())
有小差距是正常的——后端会融合操作并重新关联浮点运算,而重新关联是不精确的。大差距意味着 lowering 改变了数值,通常是因为在你不想用量化核的地方用了量化核。收紧容差直到它失败,然后看哪个输出出现了分歧。
用你真实分布的输入做这个对比,而不是用 torch.randn。随机 tensor 只锻炼了网络的中间范围,几乎不锻炼其他部分,所以在噪声上通过数值检查的 lowering,可能已经破坏了你的产品所依赖的行为。你导出时用的示例输入只是形状规格,不是测试集,两者不应该是同一组 tensor。
弄到手机上
.pte 是可移植制品;运行时是按平台的。在 Android 上你通过其 AAR 链接 ExecuTorch 运行时,从 Kotlin 或通过 JNI 调用;在 iOS 上你把运行时作为 Swift package 或 framework 添加,从 Swift 调用。模型文件作为 asset 放入,这使得它直接进入打包页面中设定的商店大小限制——Google Play 上的基础模块压缩下载被限制在 500 MB,一个 1B 模型以 4 bit 量化本身就可能达到这个限制。
生产化过程中有两个要点要牢记。后端选择是编译时决定,打包进 .pte 的,所以用 QualcommPartitioner lower 的构建不是你发给联发科手机的那个构建——要么往各处都发 Xnnpack 构建并接受 CPU 性能,要么按目标产物发货并在下载时选择。而量化是一个独立问题,与前述内容是组合关系:你在 lower 之前或期间做量化,ONNX 量化页面上同样的精度验证规范照搬适用——用真实输入对比未量化模型,绝不用噪声。
ExecuTorch 已达到稳定的 1.x 发布,其分区器模块路径和运行时绑定在版本间有变动。锁定你开发时用的版本,查看该版本对应的入门指南,而不是看旧教程。
Quantizing a Core ML Model for iOS