第九章 性能测量与基准测试:用数据说话

做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 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

NNAPI性能测量与基准测试知识体系 核心目标:用数据驱动优化 内置计时API Android Benchmarks 分层时间分析 同步/异步计时 System.nanoTime() ExecutionCallback 自动化基准测试 BenchmarkState JSON报告输出 阶段拆分 预处理/推理/后处理 定位瓶颈 优化决策:量化 / 算子替换 / 设备选择

9.6 避坑指南

最后,分享几个我踩过的坑:

  • 别在模拟器上测性能——模拟器的NNAPI实现和真机完全不同,数据没有参考价值。
  • 注意CPU调频——手机刚跑完大型游戏,CPU频率高,测出来的数据偏快。我一般等手机冷却5分钟再测。
  • 多设备对比才有意义——单台设备的数据只能看趋势,跨设备对比才能发现问题。
  • 记录环境信息——测试时记录设备型号、Android版本、NNAPI驱动版本。不然过两周回头看数据,根本不知道对应哪个环境。

好了,性能测量这块就聊到这。记住一句话:没有测量就没有优化。下次你调模型的时候,先跑一轮基准测试,让数据告诉你该往哪个方向走。


公众号:蓝海资料掘金营,微信deep3321