本文所用的V8版本为9.4.146.24,源码层面分析builtin、Ignition、Sparkplug、TurboFan。
builtin
builtin是理解V8源码的关键,因为
- 它本身很重要,是V8最重要的“积木块”;比如ignition解析器每一条指令实现就是一个builtin,js调用原生也是一个builtin,js的很多内置函数(比如Array.prototype.join)也是一个builtin。
- 它很难懂,因为大多数builtin的“源码”,其实是builtin的生成逻辑
对于第二点,举个例子,很多介绍Ignition的文章会告诉你Ldar指令的实现如下:
代码语言:javascript复制IGNITION_HANDLER(Ldar, InterpreterAssembler) {
TNode<Object> value = LoadRegisterAtOperandIndex(0);
SetAccumulator(value);
Dispatch();
}
也确实是,但问题上述代码运行时不会跑,(9.4版本)甚至都不会编译到运行时,这就很让人困惑。
其实上述逻辑只在V8的编译阶段由mksnapshot程序执行,在该进程先通过jit产出机器码,然后dump下来放到汇编文件genembedded.S里(在window下会以inline asm放到c 文件genhttp://embedded.cc里),再重新编译到V8库(相当于用jit编译器去AOT)。
上述ldar指令dump到genembedded.S后会这样子:
代码语言:javascript复制Builtins_LdarHandler:
.def Builtins_LdarHandler; .scl 2; .type 32; .endef;
.octa 0x72ba0b74d93b48fffffff91d8d48,0xec83481c6ae5894855ccffa9104ae800
.octa 0x2454894cf0e4834828ec8348e2894920,0x458948e04d894ce87d894cf065894c20
.octa 0x4d0000494f808b4500001410858b4dd8,0x1640858b49e1894c00000024bac603
.octa 0x4d00000000158d4ccc01740fc4f64000,0x2045c749d0ff206d8949285589
.octa 0xe4834828ec8348e289492024648b4800,0x808b4500001410858b4d202454894cf0
.octa 0x858b49d84d8b48d233c6034d00004953,0x158d4ccc01740fc4f64000001640
.octa 0x2045c749d0ff206d89492855894d0000,0x5d8b48f0658b4c2024648b4800000000
.octa 0x4cf7348b48007d8b48011c74be0f49e0,0x100000000ba49211cb60f43024b8d
.octa 0xa90f4fe800000002ba0b77d33b4c0000,0x8b48006d8b48df0c8b49e87d8b4cccff
.octa 0xcccccccccccccccc90e1ff30c48348c6
.byte 0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc
builtin在这文件定义:v8srcbuiltinsbuiltins-definitions.h,这个文件还还include一个根据ignition指令生成的builtin定义以及torque编译器生成的builtin定义,一共1700 个builtin。每个builtin,都在genembedded.S那生成一段代码。
builtin的生成
builtin的生成位于这个文件:v8srcbuiltinshttp://setup-builtins-internal.cc
代码语言:javascript复制void SetupIsolateDelegate::SetupBuiltinsInternal(Isolate* isolate) {
Builtins* builtins = isolate->builtins();
DCHECK(!builtins->initialized_);
PopulateWithPlaceholders(isolate);
// Create a scope for the handles in the builtins.
HandleScope scope(isolate);
int index = 0;
Code code;
#define BUILD_CPP(Name)
code = BuildAdaptor(isolate, Builtin::k##Name,
FUNCTION_ADDR(Builtin_##Name), #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_TFJ(Name, Argc, ...)
code = BuildWithCodeStubAssemblerJS(
isolate, Builtin::k##Name, &Builtins::Generate_##Name, Argc, #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_TFC(Name, InterfaceDescriptor)
/* Return size is from the provided CallInterfaceDescriptor. */
code = BuildWithCodeStubAssemblerCS(
isolate, Builtin::k##Name, &Builtins::Generate_##Name,
CallDescriptors::InterfaceDescriptor, #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_TFS(Name, ...)
/* Return size for generic TF builtins (stub linkage) is always 1. */
code = BuildWithCodeStubAssemblerCS(isolate, Builtin::k##Name,
&Builtins::Generate_##Name,
CallDescriptors::Name, #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_TFH(Name, InterfaceDescriptor)
/* Return size for IC builtins/handlers is always 1. */
code = BuildWithCodeStubAssemblerCS(
isolate, Builtin::k##Name, &Builtins::Generate_##Name,
CallDescriptors::InterfaceDescriptor, #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_BCH(Name, OperandScale, Bytecode)
code = GenerateBytecodeHandler(isolate, Builtin::k##Name, OperandScale,
Bytecode);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
#define BUILD_ASM(Name, InterfaceDescriptor)
code = BuildWithMacroAssembler(isolate, Builtin::k##Name,
Builtins::Generate_##Name, #Name);
AddBuiltin(builtins, Builtin::k##Name, code);
index ;
BUILTIN_LIST(BUILD_CPP, BUILD_TFJ, BUILD_TFC, BUILD_TFS, BUILD_TFH, BUILD_BCH,
BUILD_ASM);
#undef BUILD_CPP
#undef BUILD_TFJ
#undef BUILD_TFC
#undef BUILD_TFS
#undef BUILD_TFH
#undef BUILD_BCH
#undef BUILD_ASM
// ...
}
BUILTIN_LIST宏内定义了所有的builtin,并根据其类型去调用不同的参数,在这里参数是BUILD_CPP, BUILD_TFJ...这些,定义了不同的生成策略,这些参数去掉前缀代表不同的builtin类型(CPP, TFJ, TFC, TFS, TFH, BCH, ASM)
builtin的生成逻辑有两种:
- 直接生成机器码,ASM和CPP类型builtin使用这种方式(CPP类型只是生成适配器,下面会详细说)
- 先生成turbofan的graph(IR),然后由turbofan编译器编译到机器码,除ASM和CPP之外其它builtin类型都是这种
builtin如何dump到genembedded.S
要找到这段逻辑比较简单,genembedded.S开头有些注释,比如这段:Autogenerated file. Do not edit, 你在V8源码搜索这段文字即可,这段dump逻辑比较简单,这里就不再赘述。
ASM类型builtin
找了个比较简单ASM类型builtin:DoubleToI,功能是把double转成整数,该builtin的jit逻辑位于Builtins::Generate_DoubleToI,如果是x64的window,该函数放在http://builtins-x64.cc文件。由于每个CPU架构的指令都不一样,所以每个CPU架构都有一个实现,放在各自的http://builtins-ArchName.cc文件。
x64的实现如下:
代码语言:javascript复制void Builtins::Generate_DoubleToI(MacroAssembler* masm) {
Label check_negative, process_64_bits, done;
// Account for return address and saved regs.
const int kArgumentOffset = 4 * kSystemPointerSize;
MemOperand mantissa_operand(MemOperand(rsp, kArgumentOffset));
MemOperand exponent_operand(
MemOperand(rsp, kArgumentOffset kDoubleSize / 2));
// The result is returned on the stack.
MemOperand return_operand = mantissa_operand;
Register scratch1 = rbx;
// Since we must use rcx for shifts below, use some other register (rax)
// to calculate the result if ecx is the requested return register.
Register result_reg = rax;
// Save ecx if it isn't the return register and therefore volatile, or if it
// is the return register, then save the temp register we use in its stead
// for the result.
Register save_reg = rax;
__ pushq(rcx);
__ pushq(scratch1);
__ pushq(save_reg);
__ movl(scratch1, mantissa_operand);
__ Movsd(kScratchDoubleReg, mantissa_operand);
__ movl(rcx, exponent_operand);
__ andl(rcx, Immediate(HeapNumber::kExponentMask));
__ shrl(rcx, Immediate(HeapNumber::kExponentShift));
__ leal(result_reg, MemOperand(rcx, -HeapNumber::kExponentBias));
__ cmpl(result_reg, Immediate(HeapNumber::kMantissaBits));
__ j(below, &process_64_bits, Label::kNear);
// Result is entirely in lower 32-bits of mantissa
int delta =
HeapNumber::kExponentBias base::Double::kPhysicalSignificandSize;
__ subl(rcx, Immediate(delta));
__ xorl(result_reg, result_reg);
__ cmpl(rcx, Immediate(31));
__ j(above, &done, Label::kNear);
__ shll_cl(scratch1);
__ jmp(&check_negative, Label::kNear);
__ bind(&process_64_bits);
__ Cvttsd2siq(result_reg, kScratchDoubleReg);
__ jmp(&done, Label::kNear);
// If the double was negative, negate the integer result.
__ bind(&check_negative);
__ movl(result_reg, scratch1);
__ negl(result_reg);
__ cmpl(exponent_operand, Immediate(0));
__ cmovl(greater, result_reg, scratch1);
// Restore registers
__ bind(&done);
__ movl(return_operand, result_reg);
__ popq(save_reg);
__ popq(scratch1);
__ popq(rcx);
__ ret(0);
}
看上去很像汇编(编程的思考方式按汇编来),实际上是c 函数,比如这行movl
代码语言:javascript复制__ movl(scratch1, mantissa_operand);
__是个宏,实际上是调用masm变量的函数(movl)
代码语言:javascript复制#define __ ACCESS_MASM(masm)
#define ACCESS_MASM(masm) masm->
而movl的实现是往pc_指针指向的内存写入mov指令及其操作数,并把pc_指针前进指令长度。
ps:一条条指令写下来,然后把内存权限改为可执行,这就是jit的基本原理。
比较有意思的是往后面的指令跳转的实现,比如这行:
代码语言:javascript复制__ jmp(&check_negative, Label::kNear);
调用jmp时目标指令的offset还未知呢,于是先在Label记录下这个跳转指令,如果一个Lable在bind前有多个跳转,会利用跳转指令的待定操作数串成一个链表,当调用bind的时候,回填这些待定操作数。
CPP类型builtin
CPP类型builtin使用“真.C ”编写,不过在SetupBuiltinsInternal那仍然要生成一个适配器放到genembedded.S。
如下是x86的适配器生成逻辑,十分简单,先加载C 函数地址到kJavaScriptCallExtraArg1Register,然后跳转TFC类型的builtin:AdaptorWithBuiltinExitFrame
代码语言:javascript复制void Builtins::Generate_Adaptor(MacroAssembler* masm, Address address) {
__ LoadAddress(kJavaScriptCallExtraArg1Register,
ExternalReference::Create(address));
__ Jump(BUILTIN_CODE(masm->isolate(), AdaptorWithBuiltinExitFrame),
RelocInfo::CODE_TARGET);
}
AdaptorWithBuiltinExitFrame会检查参数,然后调用一个ASM类型的builtin:CEntry_Return1_DontSaveFPRegs_ArgvOnStack_BuiltinExit,CEntry_Return1_DontSaveFPRegs_ArgvOnStack_BuiltinExit主要是取参数按各平台的ABI去调用C 实现的builtin逻辑。
HandleApiCall是实现FunctionTemplate原生扩展的关键,就是一个CPP类型builtin
代码语言:javascript复制BUILTIN(HandleApiCall) {
HandleScope scope(isolate);
Handle<JSFunction> function = args.target();
Handle<Object> receiver = args.receiver();
Handle<HeapObject> new_target = args.new_target();
Handle<FunctionTemplateInfo> fun_data(function->shared().get_api_func_data(),
isolate);
if (new_target->IsJSReceiver()) {
RETURN_RESULT_OR_FAILURE(
isolate, HandleApiCallHelper<true>(isolate, function, new_target,
fun_data, receiver, args));
} else {
RETURN_RESULT_OR_FAILURE(
isolate, HandleApiCallHelper<false>(isolate, function, new_target,
fun_data, receiver, args));
}
}
BUILTIN是一个宏,定义了上述逻辑的对外接口函数。对外接口函数会被CEntry_Return1_DontSaveFPRegs_ArgvOnStack_BuiltinExit调用。
其它类型builtin
除了ASM和CPP的其它类型builtin都通过调用CodeStubAssembler API(下称CSA)编写,这套API和之前介绍ASM类型builtin时提到的“类汇编API”类似,不同的是“类汇编API”直接产出原生代码,CSA产出的是turbofan的graph(IR)。
CSA比起“类汇编API”的好处是不用每个平台各写一次。
尽管如此,和汇编类似的CSA还是太低级了,写起来太废功夫了,于是V8提供了一个类javascript的高级语言:torque ,这语言最终会编译成CSA形式的c 代码和V8其它C 代码一起编译。
Ignition
Ignition的指令
指令的定义位于:v8srcinterpreterbytecodes.h
指令的实现(生成逻辑)位于:v8srcinterpreterhttp://interpreter-generator.cc
以最简单的LdaZero指令为例:
代码语言:javascript复制IGNITION_HANDLER(LdaZero, InterpreterAssembler) {
TNode<Number> zero_value = NumberConstant(0.0);
SetAccumulator(zero_value); //把0设置倒累加器
Dispatch(); //执行下一条指令
}
NumberConstant,SetAccumulator,Dispatch都是基于CSA封装,本质上也是CSA。
再次强调下CSA并不是真正的操作,可以认为这是LdaZero指令的生成器,实际Ignition虚拟机运行的时候不会跑到这段逻辑。
我们可以断点验证下:
- 设置mksnapshot为启动项目
- 断点在上面的LdaZero指令生成逻辑的第一行
断点触发后,再到turbofan graph节点创建的地方(位于v8srccompilerhttp://node.cc的Node::NewImpl函数)下断点。然后运行,然后我们可以观测到这小段代码执行过程中的graph节点创建。
Ignition的运行
指令间的衔接
Ignition的指令间的衔接看上述Dispatch的逻辑
- DispatchTable是个数组:DispatchTable[Bytecode]即是Bytecode指令的builtin原生代码入口
- 取DispatchTable[Next-Bytecode]后,尾调用过去
ps:一个解析器的实现并不复杂,定义好指令和相应的操作,然后某种方式(比如lua的while switch)一条条指令执行相应的操作即可。
函数入口
一个函数的第一个指令的builtin并不是第一个执行的builtin,因为还需要诸如一些当前函数帧初始化的操作,这些操作在这几个这几个ASM类型的builtin里:JSEntry、JSEntryTrampoline、InterpreterEntryTrampoline
Sparkplug
Sparkplug是v9.1加入的(jit)编译器,在此之前已经有一个(jit)编译器TurboFan了,为啥还要加一个呢?它们的区别是什么呢?让我们带着这疑问往下看
断点
- 设置d8为启动项目
- 在v8srcbaselinehttp://baseline-compiler.cc,如下函数断点
void BaselineCompiler::VisitSingleBytecode()
ps:类名为什么叫BaselineCompiler而不是SparkplugCompiler?不知道,但整个http://baseline-compiler.cc是被#if ENABLE_SPARKPLUG宏框住的,这块应该是Sparkplug的实现。
在d8窗口输入如下代码可以触发Sparkplug编译
代码语言:javascript复制function add(x, y) {return x y}
for(var i = 0; i< 100;i ) add(1, 2) //循环调用add,直到触发Sparkplug
for(var i = 0; i< 100;i ) add(1, 2)
for(var i = 0; i< 100;i ) add(1, 2)
for(var i = 0; i< 100;i ) add(1, 2)
for(var i = 0; i< 100;i ) add(1, 2)
for(var i = 0; i< 100;i ) add(1, 2) //触发Sparkplug
BaselineCompiler::VisitSingleBytecode针对每条Ignition指令用宏生成switch-case,每个case根据Ignition指令调用对应的Visit函数,Visit函数里会生成对应builtin的调用(对于每一个Ignition指令,在Sparkplug有对应的另外一个builtin)。
以kAdd为例,调用的是VisitAdd,而VisitAdd会生成对TFC类型的Add_Baseline builtin的调用。
Add_Baseline builtin的生成逻辑在v8srcbuiltinshttp://builtins-number-gen.cc的63行
对比Ignition的builtin的生成逻辑(v8srcinterpreterhttp://interpreter-generator.cc的870行),它们俩部分逻辑是重用的,核心区别是Ignition最后面是Dispatch,尾调用下一条指令,而Sparkplug则是return。
结论:
- Sparkplug比较简单粗暴:针对ignition指令(jit)生成一个个call指令,调用对应的builtin,相比ignition,省掉了加载指令地址然后尾调用的过程
- 好处是没有优化过程,所以编译开销小
- 坏处也是没有优化过程,生成的代码比turbofan性能差
所以Sparkplug的意图是在Ignition和TurboFan间加入一个更中庸的方案,它编译开销比TurboFan开销小,还不是十分热点的地方也可以用,而TurboFan编译出来的代码跑得快,但它编译的开销大,所以更热点的地方才执行TurboFan编译。
TurboFan
builtin是V8实现的重要积木块,而这些积木块大多是TurboFan编译的,包括Ignition和Sparkplug的指令实现也是用TurboFan编译的builtin。同时TurboFan也是V8高性能的关键,其重要性不言而喻。
本文只是简单讲下个整体的处理框架:一次TurboFan编译抽象为一个Pipeline,Pipeline有一个个Phase,这些Phase大致分为四部分:
- Graph(IR)生成:这Phase有个遍历Ignition指令,生成Graph的过程
- 对Graph优化,比如inline,死代码消除之类
- 优化后的Graph转CPU架构相关的中间指令
- 上一步的中间指令转原生代码
Ignition指令 -> TurboFan Graph Node
在创建节点处断点 F:v8v8srccompilerhttp://node.cc
代码语言:javascript复制template <typename NodePtrT>
Node* Node::NewImpl(Zone* zone, NodeId id, const Operator* op, int input_count,
NodePtrT const* inputs, bool has_extensible_inputs)
如下js代码可以触发TurboFan编译
代码语言:javascript复制function add(x, y) {return x y}
for(var i = 0; i< 10000000;i ) add(1, 2)
ps:触发了Sparkplug,上述代码仍然会触发TurboFan编译,但如果没之前的小规模调用,一上来就10000000循环,会直接触发TurboFan,不会再触发Sparkplug。
断点触发后可以看到创建TurboFan Graph的逻辑在BytecodeGraphBuilder::CreateGraph
里面对VisitBytecodes就是根据Ignition指令创建Graph对应的节点的地方。这块和Sparkplug是类似的。
Graph优化
前一章的断点堆栈上,BytecodeGraphBuilder::CreateGraph往前还有一个PipelineImpl::CreateGraph栈帧。
通过PipelineImpl::CreateGraph的代码可以看到Ignition指令转TurboFan Graph Node是其中一个Phase:GraphBuilderPhase,还有和优化相关的Phase,比如InliningPhase,而更多优化Phase在这里:PipelineImpl::OptimizeGraph
TurboFan Graph -> 中间指令(ArchOpcode)
优化完毕的Graph,不会直接转原生代码,而是先转到一个更底层的,和CPU架构相关的指令。我们还是通过断点来观测这个行为。
去掉前面的断点,在v8srccompilerbackendhttp://instruction-selector.cc如下代码下断点,点击“继续”
代码语言:javascript复制Instruction* InstructionSelector::Emit(Instruction* instr) {
instructions_.push_back(instr);
return instr;
}
主要的转换流程在InstructionSelector::SelectInstructions,也不难懂,这就不展开说了。
中间指令(ArchOpcode)-> 原生代码
核心逻辑在CodeGenerator::AssembleArchInstruction,选择一个常用的OpCode下断点
去掉前面的断点,在v8srccompilerbackendx64http://code-generator-x64.cc如下代码下断点,点击“继续”
代码语言:javascript复制CodeGenerator::CodeGenResult CodeGenerator::AssembleArchInstruction(
Instruction* instr) {
// ...
case kArchRet:
AssembleReturn(instr->InputAt(0));
break;
}