開発するアプリケーションの種類に関係なく、パフォーマンスを最適化し、読み込みが速く、スムーズなインタラクションを提供することは、ユーザー エクスペリエンスとアプリケーションの成功のために不可欠です。そのための 1 つの方法は、プロファイリング ツールを使用してアプリのアクティビティを検査し、一定の期間にわたって実行されたときに内部で何が起こっているかを確認することです。DevTools の [パフォーマンス] パネルは、ウェブ アプリケーションのパフォーマンスを分析して最適化するための優れたプロファイリング ツールです。アプリが Chrome で実行されている場合は、アプリケーションの実行中にブラウザが何を行っているかを詳細に把握できます。このアクティビティを理解することで、パフォーマンスを改善するために対処できるパターン、ボトルネック、パフォーマンスのホットスポットを特定できます。
次の例では、[パフォーマンス] パネルの使用方法について説明します。
プロファイリング シナリオの設定と再作成
Google は先日、[パフォーマンス] パネルのパフォーマンスを改善することを目標に掲げました。特に、大量のパフォーマンス データをより高速に読み込むことが目的でした。たとえば、長時間実行プロセスや複雑なプロセスをプロファイリングしたり、粒度の高いデータをキャプチャしたりする場合が該当します。そのためには、プロファイリング ツールを使用して、アプリケーションがどのようにそのパフォーマンスをなぜ実行したのかを把握する必要があります。
ご存じのとおり、DevTools 自体はウェブ アプリケーションです。そのため、[パフォーマンス] パネルを使用してプロファイリングできます。このパネル自体をプロファイリングするには、DevTools を開き、それに接続されている別の DevTools インスタンスを開きます。Google では、この設定は DevTools-on-DevTools と呼ばれています。
セットアップが完了したら、プロファイリングするシナリオを再作成して記録する必要があります。混乱を避けるため、元の DevTools ウィンドウを「最初の DevTools インスタンス」、最初のインスタンスを検証しているウィンドウを「2 番目の DevTools インスタンス」と呼びます。
2 つ目の DevTools インスタンスの [パフォーマンス] パネル(以下、パフォーマンス パネル)は、最初の DevTools インスタンスを監視してシナリオを再現し、プロファイルを読み込みます。
2 番目の DevTools インスタンスでライブ録画が開始され、2 番目のインスタンスでプロファイルがディスク上のファイルから読み込まれます。大規模な入力の処理パフォーマンスを正確にプロファイリングするために、大きなファイルが読み込まれます。両方のインスタンスの読み込みが完了すると、パフォーマンス プロファイリング データ(一般にトレースと呼ばれる)が、プロファイルを読み込んでいるパフォーマンス パネルの 2 番目の DevTools インスタンスに表示されます。
初期状態: 改善の機会を特定する
読み込みが完了すると、2 番目のパフォーマンス パネル インスタンスで、次のスクリーンショットを確認できました。メインスレッドのアクティビティに注目します。このアクティビティは、[Main] というラベルの付いたトラックの下に表示されます。フレームチャートには、5 つの大きなアクティビティ グループがあることがわかります。これらは、読み込みに最も時間がかかっているタスクで構成されています。これらのタスクの合計時間は約 10 秒でした。次のスクリーンショットでは、パフォーマンス パネルを使用して、これらのアクティビティ グループのそれぞれに焦点を当てて、確認できる内容を確認しています。
1 つ目のアクティビティ グループ: 不要な作業
最初のアクティビティ グループは、まだ実行されているが実際には必要のないレガシー コードであることが明らかになりました。基本的に、processThreadEvents
というラベルの付いた緑色のブロックの下にあるものはすべて無駄な作業でした。あっという間に勝ちました。関数呼び出しを削除することで、約 1.5 秒短縮できました。すばらしいですね!
2 つ目のアクティビティ グループ
2 つ目のアクティビティ グループでは、解決策は 1 つ目のグループほど簡単ではありませんでした。buildProfileCalls
に約 0.5 秒かかり、その作業は避けられません。
興味があったから、パフォーマンス パネルの [Memory] オプションを有効にしてさらに調査したところ、buildProfileCalls
アクティビティも大量のメモリを使用していることがわかりました。buildProfileCalls
が実行されたとき、青い折れ線グラフが急激に跳ね上がっています。これは、メモリリークの可能性を示しています。
この疑惑を調査するため、[Memory] パネル(DevTools の別のパネルで、[perf] パネルの [Memory] ドロワーとは異なります)を使用しました。[メモリ] パネルで、[割り当てサンプリング] プロファイリング タイプが選択され、CPU プロファイルを読み込む perf パネルのヒープ スナップショットが記録されました。
次のスクリーンショットは、収集されたヒープ スナップショットを示しています。
このヒープ スナップショットから、Set
クラスが大量のメモリを消費していることが判明しました。呼び出しポイントを確認した結果、大量に作成されたオブジェクトに Set
型のプロパティが不必要に割り当てられていることが判明しました。このコストが積み重なり、大量のメモリが消費され、大規模な入力でアプリがクラッシュすることがよくありました。
セットは、一意のアイテムを保存する場合に便利です。また、データセットの重複除去や効率的なルックアップなど、コンテンツの一意性を使用するオペレーションを提供します。ただし、保存されるデータはソースとは一意であることが保証されているため、これらの機能は必要ありませんでした。そのため、そもそもセットは必要ありませんでした。メモリ割り当てを改善するため、プロパティの型が Set
から単純な配列に変更されました。この変更を適用した後、別のヒープ スナップショットを取得し、メモリ割り当ての減少を確認しました。この変更によって速度が大幅に向上することはありませんでしたが、アプリケーションのクラッシュ頻度が低下するという副次的な効果がありました。
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 ミリ秒程度に短縮されました。
変換前:
変換後:
4 つ目のアクティビティ グループ: 重要でない処理とキャッシュデータを遅らせて重複処理を防ぐ
このウィンドウを拡大すると、ほぼ同じ関数呼び出しのブロックが 2 つあることがわかります。呼び出された関数の名前から、これらのブロックは(refreshTree
や buildChildren
のような名前で)ツリーを構築しているコードで構成されていると推測できます。実際、関連するコードは、パネルの下部ドロワーにツリービューを作成するコードです。興味深いことに、これらのツリービューは読み込み後すぐには表示されません。代わりに、ツリーを表示するには、ツリービュー(引き出しの [ボトムアップ]、[呼び出しツリー]、[イベントログ] タブ)を選択する必要があります。さらに、スクリーンショットからわかるように、ツリー構築プロセスが 2 回実行されています。
この写真には 2 つの問題があります。
- 重要でないタスクが読み込み時間のパフォーマンスを妨げていた。ユーザーが常に出力を求めているわけではありません。そのため、このタスクはプロファイルの読み込みに不可欠ではありません。
- これらのタスクの結果はキャッシュに保存されませんでした。そのため、データに変化がないにもかかわらず、ツリーが 2 回計算されました。
まず、ユーザーが手動でツリービューを開いた時点までツリーの計算を延期しました。そのような場合にのみ、これらのツリーを作成するための費用を支払う価値があります。この処理を 2 回実行する合計時間は約 3.4 秒だったため、遅延させることで読み込み時間が大幅に短縮されました。この種のタスクのキャッシュ保存についても検討中です。
5 番目のアクティビティ グループ: 複雑な呼び出し階層はできるだけ避ける
このグループをよく見ると、特定の呼び出しチェーンが繰り返し呼び出されていることがわかります。同じパターンがフレーム チャートの異なる場所に 6 回出現しており、この時間枠の合計時間は約 2.4 秒でした。
複数回呼び出される関連コードは、「ミニマップ」(パネル上部のタイムライン アクティビティの概要)にレンダリングされるデータを処理する部分です。なぜ何度も起こっているのかはわかりませんが、6 回も起こらなければならないわけではありません。実際、他のプロファイルが読み込まれていない場合、コードの出力は最新の状態を維持する必要があります。理論上、コードは 1 回だけ実行されるはずです。
調査の結果、関連するコードが呼び出された原因は、読み込みパイプラインの複数の部分がミニマップを計算する関数を直接または間接的に呼び出していたためであることが判明しました。これは、プログラムの呼び出しグラフの複雑さが時間とともに進化し、このコードに対する依存関係が知らぬ間に追加されたためです。この問題の簡単な解決策はありません。解決方法は、対象のコードベースのアーキテクチャによって異なります。この場合、呼び出し階層の複雑さを少し減らし、入力データが変更されていない場合にコードの実行を防ぐチェックを追加する必要がありました。これを実装した後のタイムラインの見通しは次のとおりです。
ミニマップのレンダリングの実行は 1 回ではなく 2 回行われます。これは、プロファイルごとに 2 つのミニマップが描画されるためです。1 つはパネル上部の概要用で、もう 1 つは履歴から現在表示されているプロファイルを選択するプルダウン メニュー用です(このメニューのすべての項目には、選択したプロファイルの概要が含まれています)。ただし、これら 2 つはまったく同じコンテンツであるため、一方は他方で再利用できる必要があります。
これらのミニマップはどちらもキャンバスに描画される画像であるため、drawImage
キャンバス ユーティリティを使用して、コードを 1 回だけ実行することで、時間を節約できました。この取り組みの結果、グループの長さは 2.4 秒から 140 ミリ秒に短縮されました。
まとめ
これらの修正(およびその他の小さな修正)をすべて適用した後のプロフィールの読み込みタイムラインの変化は次のとおりです。
変換前:
変換後:
改善後の読み込み時間は 2 秒でした。つまり、行われた作業のほとんどが簡単な修正だったため、比較的少ない労力で約 80%の改善を達成できました。もちろん、最初に何をすべきかを正しく特定することが重要であり、パフォーマンス パネルはこれに適したツールでした。
また、これらの数値は、調査の対象として使用されているプロファイルに固有のものであることも重要です。このプロファイルは特にサイズが大きいため、Google の関心を引きました。ただし、処理パイプラインはすべてのプロファイルで同じであるため、大幅な改善は、パフォーマンス パネルに読み込まれたすべてのプロファイルに適用されます。
要点
アプリケーションのパフォーマンスの最適化という観点から、これらの結果から得られる教訓は次のとおりです。
1. プロファイリング ツールを使用してランタイム パフォーマンス パターンを特定する
プロファイリング ツールは、実行中のアプリケーションで何が起こっているかを把握するのに非常に役立ちます。特に、パフォーマンスを改善する余地を特定する場合に便利です。Chrome DevTools の [Performance] パネルは、ブラウザのネイティブなウェブ プロファイリング ツールであり、最新のウェブ プラットフォーム機能が常に最新の状態に維持されるため、ウェブ アプリケーションに最適なオプションです。また、処理速度も大幅に向上しました。😉
代表的なワークロードとして使用できるサンプルを使用して、何が見つかるでしょうか。
2. 複雑な呼び出し階層を避ける
コールグラフを複雑にしすぎないようにします。呼び出し階層が複雑になると、パフォーマンスの低下が簡単に発生し、コードがそのように実行されている理由を把握しにくくなり、改善を適用しにくくなります。
3. 不要な作業を特定する
古くなったコードベースには、不要になったコードが含まれていることがよくあります。弊社の場合、読み込み時間の大部分を占めていたのは、不要なレガシー コードでした。削除は最も簡単に実行できる対策でした。
4. データ構造を適切に使用する
データ構造を使用してパフォーマンスを最適化しますが、どのデータ構造を使用するかを決定する際には、各タイプのデータ構造に伴う費用とトレードオフを理解してください。これは、データ構造自体のスペースの複雑さだけでなく、該当するオペレーションの時間の複雑さでもあります。
5. 結果をキャッシュに保存して、複雑なオペレーションや反復的なオペレーションの重複を回避する
オペレーションの実行にコストがかかる場合は、次回必要になるときに使用できるように結果を保存することをおすすめします。また、オペレーションが複数回実行される場合(1 回あたりのコストが特に高くない場合でも)にも、この方法が適しています。
6. 重要性の低い作業を延期する
タスクの出力がすぐに必要なく、タスクの実行によってクリティカル パスが拡張される場合は、出力が実際に必要になったときに呼び出しを遅延させて遅延させることを検討してください。
7. 大規模な入力に効率的なアルゴリズムを使用する
大量の入力の場合、最適な時間複雑さアルゴリズムが重要になります。この例ではこのカテゴリについては検討しませんでしたが、その重要性は過大評価されることはありません。
8. ボーナス: パイプラインのベンチマーク
進化するコードの速度を維持するには、動作をモニタリングして標準と比較することをおすすめします。このようにして、先を見越して回帰を特定し、全体的な信頼性を向上させ、長期的な成功に向けた基盤を整えます。