事例紹介

R8 により Android での Kotlin コルーチンが 2 倍高速化

7 min read

AGP 9.2.0 以降、R8 はほとんどの Atomic*FieldUpdater 呼び出しを Unsafe バリアントに最適化し、一般的なオペレーションで 2 ~ 4 倍のパフォーマンスを実現します。これは、kotlinx.coroutines のアトミックを実装する kotlinx.atomicfu ライブラリに特に大きな影響を与え、コルーチンの起動とキャンセルを最大 2 倍高速化します。この特典を得るには、AGP を 9.2.0 以降に更新してください。

Android アプリの大部分が Kotlin をメイン言語として採用しているため、kotlinx.coroutines は非同期プログラミングのデファクト スタンダードになっています。このライブラリは、Kotlin ネイティブの同時実行フローを管理するための、適切に設計された構造化された方法を提供します。Jetpack Compose も例外ではなく、ポインタ イベント、アニメーション、その他のインタラクションの管理にコルーチンを採用しています。執筆時点では、Compose のほとんどの同時実行 API は内部で suspend 関数を呼び出し、更新を処理するためにコルーチンを起動またはキャンセルしています。

Compose チームがパフォーマンスの調査を開始したところ、コルーチンがコンポジション外で発生する多くのオペレーションのボトルネックになっていることがわかりました。たとえば、Modifier.clickable の作成と更新に費やされた時間の 80% は、InteractionSource の更新を処理する内部コルーチンの起動とキャンセルに費やされていました。これらの観察に基づいて、初期のパフォーマンス改善作業の多くは、コルーチンをデフォルト パスから削除し、必要なときに初期化を遅らせることに重点が置かれました。

コルーチンのコスト

Android で関数の内部動作を分析する最も簡単な方法は、Android ランタイム(ART)メソッド トレースをキャプチャすることです。ART メソッド トレースは、アプリの実行フローを記録するツールで、呼び出されるメソッド、その順序、各メソッドで費やされる時間を正確に示し、デベロッパーがパフォーマンスのボトルネックを特定できるようにします。空の LaunchedEffect { } 呼び出しの場合、次のようになります。

pic01_enhanced.png
Perfetto UI で視覚化された LaunchedEffect メソッド トレース

上記のメソッド トレースは、次の 3 つの部分に分けることができます。

  • 新しいコルーチンの初期化
  • コルーチンの開始
  • コルーチンの完了(すぐに終了するため)

LaunchedEffect のキャンセルは通常の完了と似ていますが、CancellationException も作成されます。

上記のプロファイルから、すぐに疑わしいのは、java.util.concurrent.AtomicReferenceFieldUpdater への頻繁な呼び出しです(j… ラベルの付いた紫または緑のボックス)。各呼び出しは比較的高速ですが、頻度が問題です。複数の呼び出しに分散された無視できないオーバーヘッドは、顕著な回帰につながる可能性があります。呼び出しを拡大すると、時間のほとんどがリフレクション チェックに費やされていることがわかります。

pic02-enhanced.png
LaunchedEffect の初期化中の AtomicReferenceFieldUpdater.get のメソッド トレースの詳細

コルーチンは、親子関係にロックフリーのツリー構造を実装し、構造化された同時実行を可能にします。kotlinx.atomicfu ライブラリは、よく知られた JVM プリミティブである AtomicReferenceFieldUpdater を使用して、ロックフリーのアトミック オペレーションを実装します。アップデータは、クラス参照とフィールド名を使用して実行時にアトミック オペレーションを実行します。フィールドが存在し、アクセス可能であることを確認するために、いくつかのリフレクション セーフティ チェックを実行する必要があります。コルーチンの各オペレーション(開始、一時停止、キャンセル、完了)は、少なくとも 1 つのアトミック オペレーションを呼び出すため、遅い場合はコルーチンが適切に機能しません。

AtomicReferenceFieldUpdater の調査

先に進みましょう。AtomicReferenceFieldUpdater は、JVM で 10 年以上前から 最適化されており、メソッド トレースでは、VM レベルの最適化(ジャストインタイム(JIT)コンパイルまたは事前(AOT)コンパイル)によって完全に削除されるオーバーヘッドがキャプチャされることがあります。パフォーマンスを確認するために、kotlinx.atomicfujava.util.concurrent.atomic のアトミック参照の違いを測定するベンチマークをいくつか作成しましょう。

@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    private val atomicReference = java.util.concurrent.atomic.AtomicReference(false)
    private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false)
    
    @Test
    fun atomicReference_compareAndSet() {
        benchmarkRule.measureRepeated { 
            atomicReference.compareAndSet(true, false)
            atomicReference.compareAndSet(false, true)
        }
    }

     @Test
    fun atomicRef_compareAndSet() {
        benchmarkRule.measureRepeated {
            atomicRef.compareAndSet(true, false)
            atomicRef.compareAndSet(false, true)
        }
    }

    /* measuring other methods from the method traces above */
}

このベンチマークを Google Pixel 5 で実行すると(ウォームアップ中に AtomicReferenceFieldUpdater#compareAndSet が JIT コンパイルされるようにします)、Google Pixel 5(API 33)で次の結果が得られます。

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

測定結果から、kotlinx.atomicfu バージョンが明らかに約 2.7 倍遅いことがわかります。これにより、ART は隠れた最適化を実行せず、リフレクション アクセス チェックが実行時に実際のオーバーヘッドを追加することが確認されます。

元のメソッド トレースを振り返ると、AtomicReferenceFieldUpdater によって実行される意味のある作業は、基盤となるアトミック オペレーションを実際に実行する Unsafe.getObjectVolatile への内部呼び出しのみです。ほとんどの場合、アップデータ イニシャライザは静的であり、周囲のクラスの構造に基づいて常に正しいことが証明できます。したがって、AtomicReferenceFieldUpdater の使用のほとんどを静的に分析し、コンパイル中に内部 Unsafe バリアントに置き換えることができます。また、Android ビルドツールチェーンには、まさにそれを行うことができる独自の最適化コンパイラがあります。

R8 による最適化

Atomic*FieldUpdater クラスは、微妙で動的なリフレクション ベースの使用をサポートしますが、静的に明らかなパターンで使用されることがよくあります。これは、ベースライン パフォーマンスが遅いことと、最適化の必要性を説明しています。R8 はフルプログラム最適化コンパイラであり、より単純なパターンを透過的に処理して、リフレクション セーフティ チェックのオーバーヘッドを削減するのに適しています。R8 は、Java コンパイラまたは Kotlin コンパイラの後に JVM バイトコードを受け取りますが、読みやすさのために、これらの例は Java 構文で示されています。AtomicReferenceFieldUpdater に型引数がないのはそのためです。

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

基本の例では、ホルダー、型、フィールド名の単純な定数引数を使用して、volatile フィールドにアクセスする静的な final アップデータを作成します。使用されるリフレクションは完全に透過的です。このアップデータが有効なフィールドを参照していることと、アップデータ作成サイトがフィールドに有効なアクセス権を持っていることがわかります。

本質的に、Atomic*FieldUpdater はフィールド オフセットと Unsafe への呼び出しのラッパーです。最適化の最良のシナリオは、アップデータ フィールドをオフセット フィールドに置き換え、アップデータ呼び出しを Unsafe への呼び出しに置き換えることです。

Atomic*FieldUpdater の最適化

最適化は、インストゥルメンテーション、置換、クリーンアップの 3 つの部分で実装されます。

インストゥルメンテーション

最初のステップは、Unsafe 呼び出しによる直接アクセスを容易にするために、アップデータ フィールドとともにオフセット フィールドを導入することです。

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

フィールドはリフレクションを介してアクセスされ、Unsafe はクラスのフィールド オフセットを抽出するために使用されます。このコードは、リフレクション検証を無視した場合の Atomic*FieldUpdater の内部を表します。代わりに、アップデータのホルダー型と volatile フィールドのフィールド型がコンパイラで静的に追跡されます。

 

元のフィールドとその初期化はそのまま残されます。最適化プロセスでは、使用を楽観的に促進して最適化し、後でクリーンアップします。これは実装の簡単なアプローチですが、アップデータ フィールドの部分的な最適化も可能です。一部の使用はそのまま残し、他の使用は最適化します。

置換

コンパイラのこの時点では、適切な同時実行結合点の後に、インストゥルメント化されたアップデータ フィールドのリストがあります。つまり、いくつかの条件に基づいて、各呼び出しサイトを個別に最適化できます。呼び出しの例を考えてみましょう。

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater に必要な条件は次のとおりです。

  • updater はインストゥルメント化されたフィールドから取得されますか?つまり、静的分析でオブジェクトの値をインストゥルメント化されたアップデータのフィールド読み取りまで追跡できますか?
  • holder は、元々定義されたホルダー型と同じクラスですか、またはそのサブクラスですか?
  • newValue は、元々定義されたフィールド型と同じクラスですか、またはそのサブクラスですか?

すべての条件が満たされると、呼び出しはリフレクション チェックなしで Unsafe への呼び出しに置き換えられます。

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

この新しい呼び出しは高速でシンプルですが、updaterholder の null 値の処理に関して元の呼び出しとは異なります。静的に除外されない限り、両方に null チェックが挿入されます。

クリーンアップ

この時点で、保持クラスには元のアップデータ フィールドと新しいオフセット フィールドがあり、どちらかを使用する可能性のある呼び出しサイトがあります。呼び出しサイトが最適化されていない場合は、オフセット フィールドを削除する必要があります。呼び出しサイトがすべて最適化されている場合は、アップデータ フィールドを削除する必要があります。どちらの場合も、初期化呼び出しも削除する必要があります。未使用のフィールドの削除とデッドコードの削除はコンパイラですでに完了していますが、ここで初期化コードを削除するには、いくつかの追加の処理が必要です。

newUpdatergetDeclaredField の両方の呼び出しは、例外をスローする可能性があるため、副作用が生じる可能性があります(API バージョンによって異なるため、実装も不明です)。つまり、一般的な最適化では安全に削除できません。したがって、このクリーンアップでは、インストゥルメント化されたフィールドを明示的に考慮する必要がありました。これらのフィールドは、例外がないことが静的にわかっているためです。

最終的に、上記の簡単なアップデータの例は、最適化後に次のようになります。

結果

これらの最適化により、kotlinx.atomicfuAtomicInt/Long/ReferenceFieldUpdater のほとんどの明示的な使用は、R8 が適用された AtomicReference のパフォーマンスと一致するようになりました。実際、一部のベンチマークではさらに高速です。kotlinx.atomicfu には、コンパイラ プラグインがあり、atomic インスタンスをフィールドにインライン化できるため、アトミックに更新されたフィールドの作成に必要な割り当てを削減します。

この作業の主なメリットは Jetpack Compose でした。Compose ランタイムには、コルーチンのパフォーマンスを非常に厳密に追跡して、パフォーマンスの低下を早期に検出するマイクロベンチマークがいくつかあります。ベンチマークが新しいバージョンの R8 に更新されたとき、2 倍の改善LaunchedEffectでコルーチンを起動してキャンセルする際に見られました。

pic03_enhanced.png
LaunchedEffect でコルーチンを開始してキャンセルするのにかかる時間を示すベンチマーク グラフ(低いほど良い)。グラフの変更は R8 の更新に対応しており、2 倍の改善を示しています。

これ以外にも、ART チームはこれらの最適化を VM レベルでネイティブに実装しています。アプリが API 36 をターゲットとしており、最新バージョンの Android で実行されている場合、デバイスはすでに同様の方法でコルーチンを最適化している可能性があります。上記のコルーチン ベンチマークでは、最近のバージョンの ART で JIT の更新後にパフォーマンスが約 15% 向上しました。

AGP 9.2.0 にアップグレードするか、R8 9.2.0 を直接使用すると、アプリはこの最適化をデフォルトで受け取ります。詳細については、D8 dexer と R8 shrinker をご覧ください。

Written by:
続きを見る