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

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

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

次の例では、[パフォーマンス] パネルを使用する方法を示します。

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

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

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

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

<ph type="x-smartling-placeholder">
</ph> DevTools 自体で要素を検査している DevTools インスタンスのスクリーンショット。 <ph type="x-smartling-placeholder">
</ph> DevTools-on-DevTools: DevTools での DevTools の検査。

2 つ目の DevTools インスタンスで、[Performance] パネル(以降は「perf パネル」と呼びます)は、最初の DevTools インスタンスを監視してシナリオを再作成し、プロファイルを読み込みます。

<ph type="x-smartling-placeholder">

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

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

読み込みが完了すると、2 番目のパフォーマンス パネル インスタンスで、次のスクリーンショットを確認できました。[Main] というトラックの下にあるメインスレッドのアクティビティに注目します。フレームチャートには、5 つの大きなアクティビティ グループがあることがわかります。これらは、読み込みに時間がかかっているタスクで構成されます。これらのタスクの合計時間は約 10 秒でした。次のスクリーンショットでは、パフォーマンス パネルで各アクティビティ グループにフォーカスが当てられており、表示される内容が表示されています。

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

1 つ目のアクティビティ グループ: 不要な作業

その結果、最初の活動グループはレガシー・コードで、今も動作しているものの、実際には必要ではないものであることが判明しました。基本的に、processThreadEvents という緑色のブロックの下にあるものはすべて、無駄な労力を費やしています。あっという間に勝ちました。関数呼び出しを削除することで、約 1.5 秒の時間を節約できました。すばらしいですね!

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

2 つ目のアクティビティ グループでは、解決策は 1 つ目のアクティビティほど簡単ではありませんでした。buildProfileCalls には約 0.5 秒かかり、その作業は避けられません。

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

興味があったから、パフォーマンス パネルの [Memory] オプションを有効にしてさらに調査したところ、buildProfileCalls アクティビティも大量のメモリを使用していることがわかりました。この例では、buildProfileCalls の実行中に青い折れ線グラフが突然急激に跳ね上がっています。これは、メモリリークの可能性を示しています。

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

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

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

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

<ph type="x-smartling-placeholder">
メモリ使用量の多い Set ベースのオペレーションが選択された Memory Profiler のスクリーンショット。

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

セットは一意のアイテムを保存するのに便利です。また、データセットの重複除去や効率的な検索など、そのコンテンツの一意性を利用するオペレーションを提供できます。ただし、保存されたデータはソースから一意であることが保証されていたため、これらの機能は必要ありませんでした。そのため、そもそもセットは必要ありませんでした。メモリ割り当てを改善するために、プロパティ型が Set からプレーン配列に変更されました。この変更を適用すると、別のヒープ スナップショットが取得され、メモリ割り当ての減少が確認されました。この変更によって速度は大幅に改善されませんでしたが、その二次的なメリットは、アプリケーションがクラッシュする頻度が減ったことでした。

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 ミリ秒に短縮されました。

変換前:

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

変換後:

AppendEventAtLevel 関数に対して最適化が行われた後のパフォーマンス パネルのスクリーンショット。関数の合計実行時間は 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 秒かかりました。

変換後:

<ph type="x-smartling-placeholder">
</ph> 最適化後のトレース読み込みを示すパフォーマンス パネルのスクリーンショット。この処理には約 2 秒かかります。
<ph type="x-smartling-placeholder">

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

また、これらの数字は調査対象として使用するプロフィールに特有のものであることを強調することが重要です。このプロフィールは特に規模が大きかったため、興味を引かれるものでした。それでも、すべてのプロファイルで処理パイプラインが同じであるため、パフォーマンスのパネルに読み込まれたすべてのプロファイルに大幅な改善が加えられます。

要点

アプリケーションのパフォーマンス最適化の観点から、この結果から学べる教訓は次のとおりです。

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

プロファイリング ツールは、アプリケーションの実行時に何が起こっているかを理解するのに非常に役立ちます。特に、パフォーマンスを向上させる機会を特定するのに非常に役立ちます。Chrome DevTools の [Performance] パネルは、ブラウザのネイティブなウェブ プロファイリング ツールであり、最新のウェブ プラットフォーム機能が常に最新の状態に維持されるため、ウェブ アプリケーションに最適なオプションです。また、速度も大幅に向上しました。😉

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

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

コールグラフを複雑にしすぎないようにします。呼び出し階層が複雑な場合、パフォーマンスの低下が発生しやすく、コードが現在のように実行されている理由を理解できず、改善が困難です。

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

古くなったコードベースには、不要になったコードが含まれていることがよくあります。この例では、全読み込み時間の大部分を以前のコードと不要なコードが占めていました。これを取り除くことは、最も難しい課題でした。

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

データ構造を使用してパフォーマンスを最適化しますが、使用するデータ構造を選択する際には、データ構造のタイプごとの費用とトレードオフを理解する必要があります。これは、データ構造自体のスペースの複雑さだけでなく、該当するオペレーションの時間の複雑さでもあります。

5. 結果をキャッシュに保存して、複雑な操作や反復的な操作で作業の重複を避ける

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

6. 重要性の低い作業を延期する

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

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

大量の入力の場合、最適な時間複雑さアルゴリズムが重要になります。この例ではこのカテゴリについては調べませんでしたが、その重要性は強調してもしすぎることはありません。

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

進化するコードを常に高速にするには、動作をモニタリングし、標準と比較することをおすすめします。このようにして、先を見越して回帰を特定し、全体的な信頼性を向上させ、長期的な成功に向けた基盤を整えます。