ジッターに関連するジャンクを特定する

ジッターとは、ユーザーが知覚できる処理の実行を妨げる、ランダムなシステム動作です。このページでは、ジッターに関連するジャンクの問題を特定して対処する方法を説明します。

アプリスレッド スケジューラの遅延

スケジューラの遅延は、ジッターの最も顕著な兆候です。これは、実行すべきプロセスが実行可能状態になっても、かなり長い間実行されないという現象です。遅延の深刻度は、状況によって異なります。次に例を示します。

  • アプリ内のランダムなヘルパー スレッドは、ほとんどの場合ミリ秒単位で大幅に遅延しても問題ありません。
  • アプリの UI スレッドは、1~2 ミリ秒のジッターであれば許容できます。
  • SCHED_FIFO として実行されるドライバ kthread は、実行前の実行可能状態が 500 マイクロ秒続くと、問題を引き起こすことがあります。

実行可能時間は、systrace で、スレッドの実行中セグメントの前にある青いバーによって識別できます。また、実行可能時間は、スレッドの sched_wakeup イベントから、スレッド実行の開始を通知する sched_switch イベントまでの時間の長さとしても計算できます。

実行時間が長すぎるスレッド

実行可能時間が長すぎるアプリの UI スレッドは問題を引き起こす可能性があります。 一般的に、下位レベルのスレッドで実行可能時間が長くなる原因はさまざまですが、UI スレッドの実行可能時間をゼロに近づけようとすると、下位レベルのスレッドの実行可能時間が長くなる問題をいくつか修正することが必要になる場合があります。遅延を軽減するには、次の措置を講じます。

  1. サーマル スロットリングの説明に従って cpuset を使用します。
  2. CONFIG_HZ の値を増やします。
    • かつて、arm と arm64 プラットフォームでは、この値が 100 に設定されていました。 しかし、これは意味があってのことではなく、この値はインタラクティブなデバイスには適していません。CONFIG_HZ=100 は、jiffy の長さが 10 ミリ秒であることを意味します。つまり、CPU 間の負荷分散に 20 ミリ秒(2 jiffy)を要するということです。この設定は、負荷がかかるシステムでのジャンクの発生に大きく影響する可能性があります。
    • 最近のデバイス(Nexus 5X、Nexus 6P、Pixel、Pixel XL)は、出荷時に CONFIG_HZ=300 に設定されています。これにより、電力コストを無視できる程度に抑えつつ、実行可能時間を大幅に短縮できます。CONFIG_HZ を変更した後、電力消費またはパフォーマンスに関する問題が著しく増加した場合は、いずれかのドライバで使用されているタイマーで jiffy がミリ秒に変換されずそのまま適用されている可能性があります。通常、これは簡単に修正できます(Nexus 5X と 6P で CONFIG_HZ=300 に変換する際の kgsl タイマーの問題を修正したパッチをご覧ください)。
    • 最後に、Nexus/Pixel で CONFIG_HZ=1000 をテストした結果、RCU オーバーヘッドの減少により大幅なパフォーマンスの向上と電力消費の削減を実現できることがわかりました。

上記の 2 つの変更だけで、負荷がかかった状況におけるデバイスの UI スレッドの実行可能時間を大幅に改善できます。

sys.use_fifo_ui を使用する

UI スレッドの実行可能時間をゼロに近づけるには、sys.use_fifo_ui プロパティを 1 に設定します。

警告: 容量対応の RT スケジューラがない場合、異種 CPU 構成ではこのオプションを使用しないでください。現時点では、現在出荷されている RT スケジューラは容量対応ではありません。EAS 用の開発に取り組んではいますが、今はまだご利用いただけません。デフォルトの RT スケジューラは、RT 優先度と、同等以上の優先度を持つ RT スレッドがすでに CPU にあるかどうかのみに基づいて動作します。

したがって、デフォルトの RT スケジューラは、優先度の高い FIFO kthread が同じ大きなコアで起動された場合、相対的にランタイムが長い UI スレッドを、周波数が高い大きなコアから周波数が最低の小さなコアに移動します。これにより、パフォーマンスが大幅に回復します。このオプションは出荷中の Android デバイスではまだ使用されていないため、使用を希望される場合は Android パフォーマンス チームにご連絡ください。検証をお手伝いいたします。

sys.use_fifo_ui が有効になっている場合、ActivityManager は、トップアプリの UI スレッドと RenderThread(UI にとって最も重要な 2 つのスレッド)をトラッキングし、これらのスレッドを SCHED_OTHER ではなく SCHED_FIFO にします。これにより、UI と RenderThread から効果的にジッターを排除できます。このオプションを有効にして収集されたトレースを見ると、実行可能時間はミリ秒単位ではなくマイクロ秒単位になっています。

ただし、RT ロードバランサが容量対応でないため、アプリ起動のパフォーマンスは 30% 低下しました。これは、アプリの起動を担う UI スレッドが 2.1 Ghz のゴールド Kryo コアから 1.5 GHz のシルバー Kryo コアに移動されたためです。容量対応の RT ロードバランサを使用すると、Google の UI ベンチマークの多くでは、一括オペレーションで同等のパフォーマンスが得られ、95 パーセンタイル フレーム時間と 99 パーセンタイル フレーム時間が 10~15% 短縮されます。

割り込みトラフィック

ARM プラットフォームでは、デフォルトでは CPU 0 への割り込みのみが提供されているため、IRQ バランサ(irqbalance、または Qualcomm プラットフォームでは msm_irqbalance)の使用をおすすめします。

Pixel の開発時には、CPU 0 への大量の割り込みが直接の原因と思われるジャンクが確認されました。たとえば、mdss_fb0 スレッドが CPU 0 でスケジューリングされた場合、スキャンアウトのほぼ直前にディスプレイによってトリガーされる割り込みが原因でジャンクが発生する確率が大幅に増大しました。mdss_fb0 は期限が迫った自身の処理に追われ、そのために MDSS 割り込みハンドラの処理時間が遅れます。当初、私たちは mdss_fb0 スレッドの CPU アフィニティを CPU 1~3 に設定し、割り込みとの競合を回避することでこの問題を解決しようとしましたが、msm_irqbalance をまだ有効にしていなかったことに気づきました。msm_irqbalance を有効にすると、mdss_fb0 と MDSS 割り込みが両方とも同じ CPU にある場合でも、他の割り込みとの競合が減少したため、ジャンクが著しく改善されました。

これは、systrace で、sched セクションと irq セクションを調べれば確認できます。sched セクションは何がスケジュールされているかを示しますが、irq セクション内での重複領域は、その時間に正規にスケジュールされたプロセスではなく割り込みが実行されたことを示します。割り込みにかなりの時間がかかっている場合の対策には、以下のオプションがあります。

  • 割り込みハンドラを高速化する。
  • 最初から割り込みが発生しないようにする。
  • 割り込みの頻度を変更して、相互に干渉する可能性がある他の通常の処理とタイミングがずれるようにする(定期的な割り込みの場合)。
  • 割り込みの CPU アフィニティを直接設定して、均等に分散されないようにする。
  • 割り込みが干渉するスレッドの CPU アフィニティを、割り込みが回避できるように設定する。
  • 割り込みバランサを使用して、より負荷が少ない CPU に割り込みを移動する。

一般的には CPU アフィニティの設定は推奨されませんが、特定のケースでは有用です。一般的な割り込みが発生するシステムの状態を予測するのは通常はきわめて困難ですが、システムに通常より多くの制約がある場合(VR など)に特定の割り込みをトリガーする限定的な条件の組み合わせが存在する場合は、明示的な CPU アフィニティが適切な解決策になることがあります。

長い softirq

softirq の実行中は、それによってプリエンプションが無効になります。また、softirq はカーネル内のさまざまな場所で起動することが可能で、ユーザー プロセス内でも実行できます。softirq アクティビティが十分に存在する場合、ユーザー プロセスは softirq の実行を停止し、ksoftirqd が起動して softirq を実行することで、負荷が分散されます。通常はそれで問題ありません。 しかし、1 件の softirq が非常に長い場合、システムに混乱を引き起こす可能性があります。


softirq はトレースの irq セクション内に示されることから、トレース中に問題を再現できる場合は簡単に見つけられます。softirq はユーザー プロセス内で実行できるため、不適切な softirq が明確な理由もなくユーザー プロセス内の余分なランタイムとして現れることもあります。そのような softirq が見つかった場合は、irq セクションをチェックして softirq が問題の原因かどうか確認してください。

プリエンプションまたは IRQ を長時間無効にするドライバ

プリエンプションまたは割り込みを長時間(数十ミリ秒)無効にすると、ジャンクが発生します。一般的に、ジャンクは、特定の CPU で実行可能になったまま実行されないスレッドとして現れます。これは、この実行可能なスレッドが他のスレッドよりはるかに高い優先度を持っている(または SCHED_FIFO である)場合でも同様です。

以下に、ガイドラインをいくつか示します。

  • 実行可能なスレッドが SCHED_FIFO で、実行中のスレッドが SCHED_OTHER である場合、実行中のスレッドでプリエンプションまたは割り込みが無効になります。
  • 実行可能なスレッドの優先度(100)が実行中のスレッドの優先度(120)よりもかなり高い場合、実行可能なスレッドが 2 jiffy 以内に実行されなければ、実行中のスレッドでプリエンプションまたは割り込みが無効になっている可能性があります。
  • 実行可能なスレッドと実行中のスレッドの優先度が同じである場合、実行可能なスレッドが 20 ミリ秒以内に実行されなければ、実行中のスレッドでプリエンプションまたは割り込みが無効になっている可能性があります。

割り込みハンドラを実行すると、他の割り込みを処理できなくなり、プリエンプションも無効になることにご注意ください。


問題を引き起こす領域を特定するもう 1 つのオプションは、preemptirqsoff トレーサを使用することです(動的 ftrace を使用するをご覧ください)。このトレーサを使用すると、割り込み不可能な領域(関数名など)の根本原因を詳細に分析できますが、有効活用するには侵襲的な作業が必要になります。パフォーマンスに大きく影響する可能性はありますが、試してみる価値は間違いなくあります。

ワークキューの不適切な使用

割り込みハンドラは、割り込みコンテキスト外で実行できる作業を頻繁に行う必要があるため、カーネル内の別のスレッドに作業を「外注」することができます。ドライバのデベロッパーは、ワークキューと呼ばれる、システム全体にまたがる大変便利な非同期タスク機能がカーネルにあることに着目して、割り込み関連の作業に使用したいと考えるかもしれません。

しかし、ほとんどの場合、これは問題の解決方法としては誤りです。ワークキューは常に SCHED_OTHER だからです。多くのハードウェア割り込みはパフォーマンスのクリティカル パスに存在し、直ちに実行する必要があります。ワークキューの場合、割り込みがいつ実行されるかは確実ではありません。これまでに確認された事例では、パフォーマンスのクリティカル パスに存在するワークキューは、デバイスにかかわらず散発的なジャンクの原因になっています。主力プロセッサを搭載した Pixel では、スケジューラの動作やシステム上で実行される他の作業によってデバイスに負荷がかかっている場合、単一のワークキューで最大 7 ミリ秒の遅延が発生する事例が確認されました。

個別のスレッド内で割り込みに似た作業を処理する必要があるドライバは、ワークキューを使用する代わりに、ドライバ固有の SCHED_FIFO kthread を作成しなければなりません。kthread_work 関数を利用してこれを行う方法については、こちらのパッチをご覧ください。

フレームワークのロック競合

フレームワークのロック競合は、ジャンクや他のパフォーマンスに関する問題の原因になる可能性があります。通常、これは ActivityManagerService ロックによって発生しますが、他のロックでも見られます。たとえば、PowerManagerService ロックは画面のパフォーマンスに影響することがあります。デバイスでこの問題が確認された場合、適切な修正方法はありません。改善するには、フレームワークに対するアーキテクチャを改善する以外に方法がないためです。とはいえ、system_server の内部で実行されるコードを変更する場合は、ロック(特に ActivityManagerService ロック)を長時間保持しないようにすることが重要です。

バインダーのロック競合

かつてバインダーには単一のグローバル ロックがありました。バインダー トランザクションを実行するスレッドがロックを保持している間にプリエンプトされた場合、元のスレッドがロックを解放するまで他のスレッドはバインダー トランザクションを実行できません。これは良好な状態ではなく、バインダー競合によりシステム内のすべての処理がブロックされる可能性があります。たとえば、UI の更新をディスプレイに送信する処理に影響します(UI スレッドはバインダーを介して SurfaceFlinger と通信します)。

Android 6.0 には、バインダー ロックを保持しながらプリエンプションを無効にすることでこの動作を改善するパッチがいくつか用意されていました。この方法が安全であるという理由は、バインダー ロックを保持する必要があるのは実際のランタイム(数マイクロ秒)の間だけであるということのみです。これにより、競合のない状況ではパフォーマンスが劇的に向上し、またバインダー ロックが保持されている間は、ほとんどのスケジューラの切り替えを回避して競合を防ぐことができました。ただし、バインダー ロックを保持するランタイム全体でプリエンプションを無効にすることはできませんでした。つまり、スリープが可能な関数(copy_from_user など)ではプリエンプションが有効化され、元のケースと同じプリエンプションが発生する可能性がありました。私たちがパッチをアップストリームに送付したとき、これは史上最悪のアイデアだという返答が直ちに返ってきました(まったくその通りだとは思いましたが、ジャンクを防ぐうえでパッチが有効であることも確かでした)。

プロセス内の fd 競合

このケースはまれです。ほとんどの場合、これはジャンクの原因ではありません。

ただし、同じ fd を書き込むプロセス内に複数のスレッドがある場合、その fd で競合が発生する可能性はあります。もっとも、Pixel のリリース準備中にこの現象が確認されたのは、テストの際、優先度の高い単一スレッドが実行されている間に、同じプロセス内で優先度の低いスレッドがすべての CPU 時間を占有しようとしたときだけでした。優先度の低いスレッドが fd ロックを保持していた後でプリエンプトされた場合、すべてのスレッドがトレース マーカー fd に書き込みを行い、優先度の高いスレッドがそのトレース マーカー fd でブロックされることがありました。優先度の低いスレッドでトレースを無効化すると、パフォーマンスの問題は発生しませんでした。

この現象は他の状況では再現できませんでしたが、トレース中にパフォーマンスの問題が発生する潜在的な原因として注意する必要はあると思われます。

不必要な CPU アイドル状態への移行

IPC を扱う場合(特にマルチプロセス パイプラインを扱う場合)、次のランタイム動作のバリエーションがよく見られます。

  1. CPU 1 でスレッド A が実行されます。
  2. スレッド A がスレッド B を起動します。
  3. CPU 2 でスレッド B の実行が開始されます。
  4. スレッド A は直ちにスリープ状態に移行し、スレッド B が現在の処理を終了したとき、スレッド B によってスリープを解除されます。

オーバーヘッドの一般的な原因はステップ 2 と 3 の間にあります。CPU 2 がアイドル状態の場合、スレッド B を実行する前に CPU 2 をアクティブ状態に戻す必要があります。SOC とアイドル状態の深さによっては、スレッド B の実行が開始されるまでに数十マイクロ秒かかることがあります。IPC の送信サイドと受信サイドの実際のランタイムがオーバーヘッドに十分近い場合、CPU アイドル状態への移行により、そのパイプラインの全体的なパフォーマンスが大幅に低下する可能性があります。Android がこの現象に遭遇する最も一般的な場所は、バインダー トランザクションの周辺であり、バインダーを使用する多くのサービスで上記の状況が見られます。

まず、カーネル ドライバで wake_up_interruptible_sync() 関数を使用し、任意のカスタム スケジューラでこれをサポートしてください。これはヒントではなく要件と見なしてください。バインダーでは現在この方法が使用されており、同期バインダー トランザクションで不必要な CPU アイドル状態への移行を回避するのに役立っています。

次に、cpuidle の移行時間が現実的で、cpuidle ガバナーでそれらが適切に考慮されていることを確認してください。SOC がアクティブ状態と最も深いアイドル状態の間を遷移する場合、最も深いアイドル状態に移行しても電力は節約できません。

ログ

CPU サイクルやメモリのロギングは無料ではないため、ログバッファをむやみに使用しないでください。 ロギングの費用は、アプリ内(直接的)およびログデーモン内のサイクルで計算されます。 デバイスを出荷する前に、デバッグログを削除してください。

I/O の問題

I/O オペレーションはジッターの一般的な原因です。スレッドがメモリマップ ファイルにアクセスする場合、ページがページ キャッシュにないと、スレッドはフォールトしてディスクからページを読み取ります。これによりスレッドがブロックされますが(通常 10 ミリ秒以上)、ブロックが UI レンダリングのクリティカル パスで起こった場合、ジャンクが発生することがあります。I/O オペレーションが原因となるケースは多すぎてここでは説明しきれませんが、I/O の動作の改善を試みる際は次の点を確認してください。

  • PinnerService。Android 7.0 で追加された PinnerService は、フレームワークがページ キャッシュ内の一部のファイルをロックできるようにします。これにより他のプロセスで使用するメモリを削除できますが、定期的に使用されることがあらかじめわかっているファイルが存在する場合は、そうしたファイルを mlock するうえでも効果的です。

    Android 7.0 を実行する Pixel デバイスと Nexus 6P デバイスでは、次の 4 つのファイルが mlock されました。
    • /system/framework/arm64/boot-framework.oat
    • /system/framework/oat/arm64/services.odex
    • /system/framework/arm64/boot.oat
    • /system/framework/arm64/boot-core-libart.oat
    上記のファイルは常に大部分のアプリと system_server で使用されるため、ページアウトされないようにする必要があります。具体的には、これらのファイルのいずれかがページアウトされた場合、非常に大規模なアプリからの切り替え時に再度ページインされてジャンクを引き起こすことがわかりました。
  • 暗号化。これもまた、I/O 問題の原因となる可能性があります。インライン暗号化を使用すると、CPU ベースの暗号化や、DMA 経由でアクセス可能なハードウェア ブロックを使用する方法と比較して、より優れたパフォーマンスが得られることがわかっています。最も重要なのは、インライン暗号化は、特に CPU ベースの暗号化と比べて、I/O に伴うジッターを削減する効果が高いことです。ページ キャッシュに対するフェッチは、多くの場合 UI レンダリングのクリティカル パスに存在します。CPU ベースの暗号化はクリティカル パスの CPU 負荷を増大させ、単純な I/O フェッチよりも多くのジッターを発生させます。

    DMA ベースのハードウェア暗号化エンジンにも同様の問題があります。カーネルは、他の重要な作業が実行可能であっても、暗号化処理の管理にサイクルを消費しなければならないからです。新しいハードウェアを構築する SOC ベンダーには、インライン暗号化のサポートを組み込むことを強くおすすめします。

小規模タスクのパッキングの多用

一部のスケジューラは、CPU のアイドル時間を長くして消費電力を削減する目的で、単一の CPU コアに小規模なタスクをパッキングする機能をサポートしています。これはスループットの向上と消費電力の抑制には役立ちますが、レイテンシには最悪の影響を及ぼす可能性があります。UI レンダリングのクリティカル パスには、短時間実行されるスレッドがいくつかあります。その種のスレッドは小規模なタスクと見なされる可能性があり、他の CPU への低速の移動によって遅延すると、確実にジャンクを引き起こします。小規模タスクのパッキングの使用はなるべく控えることをおすすめします。

ページ キャッシュのスラッシング

十分な空きメモリがないデバイスは、長時間のオペレーション(新しいアプリを開くなど)の実行中に、突然極端に遅くなることがあります。アプリのトレースを調べると、I/O でアプリが頻繁にブロックされていなくても、特定の実行中に I/O で一貫してブロックされているケースが見つかることがあります。一般的に、これはページ キャッシュのスラッシングの兆候です。この現象は特にメモリの少ないデバイスで発生します。

この問題を特定する方法の 1 つは、pagecache タグを使用して systrace を記録し、そのトレースを system/extras/pagecache/pagecache.py のスクリプトに渡すことです。pagecache.py は、ページ キャッシュに対するマップファイルへの個々のリクエストを、ファイルごとの統計の集計に変換します。1 つのファイルのバイトがディスク上のファイルの合計バイト数よりも多く読み込まれていたら、ページ キャッシュのスラッシングが発生したことが明確にわかります。

これは、ワークロードが必要とするワーキング セット(一般的には単一のアプリと system_server)が、デバイスのページ キャッシュで使用できるメモリ量より大きいことを意味します。その結果、ワークロードの一部がページ キャッシュ内の必要なデータを取得すると、近い将来使用される別の部分がキャッシュから追いやられ、後で再度フェッチする必要が生じます。これにより、読み込みが完了するまでに問題が再び発生します。これは、デバイスで十分なメモリが利用できない場合のパフォーマンスに関する問題の根本的な原因です。

ページ キャッシュのスラッシングを解決する絶対確実な方法はありませんが、特定のデバイスで改善を図るための方法がいくつかあります。

  • 永続プロセスで使用するメモリの量を減らします。永続プロセスによって使用されるメモリが少ないほど、アプリとページ キャッシュで使用できるメモリが増えます。
  • デバイスのカーブアウトを監査して、OS からメモリを不必要に削除していないことを確認します。デバッグに使用されるカーブアウトが出荷時のカーネル設定に誤って残され、数十メガバイトのメモリが消費されるケースがこれまでに確認されています。とりわけメモリの少ないデバイスでは、こうした問題がページ キャッシュのスラッシングが起こるか起こらないかを左右する可能性があります。
  • system_server でのページ キャッシュのスラッシングが重要なファイルで発生する場合は、それらのファイルを固定するようにしてください。それによって他の場所ではメモリ負荷が高まりますが、スラッシングを回避するのに十分なだけ動作が修正される場合があります。
  • より多くのメモリを空き状態にしておくため、lowmemorykiller を再調整します。lowmemorykiller のしきい値は空きメモリの絶対量とページ キャッシュの両方に基づいているため、特定の oom_adj レベルでプロセスを終了するためのしきい値を増やすと、バックグラウンド アプリの終了と引き換えに動作が改善される可能性があります。
  • ZRAM を試しに使用します。Pixel は 4 GB ですが、ここでは Pixel で ZRAM を使用しています。めったに使用されないダーティページで役立つ可能性があるためです。