V8 Iginition Interpreter
以前、東京Node学園25時限目で発表した内容を修正して書いていこうと思う。
というわけで、V8にバイトコードインタープリタ Ignition が搭載された。
このインタープリタは単純そうに見えて非常にわかりづらいので解説していく。
バイトコードインタープリタとは
インタープリタとは、ソースコードを逐次実行する形式のエンジン。
今までのV8はソースコードを即アセンブラにコンパイルし実行していたが、
インタープリタはそれとは違い、一度ソースコードを高レベルなバイト命令に変換し、そのバイト命令を逐次実行していく。
高級アセンブラみたいな感じ。
Ignition概要
Ignitionはレジスタベースのバイトコードインタープリタである。Javaのスタックベースとは違って、CPUのレジスタに実際に値を割り付けて実行する。
IgnitionはBytecodeHandler
と呼ばれるバイトコード処理関数を予め生成しておき、バイトコードから配列のインデックスを取得、
そのインデックスに生成された処理関数を割り当て、Bytecodeの配列を次々巡回して、対応するインデックスの関数を呼び出しコードを実行する。
JSで非常に単純化されたコードを書くと以下の様になる。
var Bytecodes = [0,1,2,3,4,5]; var index = 0; function dispatch(next) {BytecodeHandlers[next]();} const BytecodeHandlers = { ['0']() {...; dispatch(Bytecodes[index++])}, ['1']() {...; dispatch(Bytecodes[index++])}, ['2']() {...; dispatch(Bytecodes[index++])}, ['3']() {...; dispatch(Bytecodes[index++])}, ['4']() {...; dispatch(Bytecodes[index++])}, ['5']() {...; dispatch(Bytecodes[index++])}, }
このモデルを念頭にV8のコードベースを確認していく。
Ignitionの構造
バイトコード生成までの道のり
IgnitionはJavascript ASTからバイトコードを生成する。
このバイトコード生成のステップを確認していく。
BytecodeGenerator
がAstVisitor
を実装しているので、Javascript ASTを巡回しながら対応しているバイトコードを生成していく。
BytecodeGenerator
はsrc/interpreter/bytecode-generator.h
にあり、バイトコード生成メソッドはBytecodeGenerator::GenerateBytecode
である。
さて、BytecodeGenerator::GenerateBytecode
はどこから呼ばれるかというと、InterpreterCompilationJob::ExecuteJobImpl(src/interpreter/interpreter.cc)
内で呼び出される。
InterpreterCompilationJob::ExecuteJobImpl
はstatic Interpreter::NewCompilationJob
で実行される。
Interpreter::NewCompilationJob
の階層は以下のようになっている。
Interpreter::NewCompilationJob | InterpreterCompilationJob::ExecuteJobImpl | BytecodeGenerator::GenerateBytecode
このstatic Interpreter::NewCompilationJob
はコンパイラパイプラインのジョブを生成するメソッドなので、compiler.cc(src/compiler.cc)
を見ていこう。
compiler.cc(src/compiler.cc)
は非常に複雑でわかりづらい呼び出し階層をもっており、さらにオプションの設定パーサーの設定も相まって非常に読みづらい。
static Interpreter::NewCompilationJob
を呼び出すまでのコールスタックは以下の様になっている。
ScriptCompiler::Compile | ScriptCompiler::CompileUnboundInternal | Compiler::GetSharedFunctionInfoForScript | Compiler::CompileToplevel | CompileUnoptimizedCode(compiler.cc) | CompileUnoptimizedInnerFunctions | GenerateUnoptimizedCode | GetUnoptimizedCompilationJob | ---- Iginitionオプションによってfullcodegenと分岐 | | Interpreter::NewCompilationJob | FullCodeGenerator::NewCompilationJob
ScriptCompiler::Compile
がV8のJavascript Compilerのエントリーポイントとなっており、そこから順次関数を呼び出し、最終的にInterpreterのJobを生成する。
最終的なBytecodeGenerator::GenerateBytecode
までの呼び出しコールスタックは以下のようになる。
ScriptCompiler::Compile | ScriptCompiler::CompileUnboundInternal | Compiler::GetSharedFunctionInfoForScript | Compiler::CompileToplevel | CompileUnoptimizedCode(compiler.cc) | CompileUnoptimizedInnerFunctions | GenerateUnoptimizedCode | GetUnoptimizedCompilationJob | ---- Iginitionオプションによってfullcodegenと分岐 | | | FullCodeGenerator::NewCompilationJob | Interpreter::NewCompilationJob | InterpreterCompilationJob::ExecuteJobImpl | BytecodeGenerator::GenerateBytecode
バイトコード生成
さて、呼び出し階層を把握したところで、バイトコードの生成方法を見ていく。
バイトコード生成は先程も書いたとおりAstVisitorを継承しているので、各種Visit***
メソッドを実装する必要がある。
ので、各種Visit***
の実装を見ていけば何をしているか理解できるはず。
ただ、闇雲にコードを見てもバイトコード自体は理解できないので、一旦trace_bytecodeでd8を実行してみる。
var a = 1;
bytecodes
0 [generating bytecode for function: ] 1 Parameter count 1 2 Frame size 32 3 0x3f5e20aafdf6 @ 0 : 09 00 LdaConstant [0] 4 0x3f5e20aafdf8 @ 2 : 1f f9 Star r1 5 0x3f5e20aafdfa @ 4 : 02 LdaZero 6 0x3f5e20aafdfb @ 5 : 1f f8 Star r2 7 0x3f5e20aafdfd @ 7 : 20 fe f7 Mov <closure>, r3 8 0x3f5e20aafe00 @ 10 : 55 aa 01 f9 03 CallRuntime [DeclareGlobalsForInterpreter], r1-r3 9 0 E> 0x3f5e20aafe05 @ 15 : 92 StackCheck 10 116 S> 0x3f5e20aafe06 @ 16 : 09 01 LdaConstant [1] 11 0x3f5e20aafe08 @ 18 : 1f f9 Star r1 12 0x3f5e20aafe0a @ 20 : 02 LdaZero 13 0x3f5e20aafe0b @ 21 : 1f f8 Star r2 14 0x3f5e20aafe0d @ 23 : 03 01 LdaSmi [1] 15 0x3f5e20aafe0f @ 25 : 1f f7 Star r3 16 0x3f5e20aafe11 @ 27 : 55 ab 01 f9 03 CallRuntime [InitializeVarGlobal], r1-r3 17 0x3f5e20aafe16 @ 32 : 04 LdaUndefined 18 118 S> 0x3f5e20aafe17 @ 33 : 96 Return 19 Constant pool (size = 2) 20 0x3f5e20aafda1: [FixedArray] 21 - map = 0x1cfd2a282309 <Map(FAST_HOLEY_ELEMENTS)> 22 - length: 2 23 0: 0x3f5e20aafd71 <FixedArray[4]> 24 1: 0x2315b1a87ef9 <String[1]: a>
そうするとこのような結果が得られる。
さてバイトコードを出力したのはいいが、見方がわからないと意味が無いので、見方も解説。
ここは関数のbytecodeの場合に関数名が入る。今回はグローバルなので空。
0 [generating bytecode for function: ]
これはstackのパラメータの数。
今回のバイトコードはグローバルなので無視。
1 Parameter count 1
FrameSizeは割り当てたレジスタの数 * ポインタのサイズ
ポインタのサイズは大体の環境で、32bitでは4byte、64bitでは8byteになる。
今回の場合、割り当てたレジスタ数の数が4 64bit環境なので、ポインタサイズが8byte
4 * 8 = 32
となる。
2 Frame size 32
各バイト列は
現在のアドレス アドレスのオフセット バイトコードの数値 バイトコードの名前 オペランド
となっている。
3 0x3f5e20aafdf6 @ 0 : 09 00 LdaConstant [0]
ここは定数値プールの中身。
今回は変数名のaがプールされている。
19 Constant pool (size = 2) 20 0x3f5e20aafda1: [FixedArray] 21 - map = 0x1cfd2a282309 <Map(FAST_HOLEY_ELEMENTS)> 22 - length: 2 23 0: 0x3f5e20aafd71 <FixedArray[4]> 24 1: 0x2315b1a87ef9 <String[1]: a>
さてこれらの情報を踏まえて、先程のソースコードとバイトコードを見ていこう。
以下の部分はすっとばしてよい。ここはインタープリタの準備なので。
3 0x3f5e20aafdf6 @ 0 : 09 00 LdaConstant [0] 4 0x3f5e20aafdf8 @ 2 : 1f f9 Star r1 5 0x3f5e20aafdfa @ 4 : 02 LdaZero 6 0x3f5e20aafdfb @ 5 : 1f f8 Star r2 7 0x3f5e20aafdfd @ 7 : 20 fe f7 Mov <closure>, r3 8 0x3f5e20aafe00 @ 10 : 55 aa 01 f9 03 CallRuntime [DeclareGlobalsForInterpreter], r1-r3 9 0 E> 0x3f5e20aafe05 @ 15 : 92 StackCheck
本番はここから
解説はコード中に書いていく。
// 定数プールのインデックス1(変数名a)から値をaccumulatorにロードする。 10 116 S> 0x3f5e20aafe06 @ 16 : 09 01 LdaConstant [1] 11 // accumulator(変数名a)からr1レジスタに値をロードする。 12 0x3f5e20aafe08 @ 18 : 1f f9 Star r1 13 // accumulatorに0をロードする。 14 0x3f5e20aafe0a @ 20 : 02 LdaZero 15 // accumulator(0)からr2レジスタに値をロードする。 16 0x3f5e20aafe0b @ 21 : 1f f8 Star r2 17 // accumulatorに即値1をロードする。 18 0x3f5e20aafe0d @ 23 : 03 01 LdaSmi [1] 19 // accumulator(1)からr3レジスタに値をロードする。 20 0x3f5e20aafe0f @ 25 : 1f f7 Star r3 21 // r1レジスタからr3レジスタの値(a, 0, 1)を使ってInitializeVarGlobalランタイムを呼び出す。 22 0x3f5e20aafe11 @ 27 : 55 ab 01 f9 03 CallRuntime [InitializeVarGlobal], r1-r3 23 // accumulatorにundefinedをセット 24 0x3f5e20aafe16 @ 32 : 04 LdaUndefined 25 // 終了 26 118 S> 0x3f5e20aafe17 @ 33 : 96 Return
これがバイトコードの実行である。
ちなみにCallRuntimeの場合、各Runtime毎に呼び出し規約が決まっているので、それぞれに合わせたレジスタの割り当てが必要になる。
InitializeVarGlobal
ランタイム呼び出しは以下のレジスタを期待している。
- r0 = 束縛される変数名
- r1 = LaunguageMode SLOPPY(通常) STRICT(strictモード) LAUNGUAGE_END(不明)
- r2 = 束縛される値
そのため、上記のコードは
- accumulatorに値をロード
- レジスタに値をロード
を繰り返して、Runtime呼び出しのコードを生成している。
とこの調子でIgnitionはバイトコードを実行していくが、
そのバイトコードを実行しているのはBytecodeHandler
とよばれるクラスである。
バイトコード実行
BytecodeHandler
バイトコードの実行はBytecodeHandler
によって行われる。
このBytecodeHandler
はV8の初期化時に生成され、配置される。
以下がBytecodeHandler
の例である。
IGNITION_HANDLER(LdaZero, InterpreterAssembler) {
Node* zero_value = NumberConstant(0.0);
SetAccumulator(zero_value);
Dispatch();
}
LadZeroの処理を行うBytecoeHandler
で、中では単純にaccumulatorに0をセットするだけ。
このような調子で各バイトコードにつき一つのBytecodeHandler
が実装されている。
各BytecodeHandler
は直接次のBytecodeHandler
を呼び出す。
このDispatchが次のBytecodeHandler
を呼び出している。
Dispatch();
しかし、このBytecodeHandler
の実装をみるとわかるのだが、BytecodeHandlerはあくまで、
実行予定Nodeを組み立てているだけで、実際には何かを実行するわけではない。
Ignitionインタープリタは最初にBytecodeの処理手順をグラフノードで生成し、生成したグラフからマシンコードを生成する。
これをBytecodeのdispatch-tableに設定することで、各バイトコード毎に行う処理が設定されたBytecodeHandler
が実装される。
以下の図はBytecodeHandlerの生成
InterpreterEntryTrampoline
Ignitionは最終的にBytecodeArray
を生成し終わった後に、
InterpreterEntryTrampoline
というbuiltinsからIgnitionのDispatchTableを発火するコード生成し、
BytecodeArrayからバイトコードを取り出し、対応するDispatchTableの処理を実行して回っていく。
以下の図はIgnitionが実行される様子
まとめ
一通りIgnitionの実行パスを眺めた。
また、Ignitionのバイトコードがアセンブラコードのキーとして振る舞い、
実際にはベースラインで生成されたコードが実行されている事を確認した。
TurboFan経由の最適化部分等については今後の記事を書く予定。