開発するアプリケーションの種類に関係なく、パフォーマンスを最適化し、読み込みが速く、スムーズなインタラクションを提供することは、ユーザー エクスペリエンスとアプリケーションの成功のために不可欠です。そのための 1 つの方法は、プロファイリング ツールを使用してアプリのアクティビティを検査し、一定の期間にわたって実行されたときに内部で何が起こっているかを確認することです。DevTools の [Performance] パネルは、ウェブ アプリケーションのパフォーマンスを分析して最適化するための優れたプロファイリング ツールです。アプリが Chrome で実行されている場合は、アプリケーションの実行中にブラウザが何を行っているかを詳細に把握できます。このアクティビティを理解することで、パターン、ボトルネック、パフォーマンスのホットスポットを特定し、パフォーマンスを改善できます。
次の例では、[パフォーマンス] パネルの使用方法について説明します。
プロファイリング シナリオの設定と再作成
Google は先日、[パフォーマンス] パネルのパフォーマンスを改善することを目標に掲げました。特に、大量のパフォーマンス データをより迅速に読み込みたいと考えました。たとえば、長時間実行または複雑なプロセスのプロファイリングや、きめ細かいデータをキャプチャする場合などです。そのためには、プロファイリング ツールを使用して、アプリケーションがどのようにそのパフォーマンスをなぜ実行したのかを把握する必要があります。
ご存知かもしれませんが、DevTools 自体はウェブ アプリケーションです。そのため、[Performance] パネルを使用してプロファイリングできます。このパネル自体をプロファイリングするには、DevTools を開き、それに接続されている別の DevTools インスタンスを開きます。Google では、この設定を DevTools-on-DevTools と呼んでいます。
セットアップの準備ができたら、プロファイリングするシナリオを再作成して記録する必要があります。混乱を避けるため、元の DevTools ウィンドウを「最初の DevTools インスタンス」、最初のインスタンスを検証しているウィンドウを「2 番目の DevTools インスタンス」と呼びます。
2 つ目の DevTools インスタンスの [パフォーマンス] パネル(以下、パフォーマンス パネル)は、最初の DevTools インスタンスを監視してシナリオを再現し、プロファイルを読み込みます。
2 番目の DevTools インスタンスではライブ録画が開始され、1 番目のインスタンスではディスク上のファイルからプロファイルが読み込まれます。大規模な入力の処理パフォーマンスを正確にプロファイリングするために、大きなファイルが読み込まれます。両方のインスタンスの読み込みが完了すると、パフォーマンス プロファイリング データ(一般にトレースと呼ばれる)が、プロファイルを読み込んでいるパフォーマンス パネルの 2 番目の DevTools インスタンスに表示されます。
初期状態: 改善の機会を特定する
読み込みが完了すると、2 番目のパフォーマンス パネル インスタンスで、次のスクリーンショットを確認できました。[Main] というトラックの下にあるメインスレッドのアクティビティに注目します。炎グラフには、5 つの大きなアクティビティ グループがあることがわかります。これらは、読み込みに最も時間がかかっているタスクで構成されています。これらのタスクの合計時間は約 10 秒でした。次のスクリーンショットでは、パフォーマンス パネルで各アクティビティ グループにフォーカスが当てられており、表示される内容が表示されています。
最初のアクティビティ グループ: 不要な作業
最初のアクティビティ グループは、まだ実行されているが実際には必要のないレガシー コードであることが明らかになりました。基本的に、processThreadEvents
というラベルの付いた緑色のブロックの下にあるものはすべて無駄な作業でした。すぐに解決しました。この関数呼び出しを削除することで、約 1.5 秒の時間が節約されました。すばらしいですね!
2 つ目のアクティビティ グループ
2 つ目のアクティビティ グループでは、解決策が 1 つ目のアクティビティほど簡単ではありませんでした。buildProfileCalls
は約 0.5 秒かかり、そのタスクは回避できませんでした。
興味が湧いたため、パフォーマンス パネルで [メモリ] オプションを有効にして詳しく調査したところ、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
などの名前)で構成されていることが推測できます。実際、関連するコードは、パネルの下部ドロワーにツリービューを作成するコードです。興味深いのは、これらのツリービューは読み込み直後に表示されないことです。代わりに、ユーザーはツリービュー(ドロワーの [Bottom-up]、[Call Tree]、[Event Log] の各タブ)を選択して、ツリーを表示する必要があります。さらに、スクリーンショットからわかるように、ツリー構築プロセスが 2 回実行されています。
この画像には 2 つの問題があります。
- 重要でないタスクが読み込み時間のパフォーマンスを妨げていた。ユーザーが常に出力を求めているわけではありません。そのため、このタスクはプロファイルの読み込みに不可欠ではありません。
- これらのタスクの結果はキャッシュに保存されませんでした。そのため、データが変更されていないにもかかわらず、ツリーが 2 回計算されました。
まず、ユーザーがツリービューを手動で開いたときにツリーの計算を遅らせました。そうして初めて、このような樹木を作り出す代償を払う価値があるのです。この処理を 2 回実行する合計時間は約 3.4 秒だったため、遅延させることで読み込み時間が大幅に短縮されました。Google では、このようなタスクのキャッシュ化についても現在調査中です。
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. 結果をキャッシュに保存して、複雑な操作や反復的な操作で作業の重複を避ける
オペレーションの実行にコストがかかる場合は、次回必要になるときに使用できるように結果を保存することをおすすめします。また、毎回のコストが特に高くなくても、オペレーションが何度も実行される場合にも、この処理は理にかなっています。
6. 重要でない処理を遅らせる
タスクの出力がすぐに必要なく、タスクの実行によってクリティカル パスが拡張される場合は、出力が実際に必要になったときに呼び出しを遅延させて遅延させることを検討してください。
7. 大量の入力に対して効率的なアルゴリズムを使用する
入力が大きい場合、最適な時間複雑度のアルゴリズムが重要になります。この例ではこのカテゴリについては検討しませんでしたが、その重要性は過大評価されることはありません。
8. ボーナス: パイプラインのベンチマーク
進化するコードの速度を維持するには、動作をモニタリングして標準と比較することをおすすめします。これにより、リグレッションを事前に特定し、全体的な信頼性を向上させ、長期的な成功を収めることができます。