GPU レンダリングのプロファイル作成ツールを使って、レンダリング パイプラインの各ステージで前のフレームをレンダリングするのにかかる相対時間を確認できます。この情報からパイプライン内のボトルネックを特定できるため、アプリのレンダリング パフォーマンスを改善するために最適化すべき対象がわかります。
このページでは、各パイプライン ステージで行われる内容について簡単に説明し、ボトルネックの原因になり得る問題を検討します。このページを読む前に、GPU レンダリング速度のプロファイル作成に記載されている内容を理解しておいてください。また、各ステージが全体としてどのように連携しているかを理解するためにも、レンダリング パイプラインの仕組みを確認しておくことをおすすめします。
視覚的な表示
GPU レンダリングのプロファイル作成ツールには、各ステージとその相対時間がグラフの形式(色分けされたヒストグラム)で表示されます。図 1 に表示例を示します。
GPU レンダリングのプロファイル作成ツールのグラフに表示される各縦棒の各セグメントはパイプラインのステージを表し、棒グラフ内で特定の色を使って表示されます。図 2 に、各色の意味の凡例を示します。
各色の意味を理解すると、アプリの特定の要素に的を絞ってレンダリング パフォーマンスを最適化できるようになります。
ステージとその意味
このセクションでは、各ステージで行われる内容と、注意すべきボトルネックの原因について説明します。
Input Handling(入力処理)
パイプラインの Input Handling ステージでは、アプリが入力イベントの処理に費やした時間を測定します。この指標は、入力イベントのコールバックの結果として呼び出されるコードの実行にアプリが費やした時間を示します。
このセグメントが大きい場合
このセグメントが大きくなるのは、通常、入力ハンドラのイベント コールバック内で行われる処理が多すぎるか、複雑すぎる場合です。 これらのコールバックは必ずメインスレッドで行われるため、この問題を解決するためには、処理を直接最適化するか、処理を別のスレッドにオフロードすることに重点を置きます。
このフェーズでは、LazyColumn または LazyRow のスクロールも表示できます。ユーザーのタッチがスクロールと見なされると、遅延リストはタッチイベントを消費して、アイテムを動的にコンポーズしてレイアウトします。アプリがスクロール位置の変更に応じてカスタム作業を行う場合は、フレーム落ちを防ぐために、このオペレーションをできるだけ高速にすることが重要です。Android Studio の CPU Profiler や Perfetto などのプロファイリング ツールを使用すると、さらに詳しく調べることができます。詳細については、システム トレースの概要をご覧ください。
アニメーション
Animation フェーズは、フレーム内で実行されていたすべてのアニメーション状態を評価するのにかかった時間を示します。Compose の一般的なアニメーション API には、animate*AsState、Transition、Animatable などがあります。また、このフェーズでは Recomposer が実行され、スナップショット状態の変更を処理してコンポジションを更新します。つまり、再コンポーズのオーバーヘッドはアニメーション ステージで直接発生することがよくあります。
Jetpack Compose UI の場合は、Compose ランタイム トレース ライブラリを含めて、システム イベントとともに詳細なコンポジション トレースを確認します。
このセグメントが大きい場合
このセグメントが大きくなるのは、通常、アニメーションによって駆動される状態の変化に伴って実行される処理に起因します。たとえば、LazyColumn または LazyRow をスクロールするフリング アニメーションは、新しいリストアイテムの高速なコンポジション、測定、割り当ての原因となります。
測定
コンポーザブルを画面に描画するために、Android は UI ツリーのレイアウト ノードで 3 つのフェーズを実行します。
まず、システムがレイアウト ノードを測定します。すべてのコンポーザブルには、画面上のオブジェクトのサイズ制限を示す特定の制約と修飾子があります。コンポーザブルには、特定の固定サイズを指定できるものと、親レイアウト コンテナから渡された制約に適合するサイズを指定できるものがあります。
次に、システムがレイアウト ノードを配置します。Compose は、測定フェーズで子ノードのサイズを計算すると、配置フェーズに進み、画面上でレイアウト ノードのサイズと位置を設定します。
システムは効率性を高めるため、常にこのシングルパス レイアウトを実行します。コンポーザブル レイアウトが無効になると、Compose はその特定のノードを測定し、子ノードのサイズまたは制約が変更された場合にのみ、レイアウトの更新を親階層に伝播します。
このセグメントが大きい場合
この領域のセグメントが大きい場合は、アプリがレイアウト フェーズに時間をかけすぎています。このフェーズでは、レイアウト ノードの位置決めとサイズの決定が行われます。これらのオペレーションには、コンポーザブルの測定と配置の修飾子の実行が含まれます。レイアウト ツリーが複雑すぎると、フレームの準備が遅れる可能性があります。このような場合は、Compose アプリのベンチマークを実施し、パフォーマンスのベスト プラクティスに沿ってパフォーマンスに対処します。
Android Studio の CPU Profiler または Perfetto を使用してレイアウト パスを検査し、ボトルネックを特定します。詳細については、システム トレースの概要をご覧ください。
引き分け
Draw ステージでは、レンダリング処理(背景、図形、テキストの描画など)を一連のネイティブな描画コマンドに変換します。システムはこれらのコマンドをキャプチャして、GPU で実行するための表示リストに追加します。
Draw セグメントは、コマンドをキャプチャして表示リストに追加するのにかかる時間を示します(画面のフレーム内で更新する必要があるすべてのレイアウト ノードが対象)。測定された時間は、draw 修飾子または Canvas コンポーザブル内にあるカスタム描画ロジックにも適用されます。
このセグメントが大きい場合
この指標は簡略化して述べると、無効化されたレイアウト ノードごとに描画コマンドをすべて実行するのにかかった時間を示します。この測定には、これらのコマンドを子ノードとベクター ドローアブルにディスパッチするのにかかった時間が含まれます。そのため、このセグメントが急に大きくなった場合、多数のコンポーザブルが急に無効化されたことが原因である可能性があります。無効化された場合は、描画コマンドを再実行し、レイアウト ノードの表示リストを再生成する必要があります。また、時間がかかる原因として、いくつかのカスタム コンポーザブルまたはキャンバスの DrawScope 実装のロジックが非常に複雑であることも考えられます。
また、Compose は、プラットフォームが描画フェーズと見なす内部の測定とレイアウトのパスを処理することがよくあります。したがって、Draw セグメントが大きくなる原因は、描画コマンドだけではなく、コストの高い内部の測定/レイアウト オペレーションが過剰に行われていることも考えられます。不明な場合は、Perfetto トレースをキャプチャして、オーバーヘッドが描画ルーチンまたは Compose の測定とレイアウトのパスに起因するかどうかを確認します。
アップロード
アップロードの指標は、現在のフレームでビットマップ オブジェクトを CPU メモリから GPU メモリに転送するのにかかる時間を表します。
CPU と GPU は別々のプロセッサであるため、処理専用の RAM 領域も異なります。Android では、ビットマップを描画する際に、GPU がビットマップを画面にレンダリングする前にシステムによってビットマップが GPU メモリに転送されます。さらに、テクスチャが GPU テクスチャ キャッシュから削除されない限りシステムがデータを再転送する必要が生じないよう、GPU がビットマップをキャッシュに保存します。
注: Lollipop デバイスでは、このステージは紫色で表示されます。
このセグメントが大きい場合
フレーム用のすべてのリソースは、フレームの描画に使用する前に GPU メモリ内に存在している必要があります。つまり、このセグメントが大きい場合、少量のリソースの読み込みを何度も行ったか、大量のリソースの読み込みを数回だけ行ったことを意味します。よくあるのは、画面のサイズに近い単一のビットマップをアプリで表示するケースです。また、アプリで多数のサムネイルを表示するケースもあります。
このセグメントを小さくするには、次のような手法を使用します。
- ビットマップの解像度が表示サイズよりあまり大きくならないようにします。たとえば、1024x1024 の画像を 48x48 の画像として表示しないようにします。
- Coil などの最新のライブラリを利用して、次の同期フェーズの前にビットマップを非同期で事前にアップロードします。
Issue Commands(コマンドの発行)
Issue Commands セグメントは、表示リストを画面に描画するのに必要なすべてのコマンドを発行するのにかかる時間を表します。
システムで表示リストを画面に描画する場合、必要なコマンドが GPU に送信されます。この操作は通常、OpenGL ES API を使用して実行されます。
システムがコマンドを GPU に送信する前にコマンドごとに最終的な変換とクリップを実行するため、このプロセスには時間がかかります。さらに、GPU 側で追加のオーバーヘッド(最終的なコマンドの計算)が生じます。これらのコマンドには、最終的な変換と追加のクリップが含まれています。
このセグメントが大きい場合
このステージで費やされる時間は、システムが特定のフレームにレンダリングする表示リストの複雑さと量の直接的な指標になります。たとえば、描画処理が多い場合(特に、各描画プリミティブに固有のコストが少しずつある場合)、この時間が長くなる可能性があります。 次に例を示します。
for (i in 0 until 1000) { canvas.drawPoint() }
上のコードは次のコードより発行コストがかなり大きくなります。
canvas.drawPoints(thousandPointArray)
コマンドの発行と表示リストの実際の描画の間に、常に 1 対 1 の関係があるとは限りません。描画指標は、描画コマンドを GPU に送信するのにかかる時間をキャプチャする発行コマンド バーとは異なり、発行済みのコマンドをキャプチャして表示リストに追加するのにかかった時間を表します。
この違いが生じるのは、表示リストが可能な限りシステムによってキャッシュに保存されるためです。そのため、スクロール、変換、アニメーションを行うためにシステムが表示リストを再送信しなければならないことはありますが、実際に表示リストをゼロから再作成(描画コマンドを再キャプチャ)する必要はありません。結果として、Draw セグメントは大きくならず、Issue Commands セグメントが大きくなることがあります。
バッファを入れ替える
Android が表示リストを GPU に送信し終えると、現在のフレームの描画が完了したことをグラフィックス ドライバに伝えるために、システムが最後の 1 つのコマンドを発行します。ドライバはこの時点でようやく、更新された画像を画面に表示できます。
このセグメントが大きい場合
重要なのは、GPU と CPU が同時に処理を実行することを理解することです。Android システムは描画コマンドを GPU に発行してから次のタスクに進みます。GPU は描画コマンドをキューから読み取って処理します。
GPU によるコマンドの処理が CPU によるコマンドの発行に追いつかない場合、プロセッサ間の通信キューがいっぱいになることがあります。その場合、CPU はブロックを行い、次のコマンドをキューに登録できるようになるまで待機します。キューがいっぱいの状態は、Swap Buffers ステージでよく発生します。これは、この時点でフレーム全体のコマンドがすでに送信されているためです。
この問題を緩和するには、Issue Commands フェーズで行う処理と同じ方法で、GPU で行われる処理の複雑さを低減することが重要です。
その他
レンダリング システムが処理を実行するのにかかる時間に加え、メインスレッドで実行される、レンダリングと無関係の処理もあります。この処理にかかる時間は、「その他の時間」として報告されます。これは一般に、2 つの連続するレンダリング フレーム間において UI スレッドで実行される可能性がある処理を表します。
このセグメントが大きい場合
このセグメントが大きい場合、コールバック、インテント、または別のスレッドで実行する必要があるその他の処理がアプリに含まれている可能性があります。Android Studio の CPU Profiler や Perfetto などのツールを使用すると、メインスレッドで実行されているタスクを表示できます。この情報を活用することで、パフォーマンスの改善点を絞り込むことができます。詳細については、システム トレースの概要をご覧ください。