Chrome DevTools のスタック トレースを 10 倍高速化した方法

Benedikt Meurer
Benedikt Meurer

ウェブ デベロッパーは、コードのデバッグ時にパフォーマンスへの影響がほとんどないか、まったくないと想定しています。しかし、この期待は必ずしも普遍的なものではありません。C++ デベロッパーは、アプリケーションのデバッグビルドが本番環境のパフォーマンスに達することを期待していません。Chrome の初期の頃は、DevTools を開くだけでページのパフォーマンスに大きな影響がありました。

このパフォーマンスの低下が感じられなくなったのは、DevToolsV8 のデバッグ機能に長年投資してきた結果です。ただし、DevTools のパフォーマンス オーバーヘッドをゼロにすることは決してできません。ブレークポイントの設定、コードのステップ実行、スタック トレースの収集、パフォーマンス トレースの取得などはすべて、実行速度にさまざまな程度で影響します。結局のところ、何かを観察することで状況は変わります

もちろん、DevTools のオーバーヘッドは、他のデバッガと同様に、妥当なものであるはずです。最近、特定のケースで DevTools によってアプリケーションの動作が遅くなり、使用できなくなるという報告が大幅に増加しています。以下は、レポート chromium:1069425 の並べ替え比較で、DevTools を単に開いているだけで発生するパフォーマンス オーバーヘッドを示しています。

動画からわかるように、速度低下は 5~10 倍のレベルであり、これは明らかに許容範囲外です。最初のステップは、DevTools を開いていたときに、これまでの流れと、この大幅な速度低下の原因を把握することでした。Chrome レンダラ プロセスで Linux perf を使用すると、レンダラ全体の実行時間の分布が次のようになりました。

Chrome レンダラの実行時間

スタック トレースの収集に関連するものが表示されることをある程度予想していましたが、全体の実行時間の約 90% がスタック フレームのシンボル化に費やされるとは予想していませんでした。ここでのシンボリケーションとは、未加工のスタックフレームから関数名と具体的なソース位置(スクリプトの行番号と列番号)を解決する行為を指します。

メソッド名の推論

さらに驚くべきことに、ほとんどの時間が V8 の JSStackFrame::GetMethodName() 関数に費やされています。以前の調査では、JSStackFrame::GetMethodName() がパフォーマンスの問題に関係していることはわかっていましたが、この関数は、メソッド呼び出しと見なされるフレーム(func() ではなく obj.func() 形式の関数呼び出しを表すフレーム)のメソッド名を計算しようとします。コードをざっと見てみると、オブジェクトとそのプロトタイプ チェーンを完全に走査し、

  1. valuefunc クロージャであるデータ プロパティ、または
  2. get または setfunc クロージャに等しいアクセサ プロパティ。

単体では特に安価とは言えませんが、この恐ろしい速度低下を説明するものとも言えません。そこで、chromium:1069425 で報告されている例を詳しく調べると、非同期タスクと、classes.js から発信されたログメッセージ(10 MiB の JavaScript ファイル)のスタック トレースが収集されたことがわかりました。詳しく調べてみると、これは基本的に Java ランタイムと JavaScript にコンパイルされたアプリケーション コードであることがわかりました。スタック トレースには、オブジェクト A で呼び出されるメソッドを持つ複数のフレームが含まれていたため、どのような種類のオブジェクトを扱っているかを理解しておく価値があると考えました。

オブジェクトのスタック トレース

Java から JavaScript へのコンパイラが、なんと 82,203 個の関数を含む単一のオブジェクトを生成したようです。これは明らかに興味深い結果です。次に、V8 の JSStackFrame::GetMethodName() に戻り、簡単に改善できる点がないか確認しました。

  1. 最初に、関数の "name" をオブジェクトのプロパティとして検索し、見つかった場合は、プロパティ値が関数と一致することを確認します。
  2. 関数に名前がない場合、またはオブジェクトに一致するプロパティがない場合は、オブジェクトとそのプロトタイプのすべてのプロパティが走査され、逆引き参照になります。

この例では、すべての関数は匿名で、"name" プロパティは空になっています。

A.SDV = function() {
   // ...
};

最初の検出結果は、リバース ルックアップが 2 つのステップに分割されていることです(オブジェクト自体とプロトタイプ チェーン内の各オブジェクトに対して実行されます)。

  1. すべての列挙可能なプロパティの名前を抽出し、
  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() が多用されており、パフォーマンス テストでは、この変更によって処理が大幅に高速化されることも示されています。

StackTrace マイクロベンチマークの高速化

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 のスタックフレーム内で関数に表示する名前を計算するのは、不必要にコストがかかることがわかりました。

  1. まず標準以外の "displayName" プロパティを探し、それによって文字列値のデータ プロパティが得られたら、それを使用します。
  2. それ以外の場合は、標準の "name" プロパティを探して、値が文字列のデータ プロパティを返すかどうかを再度確認します。
  3. 最終的には、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 の CanaryDev、または Beta を使用することを検討してください。これらのプレビュー チャンネルでは、最新の DevTools 機能にアクセスしたり、最先端のウェブ プラットフォーム API をテストしたりできます。また、ユーザーよりも早くサイトの問題を見つけることもできます。

Chrome DevTools チームに問い合わせる

次のオプションを使用して、DevTools の新機能、更新、その他のトピックについて話し合います。