プロダクト ニュース

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

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

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

続きを読む