第九章 性能测量与基准测试:用数据说话
做AI应用开发,最怕什么?
我最怕的是模型在开发机上跑得飞快,一上用户手机就卡成PPT。这种事我遇到过不止一次。所以这一章,咱们专门聊聊怎么给NNAPI做性能测量和基准测试。
说白了,就是拿数据说话。别靠感觉,别靠猜。
9.1 为什么需要性能测量?
你想想看,一个模型在骁龙8 Gen3上跑30ms,在麒麟9000上可能就要60ms。这中间的差距,不测你根本不知道。
我个人习惯是:每次模型优化前后,必须跑一轮基准测试。没有数据支撑的优化,都是耍流氓。
核心测量指标:
- 推理时间(Inference Time):单次模型执行耗时
- 初始化时间(Init Time):创建Session、编译模型的耗时
- 内存占用:模型加载后的内存峰值
- 功耗:连续推理时的电池消耗
9.2 NNAPI内置计时功能
NNAPI其实自带了一些计时工具。嗯,这里要注意,不是所有设备都支持,但大部分主流机型都没问题。
9.2.1 使用Execution的计时回调
我最常用的方式,是通过Execution的回调接口来获取时间戳。看代码:
// 创建Execution对象
Execution execution = model.createExecution();
// 设置输入输出
execution.setInput(0, inputTensor);
execution.setOutput(0, outputTensor);
// 计时开始
long startTime = System.nanoTime();
// 同步执行
execution.compute();
// 计时结束
long endTime = System.nanoTime();
long durationMs = (endTime - startTime) / 1_000_000;
Log.d("NNAPI_Benchmark", "单次推理耗时: " + durationMs + " ms");
这里有个坑:第一次推理通常比后续慢很多。为什么?因为驱动层有初始化开销、缓存预热等。我曾经被这个坑过,第一次测出来200ms,后面稳定在30ms,差点以为模型有问题。
我的建议:至少跑10次,去掉最高和最低,取平均值。这样数据才靠谱。
9.2.2 异步执行的计时
如果你用异步方式,计时稍微复杂一点:
Execution execution = model.createExecution();
execution.setInput(0, inputTensor);
execution.setOutput(0, outputTensor);
CountDownLatch latch = new CountDownLatch(1);
long[] elapsedTime = new long[1];
execution.compute(new ExecutionCallback() {
@Override
public void onComplete() {
elapsedTime[0] = System.nanoTime();
latch.countDown();
}
@Override
public void onError(int errorCode) {
Log.e("NNAPI", "执行失败: " + errorCode);
latch.countDown();
}
});
long startTime = System.nanoTime();
latch.await(); // 等待执行完成
long endTime = elapsedTime[0];
long durationMs = (endTime - startTime) / 1_000_000;
Log.d("NNAPI_Benchmark", "异步推理耗时: " + durationMs + " ms");
异步的好处是能模拟真实场景——用户操作UI时,推理在后台跑,互不干扰。
9.3 基准测试框架:Android Benchmarks
手动计时虽然简单,但不够系统。Google官方推荐用Android Benchmarks框架来做性能测试。这个框架我用了两年多,确实好用。
9.3.1 集成方式
在build.gradle里加依赖:
dependencies {
androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.0'
}
然后写测试用例:
@RunWith(AndroidJUnit4.class)
public class NnapiBenchmarkTest {
@Test
@LargeTest
public void benchmarkInference() {
// 准备模型和数据
NnapiModel model = loadModel();
float[] inputData = generateInputData();
// 基准测试
BenchmarkState state = new BenchmarkState();
while (state.keepRunning()) {
model.runInference(inputData);
}
}
}
这个框架会自动处理预热、统计、输出报告。跑完直接生成JSON结果,方便对比。
注意:基准测试必须在release build下运行。debug模式下性能数据完全不准,我吃过这个亏。
9.4 分析模型推理时间
拿到总耗时之后,还得拆开看。一个模型推理过程,其实分好几段:
| 阶段 | 说明 | 典型耗时占比 |
|---|---|---|
| 数据预处理 | 输入数据格式转换、归一化 | 5%-15% |
| 模型编译 | NNAPI将模型编译为驱动指令 | 10%-30%(仅首次) |
| 推理执行 | 实际的计算过程 | 60%-80% |
| 后处理 | 输出结果解析、阈值过滤 | 5%-10% |
我个人习惯用分层计时的方式,把每个阶段单独测:
// 预处理计时
long preStart = System.nanoTime();
float[] normalizedInput = normalize(inputData);
long preEnd = System.nanoTime();
// 推理计时
long inferStart = System.nanoTime();
execution.compute();
long inferEnd = System.nanoTime();
// 后处理计时
long postStart = System.nanoTime();
float[] results = parseOutput(outputTensor);
long postEnd = System.nanoTime();
Log.d("Timing", "预处理: " + (preEnd-preStart)/1_000_000 + "ms");
Log.d("Timing", "推理: " + (inferEnd-inferStart)/1_000_000 + "ms");
Log.d("Timing", "后处理: " + (postEnd-postStart)/1_000_000 + "ms");
这样一拆,瓶颈在哪一目了然。我遇到过好几次,推理本身很快,但预处理慢得要命——原来是图像缩放算法没优化。
9.5 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
9.6 避坑指南
最后,分享几个我踩过的坑:
- 别在模拟器上测性能——模拟器的NNAPI实现和真机完全不同,数据没有参考价值。
- 注意CPU调频——手机刚跑完大型游戏,CPU频率高,测出来的数据偏快。我一般等手机冷却5分钟再测。
- 多设备对比才有意义——单台设备的数据只能看趋势,跨设备对比才能发现问题。
- 记录环境信息——测试时记录设备型号、Android版本、NNAPI驱动版本。不然过两周回头看数据,根本不知道对应哪个环境。
好了,性能测量这块就聊到这。记住一句话:没有测量就没有优化。下次你调模型的时候,先跑一轮基准测试,让数据告诉你该往哪个方向走。