より速いJavaScriptコール
JavaScriptでは、期待されるパラメータの数とは異なる引数の数で関数を呼び出すことができます。つまり、宣言された形式パラメータより少ないまたは多い引数を渡すことができます。前者の場合は「アンダーアプリケーション」と呼ばれ、後者は「オーバーアプリケーション」と呼ばれます。
アンダーアプリケーションの場合、残りのパラメータにはundefined値が割り当てられます。オーバーアプリケーションの場合、残りの引数は残りのパラメータやarguments
プロパティを使用してアクセスできます。あるいは、単に余分で無視される場合もあります。多くのWeb/Node.jsフレームワークは今日、このJSの特徴を使用してオプションパラメータを受け入れ、より柔軟なAPIを作成しています。
ここ最近まで、V8は引数のサイズミスマッチを処理する特別な仕組み、引数アダプタフレームを持っていました。残念ながら、引数の適応は性能のコストが伴いますが、現代のフロントエンドおよびミドルウェアフレームワークでは一般的です。賢いトリックを使えば、この追加フレームを削除し、V8コードベースを簡素化しながらほとんどのオーバーヘッドを排除できます。
引数アダプタフレームを削除することによる性能への影響をマイクロベンチマークで計算することができます。
console.time();
function f(x, y, z) {}
for (let i = 0; i < N; i++) {
f(1, 2, 3, 4, 5);
}
console.timeEnd();
グラフから分かるように、JITなしモード(Ignition)で実行した場合にはもうオーバーヘッドがなく、性能が11.2%向上しています。TurboFanを利用する場合、最大40%の速度向上が見られます。
このマイクロベンチマークは引数アダプタフレームの影響を最大化するよう自然に設計されています。しかし、多くのベンチマークで顕著な改善が見られました。例えば、内部JSTests/Arrayベンチマークで7%、Octane2ではリチャードで4.6%、アーリーボイヤーでは6.1%の向上が見られました。
TL;DR: 引数を逆転
このプロジェクトの核心は、引数アダプタフレームを削除することでした。これは、スタック内で引数を確認する際、呼び出し元に一貫したインターフェースを提供します。そのためには、スタック内の引数を逆転させ、呼び出し元フレームに実際の引数カウントを含む新しいスロットを追加する必要がありました。以下の図は、変更前後の典型的なフレームの例を示しています。
JavaScriptコールを高速化する方法
コールを高速化するために何を行ったかを確認するには、V8がコールを実行する過程と引数アダプタフレームがどのように機能するかを見てみましょう。
JavaScriptで関数コールを実行するとV8内部で何が起きるのでしょうか?次のJSスクリプトを例に考えてみましょう。
function add42(x) {
return x + 42;
}
add42(3);
Ignition
V8はマルチレベルVMであり、最初のレベルはIgnitionと呼ばれています。これは、累加器レジスタを持つバイトコードスタックマシンです。V8はコードをIgnitionバイトコードにコンパイルすることから始めます。上記のコールは次のようにコンパイルされます。
0d LdaUndefined ;; 未定義値を累加器にロード
26 f9 Star r2 ;; レジスタr2に値を格納
13 01 00 LdaGlobal [1] ;; 定数1(add42)にポイントされるグローバルをロード
26 fa Star r1 ;; レジスタr1に値を格納
0c 03 LdaSmi [3] ;; 累加器に小整数3をロード
26 f8 Star r3 ;; レジスタr3に値を格納
5f fa f9 02 CallNoFeedback r1, r2-r3 ;; コールを実行
コールの最初の引数は通常、受信者と呼ばれます。受信者はJSFunction内のthis
オブジェクトであり、すべてのJS関数コールにはこれが必要です。CallNoFeedback
のバイトコードハンドラーは、オブジェクトr1
を引数リストr2-r3
でコールする必要があります。
バイトコードハンドラーに入る前に、バイトコードでレジスターがどのようにエンコードされているかを確認してください。それらは負の1バイト整数としてエンコードされています:r1
はfa
、r2
はf9
、r3
はf8
としてエンコードされています。我々は任意のレジスターri
を実際にはfb - i
として参照できますが、正確なエンコードは- 2 - kFixedFrameHeaderSize - i
です。レジスターリストは、リストの最初のレジスターとサイズでエンコードされます。したがって、r2-r3
はf9 02
となります。
Ignitionには多くのバイトコードコールハンドラーがあります。それらのリストはこちらで確認できます。それらは少しずつ異なります。undefined
レシーバーを伴うコール、プロパティのコール、固定数のパラメータを持つコール、または汎用コールに最適化されたバイトコードがあります。ここでは、実行からフィードバックを収集しない汎用コールであるCallNoFeedback
を分析します。
このバイトコードのハンドラーは非常にシンプルです。それはCodeStubAssembler
で書かれており、こちらで確認できます。本質的には、アーキテクチャ依存のビルトインInterpreterPushArgsThenCall
に対してテールコールします。
このビルトインは基本的に、リターンアドレスを一時レジスターにポップし、全引数(レシーバーを含む)をプッシュし、リターンアドレスを再びプッシュします。この時点で、呼び出し先が呼び出し可能オブジェクトであるか、また呼び出し先が期待している引数の数(すなわち、その形式的なパラメータ数)を把握していません。
最終的に実行は、ビルトインCall
に対してテールコールします。そこで、ターゲットが適切な関数、コンストラクターまたは呼び出し可能なオブジェクトであるかを確認します。また、その共有関数情報
構造を読んで、その形式的なパラメータ数を取得します。
呼び出し先が関数オブジェクトである場合、それはビルトインCallFunction
に対してテールコールし、そこでundefined
オブジェクトがレシーバーとしてあるかどうかを含む、多数のチェックが行われます。もしレシーバーがundefined
またはnull
オブジェクトである場合、ECMA仕様に従って、それをグローバルプロキシオブジェクトを指すようにパッチする必要があります。
その後の実行は、ビルトインInvokeFunctionCode
に対してテールコールし、引数の不一致がない限り、呼び出し先オブジェクトのCode
フィールドによって指されるものを呼び出します。これは、最適化された関数またはビルトインInterpreterEntryTrampoline
のいずれかである可能性があります。
まだ最適化されていない関数を呼び出していると仮定すると、IgnitionトランポリンはInterpreterFrame
をセットアップします。V8でのフレームタイプのおおまかな概要はこちらで確認できます。
次に何が起こるかを細かく見ていくことなく、呼び出し先実行中のインタープリターフレームのスナップショットを確認できます。
フレームには固定数のスロットがあることがわかります:リターンアドレス、以前のフレームポインター、コンテキスト、現在実行中の関数オブジェクト、この関数のバイトコード配列、そして現在実行中のバイトコードのオフセットです。最後に、この関数専用のレジスターリストがあります(これらは関数ローカルのようなものと考えられます)。add42
関数には実際にはレジスターがありませんが、呼び出し元には3つのレジスターを含む似たフレームがあります。
予想通り、add42は簡単な関数です:
25 02 Ldar a0 ;; 最初の引数をアキュムレータに読み込む
40 2a 00 AddSmi [42] ;; それに42を加える
ab Return ;; アキュムレータを返す
Ldar
(Load Accumulator Register) バイトコードで引数がどのようにエンコードされているかに注目してください。引数1
(a0
)は数値02
でエンコードされています。実際、任意の引数のエンコードは単に[ai] = 2 + parameter_count - i - 1
であり、レシーバー[this] = 2 + parameter_count
です。この例では、[this] = 3
です。ここでのパラメータ数はレシーバーを含みません。
レジスタや引数をこのようにエンコードする理由がわかるようになります。それらは単にフレームポインタからのオフセットを示しています。これにより、引数やレジスタのロードとストアを同じ方法で扱うことができます。フレームポインタから最後の引数までのオフセットは2
(以前のフレームポインタと戻りアドレス)です。これがエンコードにおける2
の説明です。インタープリターフレームの固定部分は6
スロット(フレームポインタから4
スロット)、したがってレジスタ0はオフセット-5
、つまりfb
に、レジスタ1
はfa
に位置しています。頭の良い方法ですよね?
ただし、引数にアクセスするためには、関数がスタックにいくつの引数があるかを知っている必要があります!インデックス2
は引数の数に関係なく最後の引数をポイントします!
Return
のバイトコードハンドラーは、内蔵関数LeaveInterpreterFrame
を呼び出して終了します。この内蔵関数は基本的に関数オブジェクトを読み込み、フレームからパラメータ数を取得し、現在のフレームをポップし、フレームポインタを回復し、一時レジスタに戻りアドレスを保存し、パラメータ数に従って引数をポップし、一時レジスタにあるアドレスにジャンプします。
このフローは素晴らしいです!しかし、関数をそのパラメータ数より少ないまたは多い引数で呼び出した場合にはどうなるのでしょうか?頭の良い引数/レジスタアクセスは失敗し、呼び出しの終わりに引数をどのようにクリーンアップするのでしょうか?
引数アダプターフレーム
次に、add42
を少ない引数または多い引数で呼び出してみましょう:
add42();
add42(1, 2, 3);
私たちの中のJS開発者は、最初の場合でx
がundefined
に設定され、関数がundefined + 42 = NaN
を返すことを知っているでしょう。2番目の場合、x
は1
に設定され、関数は43
を返します。残りの引数は無視されます。それが起こるかどうかは呼び出し元には分かりません。たとえ呼び出し元がパラメータ数を確認しても、呼び出し先はレストパラメータや引数オブジェクトを使って他のすべての引数にアクセスすることができます。実際、引数オブジェクトはスラッピーモードでadd42
の外部でもアクセス可能です。
以前と同じ手順を踏むと、まず内蔵関数InterpreterPushArgsThenCall
を呼び出します。それは次のようにスタックに引数をプッシュします:
以前と同じ手順を続けると、呼び出し先が関数オブジェクトであるか確認し、そのパラメータ数を取得し、レシーバーをグローバルプロキシに変更します。最終的にInvokeFunctionCode
に到達します。
ここで、呼び出し先オブジェクトのCode
にジャンプする代わりに、引数のサイズとパラメータ数の不一致を確認し、ArgumentsAdaptorTrampoline
にジャンプします。
この内蔵関数内で追加のフレーム、いわゆる引数アダプターフレームを構築します。内蔵関数内で何が起こるのかを説明する代わりに、呼び出し先のCode
を呼び出す前のフレームの状態を示します。これは適切なx64 call
(jmp
ではありません)であり、呼び出し先の実行後にArgumentsAdaptorTrampoline
に戻ります。これはInvokeFunctionCode
がテールコールするのとは対照的です。
ご覧のとおり、呼び出し先フレームの上に正確なパラメータ数の引数を持つように必要なすべての引数をコピーする別のフレームを作成します。これにより、呼び出し先関数が引数の数を知る必要がなくなります。呼び出し先は以前と同じ計算を用いて常にそのパラメータにアクセスすることができます。それは[ai] = 2 + parameter_count - i - 1
です。
V8には、残りのパラメータまたは引数オブジェクトを通じて残りの引数にアクセスする必要がある場合に引数アダプターフレームを理解する特別な内蔵関数があります。それらは常に呼び出し先フレームの上にあるアダプターフレームタイプを確認し、それに応じて動作します。
ご覧のとおり、引数/レジスタアクセスの問題を解決していますが、多くの複雑さが生じます。すべての引数にアクセスする必要がある内蔵関数はアダプターフレームの存在を理解し確認する必要があります。それだけでなく、古いデータや古いデータにアクセスしないよう注意が必要です。add42
への次の変更を考慮してください:
function add42(x) {
x += 42;
return x;
}
現在のバイトコード配列は次のようになります:
25 02 Ldar a0 ;; 最初の引数を累積器にロード
40 2a 00 AddSmi [42] ;; それに42を加算する
26 02 Star a0 ;; 累積器を最初の引数スロットに保存
ab Return ;; 累積器を返す
ご覧のとおり、a0
を変更します。そのため、add42(1, 2, 3)
の呼び出しの場合、引数アダプターフレーム内のスロットは変更されますが、呼び出し元フレームは依然として数値1
を保持しています。引数オブジェクトが古い値ではなく変更された値にアクセスしていることに注意しなければなりません。
関数から戻るのは簡単ですが、遅いです。LeaveInterpreterFrame
が何をするか覚えていますか?それは基本的に呼び出し先フレームとパラメータ数分の引数をポップします。そのため、引数アダプターのスタブに戻ると、スタックは次のようになります:
引数の数をポップし、アダプタフレームをポップし、実際の引数の数に応じて全ての引数をポップして、呼び出し元の実行に戻れば良いだけです。
要約すると、引数アダプタの仕組みは複雑であるだけでなく、コストも高いです。
引数アダプタフレームの削除
もっと良い方法はないでしょうか?アダプタフレームを削除することは可能でしょうか?実際、可能です。
要件を再確認してみましょう:
- 引数およびレジスタに対して、以前のようにシームレスにアクセスできる必要があります。それらにアクセスする際のチェックはコストが高すぎますので行いません。
- スタックから rest パラメータと arguments オブジェクトを生成できる必要があります。
- 呼び出しから戻る際に不明な数の引数を簡単にクリーンアップできる必要があります。
- もちろん、追加のフレームなしでこれを実現したいです!
追加フレームを削除したい場合、引数を置く場所を決める必要があります。それは、呼び出し元フレームまたは呼び出し先フレームのいずれかです。
呼び出し先フレーム内の引数
引数を呼び出し先フレームに置くと仮定します。この場合、フレームをポップする際に、引数も一度に全てポップされるので、実際に良いアイデアのようです!
引数は、保存されたフレームポインタとフレームの終わりの間のどこかに配置する必要があります。これにより、フレームのサイズが静的に知られないことを意味します。引数のアクセスは依然として簡単で、フレームポインタからのオフセットだけだからです。しかし、レジスタへのアクセスは引数の数によって異なるため、はるかに複雑になります。
スタックポインタは常に最後のレジスタを指しています。それを利用して引数数を知らなくてもレジスタにアクセスできるかもしれません。このアプローチは実際に機能する可能性がありますが、大きな欠点があります。それは、レジスタと引数にアクセスできる全てのバイトコードを複製することを意味します。つまり、単なる Ldar
の代わりに LdaArgument
と LdaRegister
が必要になります。もちろん、引数をアクセスしているのかレジスタをアクセスしているのかをチェックするという方法もありますが(正または負のオフセットによる)、それだと全ての引数およびレジスタのアクセスごとにチェックが必要になります。明らかにコストが高すぎますね!
呼び出し元フレーム内の引数
では、引数を呼び出し元フレーム内に保持する場合はどうでしょうか?
フレーム内で引数 i
のオフセットを計算する方法を覚えていますか? [ai] = 2 + parameter_count - i - 1
。全ての引数(パラメータだけではなく)を持つ場合、オフセットは [ai] = 2 + argument_count - i - 1
になります。つまり、引数をアクセスするたびに実際の引数数をロードする必要があることを意味します。
では、引数を反転させた場合はどうなるでしょうか?この場合、オフセットは単純に [ai] = 2 + i
として計算できます。スタック内にいくつの引数があるかを知る必要がありませんが、スタックには常に少なくともパラメータ数分の引数があることを保証できれば、この方法を常に使用してオフセットを計算することができます。
言い換えれば、スタックにプッシュされる引数の数は、引数数と形式的なパラメータ数のうち大きい方になり、必要に応じて未定義のオブジェクトで埋めます。
これにはもう1つの利点があります!レシーバーは任意のJS関数に対して常に同じオフセットに配置されており、戻りアドレスのすぐ上になります:[this] = 2
。
これは、要件の番号 1
と番号 4
に対してクリーンな解決策です。他の2つの要件についてはどうでしょうか?rest パラメータと arguments オブジェクトをどのように構築するのでしょうか?また、呼び出し元に戻る際にスタック内の引数をどのようにクリンアップするのでしょうか?これには引数数だけが不足しています。その情報をどこかに保存する必要があります。ここでの選択はやや恣意的ですが、この情報に簡単にアクセスできる限り問題ありません。基本的な2つの選択肢は、呼び出し元フレームでレシーバーの直後にプッシュするか、呼び出し先フレームの固定ヘッダ部分として保存するかです。私たちは後者を実装しました。なぜなら、これはInterpreterフレームとOptimizedフレームの固定ヘッダ部分を統合するからです。
V8 v8.9で例を実行すると、InterpreterArgsThenPush
の後に次のようなスタックが表示されます(引数が反転していることに注意してください):
全ての実行は類似したパスを辿り、InvokeFunctionCode
に到達します。ここでは過少適用の場合に引数を加工し、必要な未定義オブジェクトをプッシュします。過剰適用の場合は何も変更しません。最終的に引数の数をレジスタを介して呼び出し先の Code
に渡します。x64
の場合、レジスタ rax
を使用します。
呼び出し先がまだ最適化されていない場合、InterpreterEntryTrampoline
に到達し、次のスタックフレームを構築します。
呼び出し先フレームには引数の数を含む余分なスロットがあり、rest パラメータや arguments オブジェクトの構築、および呼び出し元に戻る前にスタック内の引数をクリーンアップするために使用できます。
戻るには、LeaveInterpreterFrame
を修正してスタック内の引数の数を読み取り、引数の数と形式的なパラメータの数の最大値をポップアウトするようにします。
TurboFan
最適化されたコードはどうでしょうか?最初のスクリプトを少し変更して、V8がTurboFanでコンパイルするように強制してみましょう。
function add42(x) { return x + 42; }
function callAdd42() { add42(3); }
%PrepareFunctionForOptimization(callAdd42);
callAdd42();
%OptimizeFunctionOnNextCall(callAdd42);
callAdd42();
ここではV8の内部関数を使用して呼び出しを最適化するように強制しています。そうでなければ、V8は小さな関数が頻繁に使用される場合にのみ最適化します。最適化前に1回呼び出して型情報を収集し、コンパイルの指針として使用します。TurboFanについての詳細はこちらをご覧ください。
ここでは生成されたコードの関連部分のみをお見せします。
movq rdi,0x1a8e082126ad ;; 関数オブジェクト<JSFunction add42>を読み込む
push 0x6 ;; 引数としてSMI 3をプッシュする
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; レシーバー(グローバルプロキシオブジェクト)をプッシュする
movl rax,0x1 ;; raxに引数の数を保存する
movl rcx,[rdi+0x17] ;; 関数オブジェクト{Code}フィールドをrcxに読み込む
call rcx ;; 最後にコードオブジェクトを呼び出す!
アセンブリで記述されていますが、コメントを追えばこのコードスニペットは読み取りやすいはずです。本質的には、呼び出しをコンパイルするとき、TFはInterpreterPushArgsThenCall
、Call
、CallFunction
、およびInvokeFunctionCall
ビルトインで行われたすべての作業を行う必要があります。幸運にも、TFはより多くの静的情報を持ってそれを行い、コンピュータ命令を少なく生成します。
引数アダプタフレーム付きのTurboFan
次に、引数の数とパラメータの数が一致しない場合を見てみましょう。add42(1, 2, 3)
を呼び出すと以下のようにコンパイルされます。
movq rdi,0x4250820fff1 ;; 関数オブジェクト<JSFunction add42>を読み込む
;; レシーバーと引数SMIs 1, 2, 3をプッシュする
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; raxに引数の数を保存する
movl rbx,0x1 ;; rbxに形式的なパラメータの数を保存する
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; ArgumentsAdaptorTrampolineを呼び出す
見ての通り、TFに引数とパラメータの数の不一致をサポートするのは難しくありません。ただし、ArgumentsAdaptorTrampolineを呼び出せば良いのです。
しかし、これは高コストです。最適化された呼び出しごとに、非最適化コードと同様にフレームを調整するArgumentsAdaptorTrampolineに入る必要があります。そのため、最適化されたコードでアダプタフレームを削除することで得られるパフォーマンス向上は、Ignitionよりもはるかに大きいのです。
生成されたコードは非常にシンプルです。そして、そこからの戻りは非常に簡単です(エピローグ)。
movq rsp,rbp ;; 呼び出し元フレームをクリーン
pop rbp
ret 0x8 ;; 1つの引数(レシーバー)をポップする
フレームをポップし、パラメータの数に応じてリターン命令を発行します。引数とパラメータの数に不一致がある場合、アダプタフレームトランポリンがそれを処理します。
引数アダプタフレームなしのTurboFan
生成されたコードは、引数の数が一致する呼び出しの場合と本質的に同じです。例えばadd42(1, 2, 3)
を呼び出すと以下が生成されます。
movq rdi,0x35ac082126ad ;; 関数オブジェクト<JSFunction add42>を読み込む
;; レシーバーと引数1, 2, 3(逆順に)をプッシュ
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; raxに引数の数を保存
movl rcx,[rdi+0x17] ;; rcxに関数オブジェクト{Code}フィールドを読み込む
call rcx ;; 最後にコードオブジェクトを呼び出す!
では、関数のエピローグはどうでしょうか?もはやArgumentsAdaptorTrampolineに戻る必要はないため、エピローグは以前よりも少し複雑です。
movq rcx,[rbp-0x18] ;; 引数のカウント(呼び出し元フレームから)をrcxに保存
movq rsp,rbp ;; 呼び出し元フレームをポップする
pop rbp
cmpq rcx,0x0 ;; 引数のカウントと形式的なパラメータの比較
jg 0x35ac000840c6 <+0x86>
;; 引数の数が形式的なパラメータ以下の場合:
ret 0x8 ;; 通常通りリターン(形式的なパラメータの数は静的にわかっている)
;; スタックに形式的なパラメータより多くの引数がある場合:
pop r10 ;; リターンアドレスを保存
leaq rsp,[rsp+rcx*8+0x8] ;; rcxに基づいてすべての引数をポップ
push r10 ;; リターンアドレスを復元
retl