kakts-log

programming について調べたことを整理していきます

v8によって生成されるbytecodeを確認してみる

Qiita Node.js アドベントカレンダー2021の20日目の記事です
qiita.com

v8のbytecodeについて、自分で書いたコードでどうやって確認するか気になったので調べてみました。

v8によるバイトコード生成

v8がどうやってバイトコード機械語を生成するかざっくりと整理します。
v8がJavaScriptコンパイルするとき、パーサはASTを生成します。
ASTはJavascriptコードの文法構造を表現したツリーです。 v8のインタプリタであるIgnitionはこのASTからバイトコードを生成します。
一方で、最適化コンパイラであるTurboFanは最終的にバイトコードを取得し、そのバイトコードから最適化された機械語を生成します。 なぜv8がこの2つの実行モードを持っているかについては、下記の動画で説明されています。

youtu.be

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 <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

  1. 1番目のオペランドの値を取得する
  2. generatorのタイプによって、オペランドの値を加算したり源さんなどしたりして、その結果をアキュムレータにセットする
  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 r0 r0レジスターの値をアキュムレータに読み込みます。 値の操作は、アキュムレータにある値をもとに行います。 このあと、引数で指定した値をアキュムレータにある値に加算していきます。 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によってバイトコードの生成や、機械語の最適化などいろいろ内部でやっているようで奥が深いので、今後も深く調べてみようと思います。

今回紹介したバイトコードの確認は比較的簡単にできるので、関数の実装で色々パターンを試してどういうバイトコードに変換されるかをみても面白いかもしれません。