深入 Android NNAPI 全链路:从 HAL 硬件抽象到 Qualcomm/MTK NPU 厂商驱动的端侧 AI 推理加速架构
一个 10 倍的性能差距
去年做端侧 OCR 项目时踩过一个坑:同一个 MobileNetV3 模型,Pixel 6 上跑 8ms,换到一台 MTK 中端机却要 85ms。排查后发现 Pixel 上 NNAPI 走了 GPU 加速,MTK 那台设备虽然内置了 APU,NNAPI 却没匹配到对应驱动,所有算子回退到了 CPU。
Android 生态的设备碎片化在 NNAPI 这个场景下尤为突出——同一套 API,不同 SoC 的执行路径完全不同。不搞清楚完整链路,端侧推理的性能调优基本靠猜。
NNAPI 在 Android AI 栈中的生态位
NNAPI 不是推理框架,是一层硬件加速抽象。它在 Android AI 栈中的位置:
LiteRT / MediaPipe / AICore ← 上层推理框架
↓
NNAPI Runtime (libneuralnetworks.so) ← 系统级服务
↓
NNAPI HAL (AIDL / HIDL) ← 硬件抽象层
↓
厂商驱动 (.so) ← QNN / Neuron / ENN
↓
DSP / NPU / GPU / CPU ← 物理硬件
上层框架不需要关心底层是 Hexagon DSP 还是 MTK APU,只需把模型交给 NNAPI Runtime,由它匹配可用的加速设备。这套架构从 Android 8.1 引入,到 Android 15 迭代了 7 个大版本,HAL 接口已经稳定。
HAL 层的三个核心接口
NNAPI HAL 定义了驱动必须实现的接口,对应模型的生命周期:
// AIDL 定义(Android 11+)
interface IDevice {
// 1. 能力查询:返回支持的算子列表和性能等级
Capabilities getCapabilities();
// 2. 模型编译:将计算图转换为设备可执行格式
IPreparedModel prepareModel(in Model model, ...);
// 3. 创建执行上下文,绑定输入输出
IExecution createExecution(in IPreparedModel preparedModel, ...);
}
getCapabilities() 返回的算子支持表是整个链路的第一道关卡。Runtime 据此做图分区(Graph Partitioning)——把模型拆成多个子图,分配给不同设备。
容易被忽略的是内存路径。NNAPI 支持用 AHardwareBuffer 在不同设备间共享张量,省掉 CPU-GPU-NPU 之间的数据拷贝。但前提是驱动实现了 BURST 模式和 MemoryDomain 扩展——很多早期驱动只做简单的 mmap,零拷贝优化根本没生效。
图分区与算子回退
Android 官方定义了 100+ 标准算子,每个设备只支持一个子集。Runtime 的处理流程:
- 遍历模型所有算子
- 查询每个算子被哪些设备支持
- 按「最少跨设备传输」策略划分子图
- 无加速器支持的算子归入 CPU 回退子图
分区默认用贪心算法——优先把连续算子分给第一个支持它们的设备。结果 GPU 子图 → NPU 子图 → CPU 子图交替出现,每次切换都触发显式内存拷贝:
// 典型分区日志
Subgraph 0: [Conv2D, ReLU, MaxPool] → QTI NPU
Subgraph 1: [Reshape] → CPU ← 一个算子就切一次
Subgraph 2: [Conv2D, Softmax] → QTI NPU
这种「单算子回退」是性能杀手。更好的做法是在模型转换阶段用 Delegate 白名单控制分区:
val options = Interpreter.Options().apply {
useNNAPI = true
// 禁止 CPU fallback,不支持则直接报错
setNnapiAllowFp16PrecisionForFp32(true)
}
allowFp16PrecisionForFp32 只是精度选项。要彻底关闭 CPU 回退,需设置 ExecutionPreference::FAST_SINGLE_ANSWER 并确认所有算子都有加速支持——做不到的话,这个模型不适合纯 NNAPI 路径。
厂商驱动:QNN 与 Neuron
Qualcomm QNN(原 SNPE)
Qualcomm 的 AI 引擎栈从 SNPE 演进到 QNN,核心优势在离线编译(Offline Compilation):
# QNN 离线编译
qnn-onnx-converter -i model.onnx -o model.cpp
qnn-model-lib-generator -c model.cpp \
-b qnn_model.bin -t aarch64-android
编译后的 .bin 直接跑在 Hexagon DSP 上,跳过 Runtime 在线编译。Snapdragon 8 Gen 2 的 Hexagon 支持 INT4 精度和微切片推理(Micro-Tile Inferencing),Transformer 类模型效率提升明显。
QNN 的版本管理是个坑:每个 SoC 平台绑定特定 SDK 版本,跨平台部署要维护多套编译产物。
MTK Neuron Delegate
MediaTek 直接走 TFLite Delegate 路线,不依赖 NNAPI HAL:
val delegate = NeuronDelegate.create(
NeuronDelegate.Options().apply {
setPlatform(NeuronDelegate.Platform.MT6983) // Dimensity 9000
setOptimization(NeuronDelegate.Optimization.PREFER_FP16)
}
)
val interpreter = Interpreter(model, options.addDelegate(delegate))
Neuron 绕过 NNAPI Runtime 的分区逻辑,直接接管整图推理。好处是避免了跨设备切换开销,代价是失去了算子兼容性保障——APU 不支持的算子直接崩溃。
实际项目中我更倾向用 Neuron 而非通用 NNAPI。MTK 平台上的 NNAPI HAL 实现往往滞后于 Neuron SDK 的功能更新,新 APU 的能力要等系统 OTA 才能通过 NNAPI 调用。
三个真实踩坑记录
坑 1:动态 Shape 支持不一致
NNAPI 1.3 引入动态 Shape,厂商实现参差不齐。同一个含 tf.while_loop 的模型,三星设备跑通,OPPO 设备卡死。最终方案:模型导出时固定所有维度,放弃动态 batch。
坑 2:INT8 量化精度不互通
Qualcomm 用对称量化,MTK 用非对称量化。模型在转换时没对齐量化方案,输出结果偏差超过 5%。我的做法是在 CI 中加精度校验节点,用余弦相似度对比 FP32 和 INT8 输出。
坑 3:驱动版本不匹配导致静默降级
Android 14 上 AIDL HAL 到了 V4,某些厂商仍用 V2。Runtime 检测到版本不匹配后静默回退到 CPU,logcat 里只有一条不起眼的 W/NativeAllocationRegistry 警告,不抓日志根本发现不了。
选型决策框架
端侧推理的技术选型,我按这个顺序做决策:
- 模型算子集是否在目标 NPU 覆盖范围内? 用
getSupportedOperationsForDevices()先验证,别直接跑 - 能否走离线编译? QNN/Neuron 的离线路径比在线编译快 3-5 倍,优先考虑
- 接不接受 CPU 回退? 不接受就关 Fallback 且确保算子全支持;接受就把回退耗时纳入性能预算
NNAPI 的本质是各厂商驱动的统一入口,真正的加速效果取决于驱动质量。理解全链路不是为了用它的默认行为,而是为了在它出问题时知道往哪看。