パフォーマンスに関するパネル表示でパフォーマンス パネルを 400% 高速化

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

開発するアプリケーションの種類にかかわらず、ユーザー エクスペリエンスとアプリケーションの成功には、パフォーマンスを最適化し、読み込みが速く、スムーズなインタラクションを提供することが不可欠です。そのための 1 つの方法は、プロファイリング ツールを使用してアプリケーションのアクティビティを検査し、特定の時間枠内で実行中の内部で何が起きているのかを確認することです。DevTools の [パフォーマンス] パネルは、ウェブ アプリケーションのパフォーマンスを分析して最適化するための優れたプロファイリング ツールです。Chrome でアプリを実行している場合は、アプリケーションの実行中にブラウザが何を行っているかの概要が視覚的に表示されます。このアクティビティを理解することで、パターン、ボトルネック、パフォーマンスのホットスポットを特定し、パフォーマンスを改善できます。

次の例では、[Performance] パネルの使い方を説明します。

プロファイリング シナリオのセットアップと再作成

先日、Google は [パフォーマンス] パネルのパフォーマンスを高めることを目標に設定しました。特に、大量のパフォーマンス データをより迅速に読み込むことが目的でした。これは、たとえば、長時間実行または複雑なプロセスをプロファイリングする場合や、粒度の高いデータをキャプチャする場合に該当します。これを実現するには、まずアプリケーションがどのように動作しているか、なぜそのように動作しているかを理解する必要があり、これはプロファイリング ツールを使用して実現しました。

ご存じのとおり、DevTools 自体はウェブ アプリケーションです。そのため、[パフォーマンス] パネルを使用してプロファイリングできます。このパネル自体をプロファイリングするには、DevTools を開いてから、そのパネルに接続されている別の DevTools インスタンスを開きます。Google では、この設定を DevTools-on-DevTools と呼んでいます。

設定の準備ができたら、プロファイリングするシナリオを再作成して記録する必要があります。混乱を避けるために、元の DevTools ウィンドウを「最初の DevTools インスタンス」、最初のインスタンスを検査しているウィンドウを「2 番目の DevTools インスタンス」と呼びます。

DevTools 自体の要素を検査している DevTools インスタンスのスクリーンショット。
DevTools-on-DevTools: DevTools による DevTools の検査

2 番目の DevTools インスタンスで、[パフォーマンス] パネル(以降はパフォーマンス パネルと呼びます)で最初の DevTools インスタンスが観察され、プロファイルが読み込まれます。

2 つ目の DevTools インスタンスでライブ録画が開始され、1 つ目のインスタンスで、ディスク上のファイルからプロファイルが読み込まれます。大量の入力の処理のパフォーマンスを正確にプロファイリングするために、大きなファイルが読み込まれます。両方のインスタンスの読み込みが完了すると、一般的に「トレース」traceと呼ばれるパフォーマンス プロファイリング データが、プロファイルを読み込むパフォーマンス パネルの 2 番目の DevTools インスタンスに表示されます。

初期状態: 改善の機会の特定

読み込み完了後、次のスクリーンショットで、2 番目のパフォーマンスパネル インスタンスの状況が次のように観察されました。[Main] というラベルのトラックの下にあるメインスレッドのアクティビティにフォーカスします。フレーム チャートには、5 つの大きなアクティビティ グループがあることがわかります。これには、読み込みに最も時間がかかるタスクが含まれます。これらのタスクの合計時間は約 10 秒でした。次のスクリーンショットでは、パフォーマンス パネルを使用して、これらの各アクティビティ グループに注目し、何が確認できるかを確認しています。

別の DevTools インスタンスのパフォーマンス パネルでパフォーマンス トレースの読み込みを調べている、DevTools のパフォーマンス パネルのスクリーンショット。プロファイルの読み込みには 10 秒ほどかかります。この時間は、主に 5 つのアクティビティ グループに分かれています。

最初のアクティビティ グループ: 不要な作業

最初の一連の活動は、まだ実行されているものの、実際には必要のないレガシー・コードであったことが明らかになりました。基本的に、processThreadEvents というラベルの付いた緑色のブロックの下の部分はすべて無駄な作業です。あれはあっという間に成果をあげた。この関数呼び出しを削除することで、約 1.5 秒の時間を節約できました。すばらしいですね!

2 つ目のアクティビティ グループ

2 つ目のアクティビティ グループでは、解決策が 1 つ目のグループほど単純ではありませんでした。buildProfileCalls には約 0.5 秒かかり、このタスクは回避できないものでした。

別のパフォーマンス パネルのインスタンスを検査している DevTools のパフォーマンス パネルのスクリーンショット。buildProfileCalls 関数に関連付けられたタスクには約 0.5 秒かかります。

残念ながら、perf パネルで [Memory] オプションを有効にしてさらに調査したところ、buildProfileCalls アクティビティも大量のメモリを使用していることがわかりました。ここでは、buildProfileCalls の実行時に青い折れ線グラフが急激に上昇しており、メモリリークの可能性を示唆しています。

パフォーマンス パネルのメモリ消費を評価している DevTools の Memory Profiler のスクリーンショット。インスペクタは、buildProfileCalls 関数がメモリリークの原因であることを示唆しています。

この疑わしい点についてフォローアップするために、[Memory] パネル(DevTools の別のパネル。[perf] パネルの [Memory] ドロワーとは異なる)を使用して調査しました。[Memory] パネルでプロファイリング タイプ [Allocation sampling] が選択されており、CPU プロファイルを読み込むパフォーマンス パネルのヒープ スナップショットが記録されています。

Memory Profiler の初期状態のスクリーンショット。赤いボックスでハイライト表示されている [allocation sampling] オプションは、このオプションが JavaScript メモリ プロファイリングに最適なことを示します。

次のスクリーンショットは、収集されたヒープ スナップショットを示しています。

メモリ使用量の多い Set ベースのオペレーションが選択されている Memory Profiler のスクリーンショット。

このヒープ スナップショットから、Set クラスが大量のメモリを消費していることが判明しました。呼び出しポイントを確認したところ、大量に作成されたオブジェクトに Set 型のプロパティを不必要に割り当てていることがわかりました。このコストは増え、大量のメモリが消費され、大量の入力でアプリケーションがクラッシュすることが一般的になっていました。

セットは一意のアイテムを格納するのに便利です。また、データセットの重複除去やルックアップの効率化など、コンテンツの一意性を使用したオペレーションも可能です。ただし、保存されたデータはソースから一意であることが保証されていたため、これらの機能は必要ありませんでした。そのため、そもそもセットは必要ではありませんでした。メモリ割り当てを改善するため、プロパティの型を Set から書式なし配列に変更しました。この変更を適用した後で、別のヒープ スナップショットが取得され、メモリ割り当ての減少が観察されました。この変更によって速度の大幅な改善は達成されていないものの、2 つ目のメリットは、アプリケーションがクラッシュする頻度が少なくなったことです。

Memory Profiler のスクリーンショット。これまでメモリ使用量の多いセットベースのオペレーションをプレーン配列を使用するように変更し、メモリコストを大幅に削減しました。

3 つ目のアクティビティ グループ: データ構造のトレードオフの比較検討

3 番目のセクションは独特です。フレーム チャートを見ると、幅が細い縦長の列で構成されていることがわかります。これは深い関数呼び出しを示し、この場合は深い再帰を示します。このセクションは合計で約 1.4 秒でした。このセクションの下部を見ると、これらの列の幅が 1 つの関数 appendEventAtLevel の継続時間によって決まっていることがわかります。これは、ボトルネックの可能性があることを示唆しています。

appendEventAtLevel 関数の実装内で注目すべき点が 1 つあります。入力内の個々のデータエントリ(コードで「イベント」と呼ばれる)ごとに、タイムライン エントリの垂直方向の位置を追跡するアイテムがマップに追加されました。これには、保存されているアイテムの量が非常に多いため、問題がありました。マップはキーベースのルックアップで高速ですが、この利点は無償ではありません。たとえば、地図が大きくなると、たとえば再ハッシュなどによってデータの追加が高コストになる可能性があります。このコストは、大量のアイテムを地図に連続して追加する場合に顕著になります。

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

フレーム チャートのエントリごとにマップにアイテムを追加する必要がない別のアプローチも試しました。大幅な改善により、マップにすべてのデータを追加することによって発生したオーバーヘッドがボトルネックに関連していることを確認しました。アクティビティ グループが約 1.4 秒から約 200 ミリ秒に縮小した時間。

変更前:

addEventAtLevel 関数に最適化が行われる前のパフォーマンス パネルのスクリーンショット。この関数の合計実行時間は 1,372.51 ミリ秒でした。

変更後:

applyEventAtLevel 関数を最適化した後のパフォーマンス パネルのスクリーンショット。関数の実行にかかった合計時間は 207.2 ミリ秒でした。

4 番目のアクティビティ グループ: 重要性の低い作業を延期し、データをキャッシュに保存して作業の重複を防ぐ

このウィンドウを拡大すると、ほぼ同一の関数呼び出しブロックが 2 つあることがわかります。呼び出された関数の名前を見ると、これらのブロックが、refreshTreebuildChildren といった名前の構成要素であるコードで構成されていると推測できます。実際、関連するコードは、パネルの下部ドロワーにツリービューを作成するコードです。興味深いのは、こうしたツリービューは読み込み直後には表示されないことです。ツリーを表示するには、代わりにツリービュー(ドロワーの [Bottom-up]、[Call Tree]、[Event Log] の各タブ)を選択する必要があります。さらに、スクリーンショットからわかるように、ツリー構築プロセスは 2 回実行されています。

不要であっても実行される反復的なタスクがいくつか示されているパフォーマンス パネルのスクリーンショット。これらのタスクは、事前にではなく、オンデマンドで実行するために延期できます。

この図には 2 つの問題があります。

  1. 重要性の低いタスクが読み込み時間のパフォーマンスの妨げになっていました。ユーザーがその出力を常に必要とするとは限りません。そのため、このタスクはプロファイルの読み込みには重要ではありません。
  2. これらのタスクの結果はキャッシュに保存されませんでした。データが変化していないにもかかわらず、ツリーが 2 回計算されたのはそのためです。

まず、ユーザーがツリービューを手動で開いたときまで、ツリーの計算を延期しました。そうして初めて、木を育てる代償を払う価値があります。この 2 回実行した合計時間は約 3.4 秒だったので、遅延させることで読み込み時間に大きな違いが生じました。この種のタスクのキャッシュ保存についても引き続き検討中です。

5 つ目のアクティビティ グループ: 可能であれば、呼び出しの複雑な階層を避ける

このグループをよく見ると、特定の呼び出しチェーンが繰り返し呼び出されていることが明らかでした。同じパターンがフレーム チャートの異なる場所に 6 回表示され、このウィンドウの合計時間は約 2.4 秒でした。

同じトレース ミニマップを生成する 6 つの個別の関数呼び出しを示すパフォーマンス パネルのスクリーンショット。それぞれの関数呼び出しには深いコールスタックがあります。

複数回呼び出される関連コードは、「ミニマップ」(パネルの上部にあるタイムライン アクティビティの概要)にレンダリングされるデータを処理する部分です。この事象が複数回発生した理由は不明でしたが、6 回は必要ではありませんでした。実際、他のプロファイルが読み込まれていない場合、コードの出力は最新のままです。理論上は、コードは 1 回だけ実行する必要があります。

調査の結果、ミニマップを計算する関数を直接的または間接的に呼び出したパイプラインの複数の部分の結果として、関連コードが呼び出されていることがわかりました。これは、プログラムのコールグラフの複雑さが時間の経過とともに進化し、このコードへの依存関係が知らないうちに追加されたためです。この問題の迅速な修正はありません。解決方法は、対象のコードベースのアーキテクチャによって異なります。今回のケースでは、呼び出し階層の複雑さを少し軽減し、入力データが変更されない場合にコードが実行されないようにチェックを追加する必要がありました。これを実装した後、タイムラインは以下のようになります。

パフォーマンス パネルのスクリーンショット。同じトレース ミニマップを生成する 6 つの個別の関数呼び出しが、2 回に減らされています。

ミニマップのレンダリングは 1 回ではなく 2 回行われます。これは、プロファイルごとに 2 つのミニマップが描画されるためです。1 つはパネルの上部の概要に、もう 1 つは履歴から現在表示されているプロファイルを選択するプルダウン メニューです(このメニューのすべてのアイテムには、選択したプロファイルの概要が含まれます)。とはいえ、これら 2 つはまったく同じ内容であるため、一方を他方で再利用できるはずです。

これらのミニマップはどちらもキャンバスに描画される画像であるため、drawImage キャンバス ユーティリティを使用してコードを 1 回実行するだけで、時間の節約になります。この取り組みの結果、グループの滞在時間は 2.4 秒から 140 ミリ秒に短縮されました。

まとめ

これらすべての修正(およびあちこちに他のいくつかの小さな修正)を適用した後、プロファイル読み込みタイムラインの変更は次のようになります。

変更前:

最適化前のトレースの読み込みを示すパフォーマンス パネルのスクリーンショット。所要時間は約 10 秒です。

変更後:

最適化後のトレース読み込みを示すパフォーマンス パネルのスクリーンショット。このプロセスには約 2 秒かかります。

改善後の読み込み時間は 2 秒でした。つまり、ほとんどの作業が迅速な修正で構成されていたため、比較的少ない労力で約 80%の改善を達成できました。もちろん、当初は何を行うべきかを適切に特定することが鍵でした。そのためには、[パフォーマンス] パネルが適したツールです。

これらの数値は、研究テーマとして使用されるプロファイルに固有であることも強調してください。このプロフィールは、特に規模が大きかったことから興味深いものでした。とはいえ、すべてのプロファイルで処理パイプラインが同じであるため、パフォーマンスパネルに読み込まれたすべてのプロファイルに大幅な改善が見られます。

要点

この結果から、アプリケーションのパフォーマンスの最適化に関する教訓をいくつか紹介します。

1. プロファイリング ツールを使用して実行時のパフォーマンス パターンを特定する

プロファイリング ツールは、実行中のアプリケーションの状況を把握するうえで非常に役立ちます。特に、パフォーマンス改善の機会を特定するのに役立ちます。Chrome DevTools の [パフォーマンス] パネルは、ブラウザのネイティブのウェブ プロファイリング ツールであり、最新のウェブ プラットフォーム機能に合わせて常に最新の状態に保たれているため、ウェブ アプリケーションに最適です。また、処理速度も大幅に向上しました。😉

代表的なワークロードとして使用できるサンプルを使用して、何が見つかるか見てみましょう。

2. 複雑な呼び出し階層を避ける

可能な限り、コールグラフを複雑にしすぎないようにします。呼び出しの階層が複雑な場合、パフォーマンスの低下を引き起こしたり、コードがこのように動作している理由の理解が難しくなったりするため、改善が困難になります。

3. 不要な作業を特定する

古くなったコードベースには、不要になったコードが含まれていることがよくあります。今回のケースでは、古いコードと不要なコードが合計読み込み時間の大部分を占めていました。これを取り除くことは最も容易な成果でした。

4. データ構造を適切に使用する

データ構造を使用してパフォーマンスを最適化するだけでなく、使用するデータ構造を決定する際には、それぞれのデータ構造のコストとトレードオフも理解してください。これは、データ構造自体のスペースの複雑さだけでなく、該当するオペレーションの時間の複雑さでもあります。

5. 結果をキャッシュに保存して、複雑なオペレーションや反復的なオペレーションで重複する作業を回避する

オペレーションの実行にコストがかかる場合は、次回必要になったときのために結果を保存することをおすすめします。また、オペレーションを何度も実行する場合は、毎回の負荷が特に高くなくても、この処理を行うのが合理的です。

6. 重要でない作業を延期する

タスクの出力が即座に必要ではなく、タスクの実行がクリティカル パスを拡張している場合は、その出力が実際に必要なときに遅延呼び出しを行って出力を遅らせることを検討してください。

7. 大量の入力で効率的なアルゴリズムを使用する

大量の入力では、最適な時間複雑性アルゴリズムが重要になります。このカテゴリについては、この例では取り上げませんでしたが、その重要性は計り知れません。

8. 参考: パイプラインのベンチマークを行う

進化するコードのスピードを維持するには、動作をモニタリングして標準と比較するのが賢明です。これにより、リグレッションをプロアクティブに特定し、全体的な信頼性を高め、長期的な成功を実現できます。