Qiita Node.js アドベントカレンダー2021の20日目の記事です
qiita.com
v8のbytecodeについて、自分で書いたコードでどうやって確認するか気になったので調べてみました。
v8によるバイトコード生成
v8がどうやってバイトコード、機械語を生成するかざっくりと整理します。
v8がJavaScriptをコンパイルするとき、パーサはASTを生成します。
ASTはJavascriptコードの文法構造を表現したツリーです。
v8のインタプリタであるIgnitionはこのASTからバイトコードを生成します。
一方で、最適化コンパイラであるTurboFanは最終的にバイトコードを取得し、そのバイトコードから最適化された機械語を生成します。
なぜv8がこの2つの実行モードを持っているかについては、下記の動画で説明されています。
JavaScriptコードからバイトコードを確認する
ここで、実際に書いたコードから、v8によって生成されるバイトコードを確認します。
nodeコマンドのv8オプションにバイトコードを表示するオプションがあります。nodeコマンド実行時にそのオプションを指定し、該当のjsコードを指定するとバイトコードを確認することができます。
v8オプションの調べ方
まずnodeコマンドでv8に関するオプションを確認するには、--v8-optionsオプションを使用して確認できます。 公式のドキュメントにも記載があります。 Command-line API | Node.js v21.0.0 Documentation
実行するコードのbytecodeを表示するオプションは --print-bytecodeになります
--print-bytecode (print bytecode generated by ignition interpreter)
type: bool default: false
--print-bytecodeのみだと、実行しているコードに関する全情報が表示されてしまいます。
実行する関数のみの情報を表示したい場合は
--print-bytecode-filter=${funcName} を追加で指定します。
--print-bytecode-filter (filter for selecting which functions to print bytecode)
type: string default: *
実装した関数をバイトコードで見てみる
早速、簡単な関数をいくつか実装し、それぞれの関数のバイトコードを確認してみます。
function add(a, b) {
return a + b;
}
add(10, 20)
function sub(a, b) {
return a - b;
}
sub(20, 10)
function constAdd(a) {
const v = 100;
return a + v;
}
constAdd(1);
function constAddLarge(a) {
const v = 100000;
return a + v;
}
constAddLarge(1);
ポイントとして、v8は遅延評価をするので、上に記載したように関数を定義したあとに実行しないと--print-bytecodeでもバイトコードが表示されないので気をつけてください。
下記に具体的なバイトコードを載せますが、各バイトコードの説明は、下記のinterpreter-generator.ccで確認可能です。
github.com
addの場合
➜ node --print-bytecode --print-bytecode-filter=add app.js [generated bytecode for function: add (0x045a03077c09 <SharedFunctionInfo add>)] Bytecode length: 6 Parameter count 3 Register count 0 Frame size 0 OSR nesting level: 0 Bytecode Age: 0 25 S> 0x45a0307878e @ 0 : 0b 04 Ldar a1 34 E> 0x45a03078790 @ 2 : 38 03 00 Add a0, [0] 38 S> 0x45a03078793 @ 5 : a8 Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 8) 0x045a03078799 <ByteArray[8]>
こういう感じで Bytecodeが表示されます。 add関数ではシンプルに変数aとbを加算します。
Ldar a1
// Load accumulator with value from register. Ldarはレジスターからアキュムレータに値を読み込むバイトコードです。 a1は a1レジスタとなり、ここでは引数の2番めの値をアキュムレータに保存します。 実際にバイトコード Ldarの実装を見てみると下記の通りになります。 - オペランド0番(ここでいう a1)のレジスターの値を読み込む
- 1で読み込んだ値をアキュムレータにセットする。 https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L130-L138
// Ldar <src>
//
// Load accumulator with value from register <src>.
IGNITION_HANDLER(Ldar, InterpreterAssembler) {
TNode<Object> value = LoadRegisterAtOperandIndex(0);
SetAccumulator(value);
Dispatch();
}
Add a0, [0]Addは、アキュムレータに対して a0レジスタの値を加算します。
https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L872-L877
// Add <src>
//
// Add register <src> to accumulator.
IGNITION_HANDLER(Add, InterpreterBinaryOpAssembler) {
BinaryOpWithFeedback(&BinaryOpAssembler::Generate_AddWithFeedback);
}
このBinaryOpWithFeedbackは、共通化された関数のため、さまざまなところで使われているものです。
バイトコードごとに特定の操作を表すBinaryOpGeneratorの値を引数に渡し、その値によってレジスターとアキュムレータの値を操作します。
BinaryOpGeneratorの値は下記のコードでまとまっています。
github.com
BinaryOpWithFeedbackの実装を見てみます。
https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L839-L853
void BinaryOpWithFeedback(BinaryOpGenerator generator) {
TNode<Object> lhs = LoadRegisterAtOperandIndex(0);
TNode<Object> rhs = GetAccumulator();
TNode<Context> context = GetContext();
TNode<UintPtrT> slot_index = BytecodeOperandIdx(1);
TNode<HeapObject> maybe_feedback_vector = LoadFeedbackVector();
BinaryOpAssembler binop_asm(state());
TNode<Object> result =
(binop_asm.*generator)([=] { return context; }, lhs, rhs, slot_index,
[=] { return maybe_feedback_vector; },
UpdateFeedbackMode::kOptionalFeedback, false);
SetAccumulator(result);
Dispatch();
}
これによって、2つの引数を加算してアキュムレータに書き込まれました。
- Return
最後にReturnとなります。
Returnは、アキュムレータにある値を返します。
https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L2658-L2665
// Return
//
// Return the value in the accumulator.
IGNITION_HANDLER(Return, InterpreterAssembler) {
UpdateInterruptBudgetOnReturn();
TNode<Object> accumulator = GetAccumulator();
Return(accumulator);
}
subの場合
subの場合は、先程のaddと違うのが、2番目のバイトコードが Sub a0, [0] になっているのが異なります。
それ以外は、基本的にレジスターの値を操作してアキュムレータに結果を書き込み、最後にその値をReturnして返すという大まかな構造は変わりません。
➜ node-sandbox node --print-bytecode --print-bytecode-filter=sub app.js [generated bytecode for function: sub (0x0a9837e37c59 <SharedFunctionInfo sub>)] Bytecode length: 6 Parameter count 3 Register count 0 Frame size 0 OSR nesting level: 0 Bytecode Age: 0 80 S> 0xa9837e38846 @ 0 : 0b 04 Ldar a1 89 E> 0xa9837e38848 @ 2 : 39 03 00 Sub a0, [0] 93 S> 0xa9837e3884b @ 5 : a8 Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 9) 0x0a9837e38851 <ByteArray[9]>
constAddの場合
constAddは、受け取った引数の値と、関数内で宣言している定数を加算して返します。 こちらも、基本的な処理は変わらず、引数をレジスターに読み込み、定数を加算してアキュムレータに書き込み、それを最後にReturnします。
node --print-bytecode --print-bytecode-filter=constAdd app.js
[generated bytecode for function: constAdd (0x18deb9bf7d41 <SharedFunctionInfo constAdd>)]
Bytecode length: 9
Parameter count 2
Register count 1
Frame size 8
OSR nesting level: 0
Bytecode Age: 0
159 S> 0x18deb9bf8aae @ 0 : 0d 64 LdaSmi [100]
0x18deb9bf8ab0 @ 2 : c3 Star0
168 S> 0x18deb9bf8ab1 @ 3 : 0b fa Ldar r0
177 E> 0x18deb9bf8ab3 @ 5 : 38 03 00 Add a0, [0]
181 S> 0x18deb9bf8ab6 @ 8 : a8 Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 11)
0x18deb9bf8ab9 <ByteArray[11]>
add, subと異なるのは、定数の読み込みにLdaSmi を使用しています。
これも紹介していきます。
LdaSmi [100]で定数100をアキュムレータに読み込む 定数を読み込む場合の専用のバイトコードが用意されていて、LdaSmiとなります。 SmiはSmall Integerの略で、ある程度小さい値の整数を扱うものとなります。
下記の実装の通り、オペランドに指定されている数を読み込んで、それをアキュムレータにセットしています。 https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L72-L80
// LdaSmi <imm>
//
// Load an integer literal into the accumulator as a Smi.
IGNITION_HANDLER(LdaSmi, InterpreterAssembler) {
TNode<Smi> smi_int = BytecodeOperandImmSmi(0);
SetAccumulator(smi_int);
Dispatch();
}
Star0でアキュムレータの値を r0レジスターに書き込む ここで 0という数字を使っていますが、この数字は下記の実装でわかるように、レジスターの番号を表します。 この値は、つまりアキュムレータにある値をr0のレジスターに書き込むこととなります。
https://github.com/v8/v8/blob/9.5.173/src/interpreter/interpreter-generator.cc#L148-L160
// Star0 - StarN
//
// Store accumulator to one of a special batch of registers, without using a
// second byte to specify the destination.
//
// Even though this handler is declared as Star0, multiple entries in
// the jump table point to this handler.
IGNITION_HANDLER(Star0, InterpreterAssembler) {
TNode<Object> accumulator = GetAccumulator();
TNode<WordT> opcode = LoadBytecode(BytecodeOffset());
StoreRegisterForShortStar(accumulator, opcode);
Dispatch();
}
これにより、 r0レジスターに定数1000が書き込まれました。
Ldar r0r0レジスターの値をアキュムレータに読み込みます。 値の操作は、アキュムレータにある値をもとに行います。 このあと、引数で指定した値をアキュムレータにある値に加算していきます。 Ldarは、addで前述したように、 オペランドで指定したレジスターの値をアキュムレータに読み込むためのバイトコードです これによって定数1000がアキュムレータに読み込まれました
残りの下記2つについてはaddと同じです。
- Add r0, [0] r0の値をアキュムレータに加算する
- Return アキュムレータの値を関数呼び出しもとに返す。
if文を使ったコードの場合
ここで、長くなるため軽く説明しますが、下記のようにif文を使った場合は、JumpIfToBooleanFalse などを使って、指定した値が条件に一致する場合に特定のコードのアドレスにジャンプするなどの処理が走ります。
function iftest(flg) {
if (flg) {
console.log(1);
} else {
console.log(2)
}
}
iftest(true);
➜ node-sandbox node --print-bytecode --print-bytecode-filter=iftest app.js
[generated bytecode for function: iftest (0x3b78e6677ca9 <SharedFunctionInfo iftest>)]
Bytecode length: 42
Parameter count 2
Register count 3
Frame size 24
OSR nesting level: 0
Bytecode Age: 0
150 S> 0x3b78e6678926 @ 0 : 0b 03 Ldar a0
0x3b78e6678928 @ 2 : 96 15 JumpIfToBooleanFalse [21] (0x3b78e667893d @ 23)
169 S> 0x3b78e667892a @ 4 : 21 00 00 LdaGlobal [0], [0]
0x3b78e667892d @ 7 : c2 Star1
177 E> 0x3b78e667892e @ 8 : 2d f9 01 02 LdaNamedProperty r1, [1], [2]
0x3b78e6678932 @ 12 : c3 Star0
0x3b78e6678933 @ 13 : 0d 01 LdaSmi [1]
0x3b78e6678935 @ 15 : c1 Star2
177 E> 0x3b78e6678936 @ 16 : 5d fa f9 f8 04 CallProperty1 r0, r1, r2, [4]
0x3b78e667893b @ 21 : 89 13 Jump [19] (0x3b78e667894e @ 40)
206 S> 0x3b78e667893d @ 23 : 21 00 00 LdaGlobal [0], [0]
0x3b78e6678940 @ 26 : c2 Star1
214 E> 0x3b78e6678941 @ 27 : 2d f9 01 02 LdaNamedProperty r1, [1], [2]
0x3b78e6678945 @ 31 : c3 Star0
0x3b78e6678946 @ 32 : 0d 02 LdaSmi [2]
0x3b78e6678948 @ 34 : c1 Star2
214 E> 0x3b78e6678949 @ 35 : 5d fa f9 f8 06 CallProperty1 r0, r1, r2, [6]
0x3b78e667894e @ 40 : 0e LdaUndefined
227 S> 0x3b78e667894f @ 41 : a8 Return
Constant pool (size = 2)
0x3b78e66788d1: [FixedArray] in OldSpace
- map: 0x0d95fcb412c1 <Map>
- length: 2
0: 0x3523f1b390b9 <String[7]: #console>
1: 0x3523f1b0c0b1 <String[3]: #log>
Handler Table (size = 0)
Source Position Table (size = 19)
0x3b78e6678951 <ByteArray[19]>
- JumpIfToBooleanFalse [21] もしアキュムレータのオブジェクトがBooleanに変換した結果Falseになる場合、 オペランドに指定したアドレスにジャンプします。 この場合、Falseだったら 21番目のアドレスにジャンプします。
// Jump by the number of bytes represented by the immediate operand |imm|.
IGNITION_HANDLER(Jump, InterpreterAssembler) {
TNode<IntPtrT> relative_jump = Signed(BytecodeOperandUImmWord(0));
Jump(relative_jump);
}
おわりに
v8では、コードの最適化のために、Ignition, TurboFanによってバイトコードの生成や、機械語の最適化などいろいろ内部でやっているようで奥が深いので、今後も深く調べてみようと思います。
今回紹介したバイトコードの確認は比較的簡単にできるので、関数の実装で色々パターンを試してどういうバイトコードに変換されるかをみても面白いかもしれません。