前阵子整理学习笔记,翻到自己当时啃 NNAPI 时的记录,发现这一章的资料散得厉害,官方文档讲得抽象,社区文章又各说各话。正好趁这次机会把 Neural Networks API 的来龙去脉、核心对象模型、实战踩坑全部串起来写一遍,给准备在安卓上做端侧推理的开发者一份能直接上手的参考。
NNAPI 是安卓系统专门为神经网络推理提供的底层 C API,它的定位不是让你直接训练模型,而是把你手里的模型“翻译”成硬件加速器能听懂的任务,再交给 GPU、DSP、NPU 去跑。换句话说,它是连接“模型文件”和“芯片算力”之间的中间层。很多人一上来就纠结“我用 Pytorch 训练好的模型怎么转 NNAPI”,其实方向就偏了——NNAPI 不负责解析模型格式,它只负责执行一个已经定义好的计算图。理解这一点之后,后面的所有概念都会顺畅很多。
1. 为什么在安卓上做端侧推理绕不开 NNAPI
1.1 从 CPU 硬算到"让芯片干活"
先聊一个基础场景。假设你写了个图像分类 Demo,拿到一张图,要跑一个卷积神经网络。最容易想到的方案是把模型直接加载进 App,用 API 让 CPU 做矩阵运算。这种方案在模型小、输入小的时候还能忍,一旦模型上了几层卷积、输入分辨率超过 224,CPU 的算力瓶颈立刻暴露无遗。我实测过一个 5MB 左右的分类模型,纯 CPU 推理单帧要跑 120ms 到 180ms,处理器温度嗖嗖往上飙,帧率完全没法看。
这时候你手上其实有更好的算力:手机里有 GPU,有 DSP,甚至还有专门的 NPU。问题在于,这些硬件各有个的脾气,GPU 擅长并行浮点,DSP 擅长低精度整型计算,NPU 更是专为卷积优化的流水线。要让模型同时在不同硬件上跑,还跑出好性能,总不能给每块硬件各写一套代码吧。NNAPI 就是解决这个问题的:它定义一个统一的计算图描述方式,系统根据设备能力把计算图拆解、分配到合适的硬件上,你只需要面向这个统一接口编程。
1.2 NNAPI 和数据科学家熟悉的"推理框架"不是一回事
很多从 PyTorch/TensorFlow 过来的开发者对 NNAPI 抱有错误预期,以为它是一个能直接加载 .pt 或 .pb 文件的运行时。实际上 NNAPI 的输入不是模型文件,而是一个你已经构造好的计算图——包含哪些张量、哪些算子、算子之间的连接关系。这个计算图本质上和 ONNX 描述的东西类似,但 NNAPI 用的是自己的一套 C API 来“手搓”计算图。
所以业界的常规做法是:你依然用熟悉的框架训练和导出模型,然后通过转换工具把模型变成 NNAPI 能理解的计算图,或者干脆用更上层的推理库,让它内部自动调用 NNAPI。NNAPI 本身提供的,是让底层硬件加速能力被上层框架调用的“最后一道桥”。
1.3 拿到手能落地的收益是什么
我自己的实际感受是三点。
第一,推理延迟下降明显。同一个量化后的分类模型,CPU 推理 120ms,切到 NNAPI 后在某些芯片上能压到 40ms 甚至 20ms 以内,具体取决于硬件驱动优化程度。
第二,耗电和发热更可控。把任务交给专属加速单元,CPU 可以长时间处于低负载状态,实测连续推理 10 分钟,机身温度比纯 CPU 跑低不少,这个差异在移动端体验上非常显著。
第三,应用更不容易卡顿。推理任务从主 CPU 卸载后,UI 线程有更多余量保持流畅,尤其在做实时视频流处理时,这个收益非常关键。
NNAPI 适合的读者画像很明确:你是安卓端做推理部署的开发者,或者你在研究如何在移动端榨干硬件算力,再或者你在选型时想搞清楚底层框架的加速原理。这篇文章接下来的内容会围绕这三点逐一展开。
2. NNAPI 的四个核心对象:Model、Compilation、Execution、Memory
NNAPI 整个 API 设计其实不复杂,核心就是四个对象:ANeuralNetworksModel描述计算图,ANeuralNetworksCompilation表示针对特定硬件优化后的“可执行程序”,ANeuralNetworksExecution负责真正跑一次推理,ANeuralNetworksMemory管理输入输出内存。把这条主线吃透,你就掌握了 NNAPI 八成的东西。
2.1 Model:用"搭积木"的方式描述神经网络
ANeuralNetworksModel不关心你原来用的是什么框架,它只在乎两件事:有哪些操作数(Operand),以及有哪些操作(Operation)。操作数就是张量、标量这些数据对象,每个操作数都要声明数据类型和形状;操作则是比如ANEURALNETWORKS_ADD、ANEURALNETWORKS_CONV_2D这样的算子,每个算子要指定它的输入操作数索引和输出操作数索引。
整个构造过程有点像是拿乐高积木拼图:先定义好每一块积木长什么样,再说明怎么拼。API 层面就是addOperand和addOperation分别完成这两步。等到所有积木拼完,调用finish告诉系统“图已经搭好,可以进入下一步了”。
这里容易犯的一个错误是搞混“模型”和“执行”。一个 Model 可以被反复编译和多次执行,它不包含任何运行时状态。这个设计和把代码编译成可执行文件有点类似——源码是 Model,编译产物是 Compilation,运行起来就是 Execution。
2.2 Compilation:针对硬件的"定向编译"
ANeuralNetworksCompilation做的事情非常关键。同样一个计算图,在不同手机上跑,最优的执行策略完全不一样。有的芯片对 Conv 算子有专门加速指令,有的芯片擅长 Winograd 变换,还有的芯片在当前驱动版本下某些算子根本没优化,直接跑 CPU 反而更快。
Compilation 的价值,就是系统在你指定硬件优先级之后,把 Model 里的算子做一遍“适配”:能加速的算子分配到加速器,不能加速的做降级处理,再生成一个在当前设备上最合理的执行计划。这个过程比较耗时,所以应该只做一次、长期复用,而不是每次推理前临时创建。这一点很多新手会踩坑,后面实战部分再细说。
2.3 Execution:一次推理请求的完整生命周期
Compilation 是“静态”的,真正“动起来”的是 Execution。你只需要把输入数据塞进去,创建好输出缓冲,然后通知系统“开始算”。NNAPI 提供同步和异步两种执行方式。同步模式就是compute一路等到结果出来;异步模式是startCompute立即返回,再通过回调或者主动wait等结果。
我个人的建议是:在 UI 线程或者需要控制回调节奏的场景,用异步模式为主;在专门的后台推理线程里,反而同步模式写起来更省心,不容易出现生命周期管理问题。
2.4 Memory:隐藏的性能钥匙
ANeuralNetworksMemory是容易被忽略的一个对象。推理性能的瓶颈往往不在计算本身,而在“把数据搬进搬出”这个环节。NNAPI 允许你创建共享内存块,把输入数据映射到硬件可以直接访问的区域,减少内核态和用户态之间的拷贝。
具体用法是把输入张量放进一个Memory对象,然后执行时通过setInputFromMemory绑定,输出也同理。这个技术在视频流、点云数据这类大输入高频推理场景里收益非常明显。我第一次实测时,输入从 480x480 的 RGB 图像做预处理,用普通 buffer 比用 memory 共享,整体延迟差了将近 10ms,这个数字已经足够影响体感了。
3. 手写一个最小推理 Demo:从加法运算到图像分类
3.1 环境准备和工程配置
NNAPI 不是独立的 SDK,它就在 NDK 里,头文件路径一般位于 NDK 的android/neuralnetworks.h。在build.gradle里配置externalNativeBuild,用 CMake 编译即可,不需要额外引入复杂的依赖。如果你是纯 Java/Kotlin 开发,不想碰 C++,NNAPI 也有 Java 层的封装,但功能略少,底层灵活性差一些。
我用 C++ 接口演示,是因为它能完整展示 NNAPI 的设计思路,也方便后续做性能调优。工程里建议开启 C++17,NDK 版本选较新的稳定版,不同 NDK 版本对 NNAPI 的 API 支持程度有差异,旧版本可能会漏掉部分新算子。
3.2 构造一个真正的计算图
从最小的例子入手:一个把两个 float32 向量相加的模型。虽然很简单,但完整覆盖了 Model->Compilation->Execution 三个核心对象的使用流程。
第一步,定义操作数类型。NNAPI 使用ANeuralNetworksOperandType描述张量,包括数据类别、维度个数、每一维的大小,对于量化张量还有 scale 和 zeroPoint 两个参数。浮点张量不需要量化参数,填 0 即可。
第二步,用addOperand把三个张量注册进 Model:两个输入加一个输出。这里要注意,NNAPI 的索引是创建顺序决定的,后续引用都靠索引,顺序搞错了排查起来很头疼。
第三步,用addOperation添加ANEURALNETWORKS_ADD算子,并指明前两个索引是输入,后一个是输出。
第四步,用identifyInputAndOutputs告诉系统整个计算图的输入输出边界在哪儿。这个很关键,模型可能有中间张量,只有被标记的才能作为执行时对外交互的端口。
第五步,finish,图完成。代码不完全收尾但关键脉络如下:
ANeuralNetworksModel* model = nullptr; ANeuralNetworksModel_create(&model); uint32_t dims[1] = {2}; ANeuralNetworksOperandType type = {}; type.type = ANEURALNETWORKS_TENSOR_FLOAT32; type.dimensionCount = 1; type.dimensions = dims; type.scale = 0.0f; type.zeroPoint = 0; uint32_t inputA = 0, inputB = 1, output = 2; ANeuralNetworksModel_addOperand(model, &type); ANeuralNetworksModel_addOperand(model, &type); ANeuralNetworksModel_addOperand(model, &type); uint32_t addInputs[2] = {inputA, inputB}; uint32_t addOutputs[1] = {output}; ANeuralNetworksModel_addOperation( model, ANEURALNETWORKS_ADD, 2, addInputs, 1, addOutputs); uint32_t modelInputs[2] = {inputA, inputB}; uint32_t modelOutputs[1] = {output}; ANeuralNetworksModel_identifyInputsAndOutputs( model, 2, modelInputs, 1, modelOutputs); ANeuralNetworksModel_finish(model);这个结构看着繁琐,但它是所有复杂模型的骨架。图像分类模型无非是把算子换成卷积、池化、全连接,张量维度变成四维,本质流程完全一致。
3.3 编译并执行一次推理
图构造完成后,创建 Compilation。我习惯在初始化阶段就把 Compilation 准备妥当,而不是每次推理时才编译。NNAPI 还支持setPreference设置偏好,常用值有三个:PREFER_FAST_SINGLE_ANSWER倾向最快的单次推理、PREFER_SUSTAINED_SPEED倾向长时间稳定功耗、PREFER_LOW_POWER倾向省电。实时视频流场景我选PREFER_SUSTAINED_SPEED,分类这种偶发推理则选PREFER_FAST_SINGLE_ANSWER。
执行环节就是创建 Execution,绑定输入输出数据,然后startCompute加wait。完整执行片段:
ANeuralNetworksCompilation* compilation = nullptr; ANeuralNetworksCompilation_create(model, &compilation); ANeuralNetworksCompilation_setPreference( compilation, ANEURALNETWORKS_PREFER_FAST_SINGLE_ANSWER); ANeuralNetworksCompilation_finish(compilation); ANeuralNetworksExecution* execution = nullptr; ANeuralNetworksExecution_create(compilation, &execution); float inputDataA[2] = {1.0f, 2.0f}; float inputDataB[2] = {3.0f, 4.0f}; float outputData[2] = {0.0f, 0.0f}; ANeuralNetworksExecution_setInput( execution, 0, &type, inputDataA, sizeof(inputDataA)); ANeuralNetworksExecution_setInput( execution, 1, &type, inputDataB, sizeof(inputDataB)); ANeuralNetworksExecution_setOutput( execution, 0, &type, outputData, sizeof(outputData)); ANeuralNetworksExecution_startCompute(execution); ANeuralNetworksExecution_wait(execution);跑完以后outputData里就是{4.0f, 6.0f},这个加法模型正常工作。看起来很简单,但整个 NNAPI 的调用链已经全部走通了。
3.4 为什么实际项目里几乎不直接手写 NNAPI
从这个 Demo 你应该能感觉到,手写 NNAPI 的代码量非常大。如果模型有几十个算子,手动一个个addOperation既不现实也容易出错。所以实际工程里,大家几乎都是通过上层推理框架来间接使用 NNAPI。上层框架负责解析模型文件、把算子逐个转成 NNAPI 计算图,你根本不需要直接接触这些 C API。
但直接手写一遍的价值是:你对“计算图如何被描述”“硬件如何被调度”有了第一手感知。后面调性能、排查算子不支持的问题时,你就知道系统深处发生了什么,不会一脸懵地满世界问“为什么这个模型跑不起来”。
4. 从 NDK 到芯片驱动:一次推理请求的完整旅程
4.1 调用链上的各个层
理解 NNAPI 的运行机制,必须关心一条调用链:应用层 C API → NNAPI Runtime → 硬件驱动 → 芯片指令。
应用层调用ANeuralNetworksExecution_startCompute后,请求首先进入 NNAPI Runtime。Runtime 负责检查这个请求对应的 Compilation 里记录了哪些算子、分配给了哪个设备。然后 Runtime 把这些算子按照驱动的接口要求封装成任务,通过 Binder IPC 发送给硬件驱动进程。
硬件驱动才是真正“碰芯片”的角色。它拿到算子后,会把算子进一步翻译成底层 DSP/NPU/GPU 能执行的指令序列,安排内存、分配计算单元,最后把结果写回。驱动完成后再逐层把结果返回给应用。这个过程说快很快,一次简单推理从调用到返回可能只有几毫秒,但链路上的每一步都可能成为瓶颈。
4.2 驱动如何决定“这块硬件能不能跑这个算子”
NNAPI 有一个Device的概念,一个设备对应一个硬件加速器,每个设备由对应的驱动注册到系统。查询设备能力时,可以看它支持的算子列表,以及每个算子支持的数据类型和约束条件。比如某款 DPS 驱动声明自己支持ANEURALNETWORKS_CONV_2D,但只支持TENSOR_QUANT8_ASYMM类型,不支持浮点。如果你喂给它一个浮点卷积,Runtime 就只能找别的设备,或者干脆交给 CPU 兜底。
这就带来一个非常现实的问题:同一个模型,在一台手机上跑得飞快,换一台手机可能性能直接腰斩。根本原因是两台手机上的硬件驱动优化情况完全不同。NNAPI 没有跨设备的“确定性性能保证”,它的定位是“尽力而为的加速”。
在实践中,可以通过getDeviceCount和getDeviceName遍历系统里有哪些加速设备,再用getSupportedOperations检查模型里的算子在当前设备上是否被支持。我建议在 App 初始化时做一次设备能力探测,把结果上报下来,后续遇到线上机型的性能投诉就能快速定位是不是设备差异导致的。
4.3 性能数据流和真实体感
光看 API 文档很难体会 NNAPI 的性能特性。我自己的一个项目里做过一组对照实验,同一张 224x224 的 RGB 图像,模型是一个三卷积加全连接的小网络。
纯 CPU 推理耗时 110ms 左右;切到 NNAPI 用 GPU 设备,耗时降到 35ms;换成某款芯片的 NPU 驱动后,继续压到 23ms。数字很诱人,但注意两个前提——驱动必须对该模型里的算子有充分优化,以及模型本身要能完整跑在加速硬件上。如果模型里混入一个驱动不支持的算子,Runtime 可能会把整个子图切出来给 CPU 算,一个两个还好,多了的话性能反而退回原点。
从功耗角度看,加速器跑同样任务通常比 CPU 省电。CPU 本身就是通用模块,什么活都接,能效比没法跟专用单元比。手机上做持续推理时,省电不仅意味着续航更长,还意味着发热更低,降频来得更晚,整体体验会更加稳定。
5. 实战中的坑:精度、预热、内存域的教训
5.1 同一模型、不同硬件,结果为什么不一样
这是刚接触 NNAPI 时最容易困惑的一件事:明明同一个模型、同一份权重,为什么换了个设备跑,输出值就对不上了?甚至偶尔一次正确一次不正确?
原因在于浮点计算的顺序和累加方式在不同硬件上不一样。GPU 的大规模并行浮点单元,内部很可能用不同的指令顺序和加法结合方式,结果在最后几位二进制上出现偏差。这是正常现象,不是平台 bug。对于分类任务,最终的 Top1 结果通常不变,但对于回归、分割这类对输出精度要求较高的任务,偏差可能会被放大。
应对策略很简单:线上验证时,不要用“输出数值完全一致”作为通过标准,而要用“关键指标基本一致”作为标准。比如分类任务的 Top-5 一致率、检测任务的 mAP 相差幅度等。如果发现某个设备上指标明显劣化,再去看具体是哪个算子产生偏差,判断能否通过调整量化参数来缓解。
5.2 不预热就测性能,等于白测
这是性能测试里非常经典的坑。NNAPI 第一次执行推理时,驱动要做很多初始化工作:加载固件、分配内存、建立计算流水线。这些都算进单次推理耗时里,会让结果严重失真。我见过有人拿第一次执行的时间当作性能指标,数据高得离谱,然后因此误判 NNAPI 没用。
正确的做法是:编译完成后先跑三轮“热身推理”,抛弃这些耗时,再连续测 20 次到 50 次,取平均或者 P50/P95 分位数。移动端推理受温控影响很大,一轮测试跑太久,后半段因为降频,数据又会逐渐变差。合理控制测试轮数和中间休息时间很重要。
5.3 内存域名和 buffer 复用的学问
NNAPI 的ANeuralNetworksMemory背后有一个“内存域”(memory domain)概念。通俗说,它把某一块内存的所有权划给某个硬件设备,让设备可以直接访问,绕过 CPU 中转。实际开发中,如果能提前把输入输出内存分配好,然后每一帧推理都重复使用同一块内存,性能提升非常明显,尤其是图像流场景。
我踩过的一个教训是:视频流每帧都新建 input buffer,结果 GC 频繁,推理线程的优先级被反复打断,帧率反而下降。后来改成预分配内存池,在初始化阶段把输入输出缓冲全部创建好,推理循环里只更新缓冲内容、不再重新分配,卡顿问题直接消失。中间还遇到过一个驱动在特定分辨率下要求内存按 16 字节对齐的问题,排查了半天,最后发现是内存分配没对齐导致的偶发延迟,通过posix_memalign解决。
除了内存,算子参数也有一堆“软约束”。比如卷积的 padding 必须显式设置,有些驱动对FUSE_ACTIVATION的选择很敏感,把 ReLU 融合进卷积算子比单独接一个 ReLU 层快得多。这些细节在跑通基础 Demo 之后,值得专门花时间研究。
6. 选择与演进:NNAPI 不是万能钥匙
6.1 先探测,再加速,最后兜底
我整理出的一个稳定可复用的工程策略是三层结构:
第一层,初始化时探测设备能力。拿到设备列表之后,把模型算子跟每个设备的支持列表做比对,确定“最优设备”和“降级设备”。
第二层,优先把算子分发到能加速的设备上。中途出现不支持的情况,让 Runtime 自动回退到 CPU。不要试图强行让所有算子都在加速器上跑,否则可能因为算子拆分导致性能不升反降。
第三层,保留一个完整的 CPU 推理路径作为兜底。当加速设备不可用、驱动异常、或者遇到兼容性问题时,能自动切换到 CPU 路径,保证功能不挂。这个兜底逻辑线上工程里非常重要——加速是体验优化,稳定是底线。
6.2 更实用的做法是站在框架肩膀上
虽然 NNAPI 功能完备,但直接用它生产效率确实低。现在更常见的选择是使用底层已经集成 NNAPI 支持的推理框架。它们负责模型解析、算子转换、图优化,同时把 NNAPI 作为后端加速器之一,需要时自动调用。这种方式既享受了 NNAPI 带来的硬件加速,又不需要亲手构造计算图。
选型时要注意框架与 NNAPI 版本的适配情况。新算子、新特性的支持往往需要框架发布新版本配合,长时间不升级容易出现“模型转换失败”或者“运行时异常”这类问题。另外,如果产品只针对自家芯片做深度定制,芯片厂商一般会提供专有的加速 SDK,这时的性能上限通常比通用 NNAPI 路径更高,但代价是绑定在特定平台上了。
拿我自己举例,一个实时做图像增强的小工具,早期直接使用 NNAPI 路径,手写算子翻译逻辑极其痛苦;后来切换到集成 NNAPI 的高层框架,代码量少了三分之二,推理耗时反而下降了。原因就是这个框架内部针对常见模型做了算子融合和内存复用优化,靠手工很难做到那个程度。
6.3 NNAPI 后续演进带来哪些新能力
NNAPI 并不是一个停滞不前的 API。后续版本在算子上扩展了不少,从早期偏基础的卷积、池化,到后来引入更多的归一化、注意力相关算子,还支持了更灵活的量化类型,比如TENSOR_QUANT8_ASYMM_SIGNED、TENSOR_BOOL等。这些新特性表面上看只是多了几个枚举值,实际影响是能跑的模型范围变大了,以前需要分拆成多个子图才能处理的结构,现在可以整图交给加速器。
另外,接口层面也出现了一些优化,比如支持更细粒度的执行优先级控制、更丰富的设备能力查询接口。如果你在长期维护推理功能,建议每隔一段时间查一下新版本 API 的变化,把产品用到的算子做一次升级评估。这里不一定追求“新”,但至少要清楚哪些老接口被标记为 deprecated,提前做好迁移计划,免得未来升级 NDK 时措手不及。
最后再分享一个小技巧
在初始化阶段做一次完整的设备能力探测,把设备名称、支持的算子数量、首轮推理耗时、预热后推理耗时这些数据统一记录下来。这样遇到线上用户反馈“推理变慢”或者“部分功能失效”时,可以先对比设备数据,快速定位问题是在驱动适配、模型算子还是业务侧调用。我后来复盘时发现,很多所谓莫名其妙的性能问题,最后都落在“某个设备不支持某个量化类型”或“某个驱动版本对某算子实现有问题”这类底层原因上。提前布好这双眼睛,能替你省掉好几天的排查时间。