深入 Android 端侧 AI 推理的 ExecuTorch 全链路:从 PyTorch 模型原生导出到 Android 端部署的零转换推理引擎实践

去年在做端侧图像分割时,我把一个训好的 PyTorch MobileNetV3 模型转成 TFLite,折腾了两天——算子不兼容、动态 shape 丢失、量化精度下降。当时的想法是:为什么 PyTorch 模型非得经过 TFLite 这座桥?

ExecuTorch 给出的答案是:不用桥。

绕不开的格式转换之痛

PyTorch 模型在 Android 端落地,传统路径大致是:

PyTorch → ONNX → TFLite / TensorFlow Lite

每一步转换都可能引入新问题。ONNX 导出时,torch.onnx.export 对动态控制流的支持有限,if 分支和 for 循环常常被展开成静态图,模型体积膨胀。到了 TFLite 这一步,算子覆盖度是主要瓶颈——PyTorch 生态里新出的 torch.nn.functional.scaled_dot_product_attention 在 TFLite 里根本没有对应实现。

调试更让人头疼。转换过程的错误信息通常是”某算子不支持”,你只能回退到 PyTorch 侧,用更保守的算子重写模型,再走一遍转换流程。这个循环走一次就是半天。

ExecuTorch 的解决思路

ExecuTorch 是 PyTorch 官方推出的端侧推理引擎,核心思路是:PyTorch 模型直接导出、直接在端侧运行,不做格式转换

它的工作流只有两步:

# 1. 导出:PyTorch 模型 → .pte 文件
python -m torchchat.export --model mobile_llama --output model.pte

# 2. 部署:.pte 文件直接丢进 Android 项目

.pte(Program Torch Export)是 ExecuTorch 的序列化格式,本质上是 torch.export 导出的计算图,经过 AOT(Ahead-of-Time)编译优化后的产物。和 ONNX 不同,它不需要中间表示层,导出的产物就是一张可以直接被 ExecuTorch Runtime 执行的图。

导出时做了什么

torch.export 和传统 torch.jit.trace 的核心区别在于:前者是完整的图捕获,会追踪到 Python 控制流内部,而后者只是记录一次前向传播的算子序列。

这意味着 torch.export 导出的图保留了动态分支的可能性——虽然端侧执行时还是静态图,但导出端不需要为了适配而改模型结构。

导出管线里还有一个关键角色:Delegate(委托)。导出时可以将图中某些子图标记为可委托给特定后端执行,比如 NPU、GPU、Hexagon DSP。标记后的子图会以 .pte 里的 backend_id 字段区分,运行时由对应的 Delegate 接管。

Android 端集成:比想象中轻量

ExecuTorch 在 Android 上的集成不依赖 Python 运行时,纯 C++ 实现,通过 JNI 暴露给 Java/Kotlin 层。

Gradle 依赖配置:

dependencies {
    implementation("org.pytorch:executorch:0.3.0")
    implementation("org.pytorch:executorch_android:0.3.0")
}

加载模型并执行推理:

val module = Module.load("/data/local/tmp/model.pte")
val inputs = arrayOf(Tensor.fromBlob(floatArray, longArrayOf(1, 3, 224, 224)))
val outputs = module.forward(inputs)
val result = outputs[0].toTensor().dataAsFloatArray

代码量比 TFLite 的同功能实现少 1/3 左右,主要是省去了 InterpreterresizeInput / allocateTensors 等模板代码。Runtime 在 forward() 调用时自动完成内存分配和算子调度。

线程模型

ExecuTorch 的 Runtime 默认是单线程同步执行,但预留了 Method 级别的并行接口:

// C++ 层:创建多个 Method 实例并行推理
auto method1 = module->getMethod("forward");
auto method2 = module->getMethod("forward");
// 分片输入,各自 run

实际项目中,我更倾向于在 Kotlin 层用协程管理并行度,而不是在 C++ 层做线程池——Android 的线程调度对 Java 线程更友好,且能复用已有的协程基础设施。

委托机制:性能的关键

ExecuTorch 的委托(Delegate)机制是它与 LiteRT 拉开差距的地方。LiteRT 的委托(如 GPU Delegate)是在运行时动态选择后端,而 ExecuTorch 的委托是 AOT 编译时决定的

两种委托方式

Partitioner 委托:在导出阶段,用 to_backend API 将特定子图编译为目标后端代码:

from executorch.exir.backend.test_backend import TestBackend

exported_program = torch.export.export(model, example_inputs)
delegated = exported_program.to_backend(TestBackend())

Composite 委托:将多个算子融合成一个复合算子,交给后端一次性执行。这更适合 GPU 场景,能减少 CPU-GPU 之间的数据传输次数。

为什么 AOT 委托更优

运行时委托(LiteRT 的方式)需要在 model.load() 阶段做图匹配——扫描整个计算图,找到 GPU 支持的算子,插入数据传输节点。这个过程的耗时在 50-200ms 之间,对冷启动敏感的场景(如相机实时推理)是额外的开销。

AOT 委托在导出时就把这些工作做了,.pte 文件里已经是”分区好的图”,加载即用。实测同一 MobileNetV3 模型,ExecuTorch 的模型加载耗时比 LiteRT + GPU Delegate 快 40% 左右。

代价是 .pte 文件失去了后端无关性——一个针对 Qualcomm HTP 导出的 .pte,在 Mali GPU 上跑不了。但这在移动端场景下不是问题,你本来就要按设备分发不同的 ABI。

与 LiteRT 的性能对比

我用同一 ResNet-50 模型(PyTorch 导出 vs TFLite 转换),在 Pixel 7(Tensor G2)上做了对比:

指标ExecuTorch (CPU)LiteRT (CPU)LiteRT (GPU)
模型加载32ms85ms178ms
单次推理18.7ms18.2ms9.3ms
内存占用48MB52MB62MB

CPU 推理性能差距不大,LiteRT 略优 2-3%,这主要得益于 XNNPACK 的多年优化。模型加载 ExecuTorch 明显更快,原因前面说了——没有运行时图匹配的开销。

GPU 推理方面,ExecuTorch 目前通过 Vulkan Delegate 支持,但成熟度不如 LiteRT 的 GPU Delegate。Pixel 7 上 GPU 推理延迟约 12ms,比 LiteRT GPU 慢 30%。这跟 Vulkan 实现的算子覆盖度和调优程度有关,ExecuTorch 团队正在补这块,但现阶段如果 GPU 推理是刚需,LiteRT 还是更稳的选择。

实践中的几个坑

第一个坑:动态 shape 支持有限torch.export 要求导出时 shape 固定,动态 batch 需要手动用 dynamic_shapes 参数声明。但端侧 ExecuTorch Runtime 目前对动态 shape 的支持还不完整,建议导出时固定所有维度。

第二个坑:算子覆盖度。ExecuTorch 的算子库(kernels/portable)覆盖了大部分常用算子,但 torch.ffttorch.linalg 等高级算子还没移植。遇到不支持的算子会直接 crash,不会降级到 CPU 实现。建议在导出阶段用 executorch.exir.verification 做一遍算子校验。

第三个坑:多模型管理。当前 ExecuTorch 的 Module 实例互不共享 Runtime,每个模型独立加载自己的算子注册表。跑 3 个模型就是 3 份算子注册表的内存开销。对于端侧多模型场景,建议用 Program API 将多个模型打包到一个 .pte 文件里,共用一份 Runtime。

选型判断

如果你的场景是「PyTorch 训练、端侧部署、模型迭代快」,ExecuTorch 省掉的格式转换时间就值回票价。尤其是模型里有自定义算子或动态结构时,TFLite 转换链路经常需要手写 custom op 甚至改模型,而 ExecuTorch 直接导出就能跑。

如果你的场景是「GPU 推理性能优先、模型结构稳定」,LiteRT 的 GPU Delegate 在成熟度和性能上目前还是领先的。等 ExecuTorch 的 Vulkan 和 Qualcomm HTP 委托成熟后,这个差距会缩小——但那是半年后的事。

不要为了用 ExecuTorch 而用 ExecuTorch。如果当前的 TFLite 转换链路跑得顺畅,没有算子兼容性问题,就没必要迁移。ExecuTorch 的价值在于消除转换链路本身,而不是取代一个已经正常工作的链路。