プロダクト ニュース

コンパイル速度が 18% 向上、妥協は一切なし

8 分で読了

Android ランタイム(ART) チームは、コンパイル済みコードやピーク時のメモリ使用量の回帰に影響を与えることなく、コンパイル時間を 18% 短縮しました。この改善は、メモリ使用量やコンパイル済みコードの品質を犠牲にすることなくコンパイル時間を短縮するという、2025 年の取り組みの一環として行われました。

コンパイル時の速度の最適化は、ART にとって非常に重要です。たとえば、ジャストインタイム(JIT)コンパイルは、アプリケーションの効率とデバイス全体のパフォーマンスに直接影響します。コンパイルが高速化されると、最適化が開始されるまでの時間が短縮され、ユーザー エクスペリエンスがよりスムーズでレスポンシブになります。さらに、JIT と事前(AOT)の両方で、コンパイル時の速度が向上すると、コンパイル プロセス中のリソース消費量が削減され、特にローエンド デバイスでバッテリー駆動時間とデバイスの温度が改善されます。

これらのコンパイル時の速度の改善の一部は、2025 年 6 月の Android リリースで導入され、残りは年末の Android リリースで提供される予定です。さらに、バージョン 12 以降のすべての Android ユーザーは、Mainline アップデートを通じてこれらの改善を受けられます。

最適化コンパイラの最適化

コンパイラの最適化は、常にトレードオフのゲームです。速度を無料で手に入れることはできません。何かを犠牲にする必要があります。Google は、コンパイラを高速化する一方で、メモリ使用量の回帰を発生させず、生成されるコードの品質を低下させないという、非常に明確で困難な目標を設定しました。コンパイラが高速化されてもアプリの実行速度が低下しては、失敗です。

Google は、この厳格な基準を満たす巧妙な解決策を見つけるために、開発時間を費やすことをいといませんでした。改善すべき領域を見つけるための取り組みと、さまざまな問題に対する適切なソリューションを見つける方法について詳しく見ていきましょう。

価値のある最適化候補を見つける

指標の最適化を開始する前に、その指標を測定できる必要があります。そうしないと、改善されたかどうかを判断できません。幸いなことに、変更前と変更後で測定に使用するデバイスを同じにする、デバイスのサーマル スロットリングが発生しないようにするなど、いくつかの注意点を守れば、コンパイル時間の速度はかなり安定しています。さらに、コンパイラの統計情報などの決定論的な測定値も取得できるため、内部で何が起こっているかを把握できます。

 

これらの改善のために犠牲にしたリソースは開発時間だったため、できるだけ迅速に反復処理を行えるようにしたいと考えました。そこで、代表的なアプリ(ファーストパーティ アプリ、サードパーティ アプリ、Android オペレーティング システム自体)をいくつか選び、ソリューションのプロトタイプを作成しました。その後、最終的な実装が価値のあるものかどうかを、手動テストと自動テストの両方で広範囲に検証しました。

 

厳選した APK のセットを使用して、ローカルで手動コンパイルをトリガーし、コンパイルのプロファイルを取得して、pprof を使用して時間の費やされている場所を可視化しました。

image.png

pprof のプロファイルのフレームグラフの例

pprof ツールは非常に強力で、データをスライス、フィルタ、並べ替えして、たとえば、どのコンパイラ フェーズまたはメソッドに最も時間がかかっているかを確認できます。pprof 自体については詳しく説明しませんが、バーが大きいほどコンパイルに時間がかかったことを意味します。

これらのビューの 1 つに「ボトムアップ」ビューがあります。このビューでは、どのメソッドに最も時間がかかっているかを確認できます。下の画像では、Kill というメソッドがコンパイル時間の 1% 以上を占めていることがわかります。他の上位メソッドについては、このブログ投稿で後ほど説明します。

image.png

プロファイルのボトムアップ ビュー

最適化コンパイラには、グローバル値番号付け(GVN)というフェーズがあります。全体的な動作を気にする必要はありませんが、関連する部分として、フィルタに応じて一部のノードを削除する `Kill` というメソッドがあることを知っておく必要があります。すべてのノードを反復処理して 1 つずつ確認する必要があるため、時間がかかります。その時点でアクティブなノードに関係なく、チェックが false になることが事前にわかっているケースがあることに気づきました。このような場合、反復処理を完全にスキップできるため、1.023% から ~0.3% に減らし、GVN のランタイムを ~15% 改善できます。

価値のある最適化の実装

時間の測定方法と費やされている場所の検出方法について説明しましたが、これは始まりにすぎません。次のステップは、コンパイルに費やされる時間を最適化する方法です。

通常、上記の `Kill` のようなケースでは、ノードの反復処理の方法を確認し、並列処理やアルゴリズム自体の改善などによって高速化します。実際、最初はそれを試しましたが、何もできないことがわからなかったときに、「ちょっと待って…」と思い、ソリューションは(場合によっては)反復処理をまったく行わないことだと気づきました。このような最適化を行う場合、木を見て森を見ないということがよくあります。

その他のケースでは、次のようなさまざまな手法を使用しました。

  • ヒューリスティックを使用して、最適化によって価値のある結果が得られないかどうかを判断し、スキップできるようにする
  • 追加のデータ構造を使用して、計算されたデータをキャッシュに保存する
  • 現在のデータ構造を変更して速度を向上させる
  • 場合によっては、サイクルを回避するために結果を遅延計算する
  • 適切な抽象化を使用する - 不要な機能はコードの速度を低下させる可能性がある
  • 頻繁に使用されるポインタを多くのロードで追跡しないようにする

最適化を進める価値があるかどうかを判断する方法

それは、わかりません。ある領域でコンパイル時間が長くなっていることを検出し、開発時間を費やして改善しようとしても、解決策が見つからないことがあります。何もできない、実装に時間がかかりすぎる、別の指標が大幅に回帰する、コードベースの複雑さが増すなどです。このブログ投稿で紹介されている最適化が成功したケースの裏には、実現しなかった無数のケースがあることを知っておいてください。

同様の状況にある場合は、できるだけ少ない作業で指標をどの程度改善できるかを推定してみてください。つまり、次の順序で推定します。

  1. すでに収集した指標または直感に基づいて推定する
  2. 簡単なプロトタイプで推定する
  3. 解決策を実装する。

解決策の欠点を推定することも忘れないでください。たとえば、追加のデータ構造に依存する場合、どの程度のメモリを使用してもよいと考えていますか?

さらに詳しく

それでは、実装した変更の一部を見ていきましょう。

FindReferenceInfoOf というメソッドを最適化する変更を実装しました。このメソッドは、エントリを見つけるためにベクトルの線形検索を行っていました。このデータ構造を命令の ID でインデックス化するように更新し、FindReferenceInfoOf が O(n) ではなく O(1) になるようにしました。また、サイズ変更を避けるためにベクトルを事前に割り当てました。ベクトルに挿入したエントリ数をカウントする追加のフィールドを追加する必要があったため、メモリはわずかに増加しましたが、ピーク時のメモリ使用量は増加しなかったため、小さな犠牲でした。これにより、LoadStoreAnalysis フェーズが 34 ~ 66% 高速化され、コンパイル時間が ~0.5 ~ 1.8% 改善されました。

HashSet のカスタム実装があり、いくつかの場所で使用しています。このデータ構造の作成にかなりの時間がかかっていたため、その理由を調べました。何年も前、このデータ構造は非常に大きな HashSet を使用する少数の場所でのみ使用されており、そのために最適化されていました。しかし、最近では、エントリ数が少なく、有効期間が短いという逆の状況で使用されていました。つまり、この巨大な HashSet を作成してサイクルを無駄にしていましたが、破棄する前に使用したのはわずかなエントリだけでした。この変更により、コンパイル時間が ~1.3 ~ 2% 改善されました。以前ほど大きなデータ構造を使用しなくなったため、メモリ使用量が ~0.5 ~ 1% 減少しました。

ラムダにデータ構造をリファレンスで渡してコピーを回避することで、コンパイル時間が ~0.5 ~ 1% 改善されました。これは、最初のレビューで見落とされ、何年もコードベースに残っていたものです。pprof でプロファイルを確認したところ、これらのメソッドが多くのデータ構造を作成して破棄していることに気づき、調査して最適化しました。

計算された値をキャッシュに保存することで、コンパイル済み出力を書き込むフェーズを高速化し、コンパイル時間の合計が ~1.3 ~ 2.8% 改善されました。残念ながら、追加の簿記処理が多すぎたため、自動テストでメモリ使用量の回帰が検出されました。その後、同じコードを再度確認し、メモリ使用量の回帰に対処するだけでなく、コンパイル時間をさらに ~0.5 ~ 1.8% 改善する新しいバージョンを実装しました。この 2 回目の変更では、2 つのデータ構造の 1 つを削除するために、このフェーズの動作方法をリファクタリングして再考する必要がありました。

最適化コンパイラには、パフォーマンスを向上させるために関数呼び出しをインライン化するフェーズがあります。インライン化するメソッドを選択するために、計算を行う前にヒューリスティックを使用し、作業後、インライン化を確定する直前に最終チェックを行います。インライン化する価値がないと判断された場合(たとえば、新しい命令が多すぎる場合)、メソッド呼び出しはインライン化されません。

時間のかかる計算を行う前に、インライン化が成功するかどうかを推定するために、2 つのチェックを「最終チェック」カテゴリから「ヒューリスティック」カテゴリに移動しました。これは推定であるため完璧ではありませんが、新しいヒューリスティックが、パフォーマンスに影響を与えることなく、以前インライン化されていたものの 99.9% をカバーしていることを確認しました。これらの新しいヒューリスティックの 1 つは必要な DEX レジスタ(~0.2 ~ 1.3% の改善)に関するもので、もう 1 つは命令数(~2% の改善)に関するものでした。

BitVector のカスタム実装があり、いくつかの場所で使用しています。特定の固定サイズのビットベクトルに対して、サイズ変更可能な BitVector クラスをよりシンプルな BitVectorView に置き換えました。これにより、間接参照とランタイム範囲チェックが一部削除され、ビットベクトル オブジェクトの構築が高速化されます。

さらに、BitVectorView クラスは、基盤となるストレージ タイプ(古い BitVector のように常に uint32_t を使用するのではなく)でテンプレート化されました。これにより、Union() などの一部のオペレーションで、64 ビット プラットフォームで 2 倍のビット数を同時に処理できます。Android OS のコンパイル時に、影響を受ける関数のサンプルが合計で 1% 以上削減されました。これは、いくつかの変更 [123456] にわたって行われました。

すべての最適化について詳しく説明すると、1 日中かかってしまいます。他の最適化に関心がある場合は、実装した他の変更をご覧ください。

まとめ

ART のコンパイル時の速度の改善に尽力した結果、大幅な改善が実現し、Android がよりスムーズで効率的になり、バッテリー駆動時間とデバイスの温度も改善されました。最適化を熱心に特定して実装することで、メモリ使用量やコード品質を損なうことなく、コンパイル時間を大幅に短縮できることを実証しました。

この取り組みでは、pprof などのツールを使用したプロファイリング、反復処理への意欲、成果の少ない方法を放棄することも必要でした。ART チームの共同作業により、コンパイル時間を大幅に短縮できただけでなく、今後の進歩の基盤も築くことができました。

これらの改善はすべて、2025 年末の Android アップデートで利用できます。Android 12 以降では、Mainline アップデートを通じて利用できます。この最適化プロセスの詳細な説明が、コンパイラ エンジニアリングの複雑さとメリットについての貴重な洞察を提供できれば幸いです。

続きを読む