ウェブ デベロッパーは、コードのデバッグ時にパフォーマンスへの影響がほとんどないか、まったくないと想定しています。ただし、この期待は決して普遍的なものではありません。C++ デベロッパーは、アプリケーションのデバッグビルドが本番環境のパフォーマンスに達することを期待していません。Chrome の初期の頃は、DevTools を開くだけでページのパフォーマンスに大きな影響がありました。
このようなパフォーマンスの低下が感じられなくなったのは、DevTools と V8 のデバッグ機能に長年投資してきた結果です。ただし、DevTools のパフォーマンス オーバーヘッドをゼロにすることは決してできません。ブレークポイントの設定、コードのステップスルー、スタック トレース、パフォーマンス トレースのキャプチャなどは、すべて実行速度にさまざまな程度で影響します。結局のところ、何かを観察すると、それは変化するのです。
ただし、他のデバッガと同様に、DevTools のオーバーヘッドは妥当なものである必要があります。最近、特定のケースで DevTools によってアプリケーションの動作が遅くなり、使用できなくなるという報告が大幅に増加しています。以下は、レポート chromium:1069425 の並べ替え比較で、DevTools を単に開いているだけで発生するパフォーマンス オーバーヘッドを示しています。
動画からわかるように、速度低下は 5 ~ 10 倍 のレベルであり、これは明らかに許容できません。まず、時間のかかる処理と、DevTools を開いたときに発生する大幅な速度低下の原因を把握しました。Chrome レンダラ プロセスで Linux perf を使用すると、レンダラ全体の実行時間の分布が次のようになりました。
スタック トレース収集に関連する問題が起きることは想定していましたが、実行時間の合計の約 90% がスタックフレームの記号化に費やされているとは予想していませんでした。ここでのシンボリケーションとは、未加工のスタックフレームから関数名と具体的なソース位置(スクリプトの行番号と列番号)を解決する行為を指します。
メソッド名の推論
さらに驚くべきことに、ほとんどの時間が V8 の JSStackFrame::GetMethodName()
関数に費やされています。以前の調査では、JSStackFrame::GetMethodName()
がパフォーマンスの問題に関係していることはわかっていましたが、この関数は、メソッド呼び出しと見なされるフレーム(func()
ではなく obj.func()
形式の関数呼び出しを表すフレーム)のメソッド名を計算しようとします。コードをざっと見てみると、オブジェクトとそのプロトタイプ チェーンを完全に走査し、
value
がfunc
閉包であるデータ プロパティ。get
またはset
がfunc
クロージャに等しいアクセサ プロパティ。
単体で見ると特に安価な印象はありませんが、この恐ろしい速度低下を説明するものでもないように思えます。そこで、chromium:1069425 で報告された例を詳しく調べたところ、非同期タスクと、10 MiB の JavaScript ファイルである classes.js
から発信されたログ メッセージに対してスタック トレースが収集されていることがわかりました。詳しく調べてみると、これは基本的に Java ランタイムと JavaScript にコンパイルされたアプリケーション コードでした。スタック トレースには、オブジェクト A
でメソッドが呼び出されているフレームが複数含まれているため、どのような種類のオブジェクトを扱っているのかを把握する価値があると考えました。
Java から JavaScript へのコンパイラが、なんと 82,203 個の関数を含む単一のオブジェクトを生成したようです。これは明らかに興味深い結果です。次に、V8 の JSStackFrame::GetMethodName()
に戻り、簡単に改善できる点がないか確認しました。
- 最初に、関数の
"name"
をオブジェクトのプロパティとして検索し、見つかった場合は、プロパティ値が関数と一致することを確認します。 - 関数に名前がないか、オブジェクトに一致するプロパティがない場合は、オブジェクトとそのプロトタイプのすべてのプロパティを走査して、リバース ルックアップにフォールバックします。
この例では、すべての関数は匿名で、"name"
プロパティは空になっています。
A.SDV = function() {
// ...
};
最初の検出結果は、リバース ルックアップが 2 つのステップに分割されていることです(オブジェクト自体とプロトタイプ チェーン内の各オブジェクトに対して実行されます)。
- すべての列挙可能なプロパティの名前を抽出します。
- 各名前に対して汎用プロパティの検索を実行し、結果のプロパティ値が探している閉包と一致するかどうかをテストします。
名前を抽出するには、すでにすべてのプロパティを走査する必要があるため、これは比較的簡単な作業のように見えました。2 つのパス(名前の抽出が O(N)、テストが O(N log(N)))を実行する代わりに、1 つのパスですべてを実行し、プロパティ値を直接確認できます。これにより、関数全体の実行時間が 2 ~ 10 倍 高速化されました。
2 つ目の調査結果はさらに興味深いものでした。これらの関数は技術的には匿名関数ですが、V8 エンジンは、それらの関数に「推論された名前」を記録していました。代入の右側に obj.foo = function() {...}
という形式で表示される関数リテラルの場合、V8 パーサーは関数リテラルの推論された名前として "obj.foo"
を記憶します。つまり、このケースでは、検索できる適切な名前はありませんでしたが、近い名前はありました。上記の A.SDV = function() {...}
の例では、推定された名前として "A.SDV"
があり、最後のドットを見つけて、オブジェクトのプロパティ "SDV"
を探すことで、推定された名前からプロパティ名を導出できました。ほとんどの場合、この方法で問題は解決し、費用のかかる完全な走査を単一のプロパティ検索に置き換えられました。この 2 つの改善は この CL の一部として実装され、chromium:1069425 で報告された例の速度低下が大幅に軽減されました。
Error.stack
これで終わりにすることもできました。しかし、DevTools はスタックフレームのメソッド名を決して使用しないため、何かおかしいことが起こっていました。実際、C++ API の v8::StackFrame
クラスには、メソッド名を取得する方法すら公開されていません。そのため、最初から JSStackFrame::GetMethodName()
を呼び出すのは間違っているように思えました。代わりに、メソッド名を使用する(公開する)場所は JavaScript スタック トレース API のみです。この用途を理解するために、次の簡単な例 error-methodname.js
について考えてみましょう。
function foo() {
console.log((new Error).stack);
}
var object = {bar: foo};
object.bar();
ここでは、object
に "bar"
という名前でインストールされている関数 foo
があります。このスニペットを Chromium で実行すると、次の出力が表示されます。
Error
at Object.foo [as bar] (error-methodname.js:2)
at error-methodname.js:6
ここでは、メソッド名の検索が行われています。最上位のスタックフレームは、bar
という名前のメソッドを介して Object
のインスタンスで関数 foo
を呼び出しています。そのため、非標準の error.stack
プロパティでは JSStackFrame::GetMethodName()
を大量に使用します。実際、パフォーマンス テストでも、この変更により大幅な高速化が実現したことが示されています。
Chrome DevTools に戻ると、error.stack
が使用されていないにもかかわらずメソッド名が計算されるという事実は正しくないように見えます。これまで V8 には、前述の 2 つの異なる API(C++ v8::StackFrame
API と JavaScript スタック トレース API)のスタックトレースを収集して表示するための 2 つの異なるメカニズムがありました。ほぼ同じことを 2 つの異なる方法で行うことはエラーが発生しやすく、不整合やバグにつながることが多かったため、2018 年後半に、スタック トレースのキャプチャの単一のボトルネックを決定するプロジェクトを開始しました。
このプロジェクトは大きな成功を収め、スタック トレース収集に関連する問題の数を大幅に減らすことができました。非標準の error.stack
プロパティで提供される情報のほとんどは、本当に必要なときだけ遅延計算されていましたが、リファクタリングの一環として、v8::StackFrame
オブジェクトにも同じ手法が適用されました。スタック フレームに関するすべての情報は、その上でメソッドが初めて呼び出されたときに計算されます。
これにより通常はパフォーマンスが向上しますが、残念ながら、Chromium と DevTools でこれらの C++ API オブジェクトが使用される方法と若干矛盾することが判明しました。特に、v8::StackFrame
または error.stack
を介して公開されたスタックフレームに関するすべての情報を保持する新しい v8::internal::StackFrameInfo
クラスを導入したため、両方の API から提供される情報のスーパーセットが常に計算されます。つまり、v8::StackFrame
の使用(特に DevTools の場合)では、スタックフレームに関する情報がリクエストされるとすぐにメソッド名も計算されます。調べてみると、DevTools は常にソースとスクリプト情報をすぐにリクエストしています。
この認識に基づいて、スタックフレームの表現をリファクタリングして大幅に簡素化し、さらに遅延を減らすことができるようになりました。これにより、V8 と Chromium 全体で、リクエストした情報の計算コストのみが支払われるようになりました。これにより、スタックフレームに関する情報のごく一部(基本的にはスクリプト名と行と列のオフセット形式のソース位置のみ)しか必要としない DevTools などの Chromium のユースケースでパフォーマンスが大幅に向上し、パフォーマンスのさらなる改善が可能になりました。
関数名
上記のリファクタリングにより、シンボル化のオーバーヘッド(v8_inspector::V8Debugger::symbolize
で費やされる時間)が全体の実行時間の約 15% にまで削減され、DevTools で使用するためにスタックフレームをシンボル化(収集および)する際に V8 が時間を費やしている場所をより明確に把握できるようになりました。
最初に目立ったのは、行番号と列番号の計算にかかる累積費用でした。ここで時間のかかる部分は、実際にスクリプト内の文字オフセットを計算することです(V8 から取得したバイトコード オフセットに基づいて)。上記のリファクタリングにより、行番号の計算と列番号の計算の 2 回、この計算が行われていました。v8::internal::StackFrameInfo
インスタンスでソース位置をキャッシュに保存することで、この問題は迅速に解決され、すべてのプロファイルから v8::internal::StackFrameInfo::GetColumnNumber
が完全に削除されました。
興味深いのは、調べたすべてのプロファイルで v8::StackFrame::GetFunctionName
が驚くほど高かったことです。詳しく調べてみると、DevTools のスタックフレーム内で関数に表示する名前を計算するのは、不必要にコストがかかることがわかりました。
- まず、標準以外の
"displayName"
プロパティを探し、文字列値を持つデータプロパティが見つかった場合は、そのプロパティを使用します。 - そうでない場合は、標準の
"name"
プロパティを検索し、値が文字列のデータプロパティが返されるかどうかを再度確認します。 - 最終的には、V8 パーサーによって推論され、関数リテラルに格納される内部デバッグ名にフォールバックします。
"displayName"
プロパティは、Function
インスタンスの "name"
プロパティが JavaScript で読み取り専用で構成できないという回避策として追加されましたが、標準化されることはなく、広く使用されることもありませんでした。これは、ブラウザのデベロッパー ツールに、99.9% のケースでこの機能を実行する関数名推論が追加されたためです。さらに ES2015 では、Function
インスタンスの "name"
プロパティを構成可能にすることで、特別な "displayName"
プロパティの必要性を完全に排除しました。"displayName"
のネガティブ ルックアップは非常にコストがかかり、実際には必要ないため(ES2015 は 5 年以上前にリリースされています)、V8(および DevTools)から非標準の fn.displayName
プロパティのサポートを削除することにしました。
"displayName"
のネガティブ ルックアップが不要になったため、v8::StackFrame::GetFunctionName
のコストが半減しました。残りの半分は、汎用の "name"
プロパティのルックアップに使用されます。幸い、(変更されていない)Function
インスタンスで "name"
プロパティのコストの高いルックアップを回避するためのロジックがすでに用意されていました。これは、Function.prototype.bind()
自体を高速化するために V8 で導入されたものです。必要なチェックを移植し、コストのかかる汎用ルックアップを最初からスキップできるようにしました。その結果、検討対象のどのプロファイルにも v8::StackFrame::GetFunctionName
が表示されなくなりました。
まとめ
上記の改善により、スタック トレースに関する DevTools のオーバーヘッドが大幅に削減されました。
改善の余地はまだまだあると認識しています(たとえば、chromium:1077657 で報告されているように、MutationObserver
を使用する際のオーバーヘッドは依然として顕著です)。現時点では、主な問題点に対処しましたが、今後、デバッグ パフォーマンスをさらに効率化するために再び取り組む可能性があります。
プレビュー チャネルをダウンロードする
デフォルトの開発用ブラウザとして Chrome の Canary、Dev、Beta を使用することを検討してください。これらのプレビュー チャンネルでは、最新の DevTools 機能にアクセスしたり、最先端のウェブ プラットフォーム API をテストしたりできます。また、ユーザーよりも早くサイトの問題を見つけることもできます。
Chrome DevTools チームに問い合わせる
次のオプションを使用して、DevTools の新機能、アップデート、その他のトピックについて話し合います。
- フィードバックや機能リクエストは crbug.com から送信してください。
- DevTools で [その他] > [ヘルプ] > [DevTools の問題を報告] を使用して、DevTools の問題を報告します。
- @ChromeDevTools にツイートします。
- DevTools の新機能に関する YouTube 動画または DevTools のヒントに関する YouTube 動画にコメントを残してください。