Jetpack Benchmark を利用した実行時間計測


はじめに

Android Advent Calendar 2019 の20日目です。

この記事では、Google I/O 2019で発表された Jetpack Benchmark の1.0.0が先月末にstableリリースされたので、こちらについて紹介したいと思います。
※ 2019年12月20日現在、日本語のドキュメントがアルファ版のままなので、言語を切り替えて利用すると良いと思います。

Jetpack Benchmarkについて

Benchmarkは、Androidアプリコードのパフォーマンスを適切に計測のためのライブラリです。

コードの実行速度を計測する上で、単純な開始時間と終了時間の差分では計測のたびにムラが生じたり、
端末が起動直後であったり高負荷状態であるなどの不安定要素の存在によって都度適切な計測は困難です。

これらの問題に対し、ウォームアップ時間の対応、異常値の削除、CPUクロックのロックなど、計測における安定性をライブラリが提供してくれるため、開発者はよりテストしたいコードに集中することができます。

セットアップ

Benchmark app code に記載の通り、ベンチマーク用のモジュールを作成します。

Android Studio 3.6 以降の場合には、File > New > New ModuleからBenchmark Moduleを選択できるようになっており、こちらからテンプレートを利用することが可能です。

Android Studio 3.5 の場合には、Set Android Studio properties に記載のようにして、IDEプロパティのカスタマイズを行うことで、利用することができるようになります。

このモジュールでは、テストでデバッガーとプロファイリングツールを使用できないようdebuggable=false が指定されています。
これはデバッガーへの接続によって、実行速度が遅くなり、遅延の度合いも変動的であることが、ベンチマーク計測に適さないとのことからこのような設定になっています。以下の図では、debuggableがtrueの場合、deserializationは1%程度の違いですが、inflateSimpleでは80%ほどの違いが出ています。

モジュール構成は以下のようになっており、モジュールとして切り出されていることで、プロダクトの設定はそのままにベンチマーク用の設定を行って計測をすることができます。

トラブルシューティング

2019年12月20日現在、テンプレートから作成される依存の記述が更新されていないのか、syncが通らないケースがあります。

Detected usage of the testInstrumentationRunner,
                            androidx.benchmark.AndroidBenchmarkRunner, in project benchmark,
                            which is no longer valid as it has been moved to
                            androidx.benchmark.junit4.AndroidBenchmarkRunner.

以下のようにjunit4のパッケージのものを利用するように変更します。

diff --git a/benchmark/build.gradle b/benchmark/build.gradle
index 82c8b333c1..89316a235a 100644
--- a/benchmark/build.gradle
+++ b/benchmark/build.gradle
@@ -11,7 +11,7 @@ android {
         versionCode 1
         versionName "1.0"

-        testInstrumentationRunner 'androidx.benchmark.AndroidBenchmarkRunner'
+        testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner'
     }

     buildTypes {
@@ -37,5 +41,5 @@ dependencies {
     androidTestImplementation 'androidx.test:runner:1.1.1'
     androidTestImplementation 'androidx.test.ext:junit:1.1.0'
     androidTestImplementation 'junit:junit:4.12'
-    androidTestImplementation 'androidx.benchmark:benchmark:1.0.0-alpha01'
+    androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.0.0'
 }
diff --git a/benchmark/src/androidTest/java/com/example/benchmark/ExampleBenchmark.kt b/benchmark/src/androidTest/java/com/example/benchmark/ExampleBenchmark.kt
index 11d4e320a4..05c506ebed 100644
--- a/benchmark/src/androidTest/com/example/benchmark/ExampleBenchmark.kt
+++ b/benchmark/src/androidTest/com/example/benchmark/ExampleBenchmark.kt
@@ -1,8 +1,8 @@
 package com.example.benchmark

 import android.util.Log
-import androidx.benchmark.BenchmarkRule
-import androidx.benchmark.measureRepeated
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import org.junit.Rule
 import org.junit.Test
diff --git a/build.gradle b/build.gradle
index 0ec2401160..4955fb048a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -72,7 +72,7 @@ buildscript {
         classpath "${Dep.GradlePlugin.butterknife}"
         classpath "com.github.mataku:releases-hub-gradle-plugin:0.0.2"
         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
-        classpath 'androidx.benchmark:benchmark-gradle-plugin:1.0.0-alpha01'
+        classpath 'androidx.benchmark:benchmark-gradle-plugin:1.0.0'
     }
 }

ベンチマークの作成・実行

モジュールを作成すると、ExampleBenchmark が作成されているので、これをベースにカスタマイズして作成していくことができます。

ExampleBenchmark.kt
@RunWith(AndroidJUnit4::class)
class ExampleBenchmark {

    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @Test
    fun log() {
        benchmarkRule.measureRepeated {
            Log.d("LogBenchmark", "the cost of writing this log method will be measured")
        }
    }
}

BenchmarkはInstrumented Unit Testsとしてsrc/androidTest/java/配下に作成します。
テストファイルを作成して、上記のようにBenchmarkRuleのインスタンスを生成し、measureRepeated のブロック内に実装した内容を計測することできます。

実行するとLog.dの速度が計測されます。

Started running tests
benchmark:         4,482 ns ExampleBenchmark.log

Tests ran to completion.

また、端末のバッテリーが少ないなど、正しく計測できない要因がある場合には、Warningとして以下のように警告してくれます。

Started running tests
benchmark: WARNING: Device has low battery (16%)
benchmark:     When battery is low, devices will often reduce performance (e.g. disabling big
benchmark:     cores) to save remaining battery. This occurs even when they are plugged in.
benchmark:     Wait for your battery to charge to at least 25%.
benchmark:     Currently at 16%.
benchmark:
benchmark:         4,518 ns LOW-BATTERY_ExampleBenchmark.log

Tests ran to completion.

RecyclerViewのスクロールを計測する

googlesamples/android-architecture-component の PagingWithNetworkSample ではRecyclerViewのベンチマークの計測が追加されています。
このアプリは、Paging Library を利用して、Raddit API へリクエストにして投稿を表示するアプリですが、
RadditActivityで利用されているPostAdapterを利用したRecyclerViewの計測が行われています。

@LargeTest
@RunWith(AndroidJUnit4::class)
class PostsAdapterBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @get:Rule
    val activityRule = ActivityTestRule(BenchmarkActivity::class.java)

    @UiThreadTest
    @Test
    fun scrollItem() {
        val activity = activityRule.activity

        // If RecyclerView has children, the items are attached, bound, and gone through layout.
        // Ready to benchmark.
        assertTrue("RecyclerView expected to have children", activity.list.childCount > 0)

        benchmarkRule.measureRepeated {
            activity.list.scrollByOneItem()
            runWithTimingDisabled {
                activity.testExecutor.flush()
            }
        }
    }

    private fun RecyclerView.scrollByOneItem() {
        scrollBy(0, getChildAt(childCount - 1).height)
    }
}

この例では、BenchmarkActivity というダミーのActivityを作成して ActivityTestRule を定義、ActivityからRecyclerViewを取得して scrollByOneItem の速度を計測しています。
@UiThreadTest アノテーションをつけることで、そのテストメソッドがメインスレッドで実行され、runWithTimingDisabled を利用することで、そのブロック内の時間計測を除外して実行することができます。

inline fun <T> runWithTimingDisabled(block: () -> T): T {
    getOuterState().pauseTiming()
    val ret = block()
    getOuterState().resumeTiming()
    return ret
}

まとめ

独立したモジュールで、ベンチマークの計測環境を構築し、気軽に計測できるのは非常に便利です。
コードレビューにおいて、書き方による速度の違いで議論になった場合には、実際にテストを書いて比較・判断することができると思います。

一方で、Android Dev Summit '19の動画や、Mediumに掲載されている記事( Fighting Regressions with Benchmarks in CI
)で紹介されていますが、CIなどで継続的に改善を行おうと思った場合、閾値などを設定するにしても計測対象によってその増減の割合が異なることや、一時的なスパイクのような形で計測が不安定になることはありえるため、継続的にデータ収集を行なった場合でもリグレッションが発生しているという検知を行うことは難しいようです。上記の記事では、ステップフィッティングによって、特定のパターンをリグレッションとして判断することで、検知を行う内容が紹介されていました。
気軽に日々のCIで検知できるというよりは分析をして判断するという印象を受けたので、この辺りがより楽に確認できるようになると採用しやすくなるのかなと感じました。

参考