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. 各名前に対して汎用プロパティ検索を実行し、結果として得られるプロパティ値が、探していたクロージャに一致するかどうかテストします。

名前を抽出するには、すでにすべてのプロパティを通過する必要があるため、かなり簡単に完了できるものに見えました。名前の抽出については O(N)、テストについては O(N log(N)) の 2 つのパスを実行する代わりに、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();

ここでは、関数 fooobject"bar" という名前でインストールされています。このスニペットを 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 CanaryDevBeta の使用を検討してください。これらのプレビュー チャネルでは、最新の DevTools 機能にアクセスしたり、最先端のウェブ プラットフォーム API をテストしたり、ユーザーが事前にサイトに関する問題を発見したりすることができます。

Chrome DevTools チームへのお問い合わせ

投稿内の新機能や変更点、または DevTools に関するその他の事項について議論するには、以下のオプションを使用します。

  • crbug.com からご提案やフィードバックをお寄せください。
  • DevTools の [その他のオプション] その他   > [ヘルプ] > [DevTools の問題を報告する] を使用して、DevTools の問題を報告する。
  • @ChromeDevTools でツイートします。
  • DevTools の新機能の YouTube 動画または DevTools のヒントの YouTube 動画で、コメントを記入してください。