本文所用的V8版本为9.4.146.24,源码层面分析builtin、Ignition、Sparkplug、TurboFan。
builtin是理解V8源码的关键,因为
对于第二点,举个例子,很多介绍Ignition的文章会告诉你Ldar指令的实现如下:
IGNITION_HANDLER(Ldar, InterpreterAssembler) {
TNode<Object> value = LoadRegisterAtOperandIndex(0);
SetAccumulator(value);
Dispatch();
}
也确实是,但问题上述代码运行时不会跑,(9.4版本)甚至都不会编译到运行时,这就很让人困惑。
其实上述逻辑只在V8的编译阶段由mksnapshot程序执行,在该进程先通过jit产出机器码,然后dump下来放到汇编文件gen\embedded.S里(在window下会以inline asm放到c++文件gen\http://embedded.cc里),再重新编译到V8库(相当于用jit编译器去AOT)。
上述ldar指令dump到gen\embedded.S后会这样子:
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在这文件定义:v8\src\builtins\builtins-definitions.h,这个文件还还include一个根据ignition指令生成的builtin定义以及torque编译器生成的builtin定义,一共1700+个builtin。每个builtin,都在gen\embedded.S那生成一段代码。
builtin的生成位于这个文件:v8\src\builtins\http://setup-builtins-internal.cc
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的生成逻辑有两种:
要找到这段逻辑比较简单,gen\embedded.S开头有些注释,比如这段:Autogenerated file. Do not edit, 你在V8源码搜索这段文字即可,这段dump逻辑比较简单,这里就不再赘述。
找了个比较简单ASM类型builtin:DoubleToI,功能是把double转成整数,该builtin的jit逻辑位于Builtins::Generate_DoubleToI,如果是x64的window,该函数放在http://builtins-x64.cc文件。由于每个CPU架构的指令都不一样,所以每个CPU架构都有一个实现,放在各自的http://builtins-ArchName.cc文件。
x64的实现如下:
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
__ movl(scratch1, mantissa_operand);
__是个宏,实际上是调用masm变量的函数(movl)
#define __ ACCESS_MASM(masm)
#define ACCESS_MASM(masm) masm->
而movl的实现是往pc_指针指向的内存写入mov指令及其操作数,并把pc_指针前进指令长度。
ps:一条条指令写下来,然后把内存权限改为可执行,这就是jit的基本原理。
比较有意思的是往后面的指令跳转的实现,比如这行:
__ jmp(&check_negative, Label::kNear);
调用jmp时目标指令的offset还未知呢,于是先在Label记录下这个跳转指令,如果一个Lable在bind前有多个跳转,会利用跳转指令的待定操作数串成一个链表,当调用bind的时候,回填这些待定操作数。
CPP类型builtin使用“真.C++”编写,不过在SetupBuiltinsInternal那仍然要生成一个适配器放到gen\embedded.S。
如下是x86的适配器生成逻辑,十分简单,先加载C++函数地址到kJavaScriptCallExtraArg1Register,然后跳转TFC类型的builtin:AdaptorWithBuiltinExitFrame
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
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调用。
除了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++代码一起编译。
指令的定义位于:v8\src\interpreter\bytecodes.h
指令的实现(生成逻辑)位于:v8\src\interpreter\http://interpreter-generator.cc
以最简单的LdaZero指令为例:
IGNITION_HANDLER(LdaZero, InterpreterAssembler) {
TNode<Number> zero_value = NumberConstant(0.0);
SetAccumulator(zero_value); //把0设置倒累加器
Dispatch(); //执行下一条指令
}
NumberConstant,SetAccumulator,Dispatch都是基于CSA封装,本质上也是CSA。
再次强调下CSA并不是真正的操作,可以认为这是LdaZero指令的生成器,实际Ignition虚拟机运行的时候不会跑到这段逻辑。
我们可以断点验证下:
断点触发后,再到turbofan graph节点创建的地方(位于v8\src\compiler\http://node.cc的Node::NewImpl函数)下断点。然后运行,然后我们可以观测到这小段代码执行过程中的graph节点创建。
Ignition的指令间的衔接看上述Dispatch的逻辑
ps:一个解析器的实现并不复杂,定义好指令和相应的操作,然后某种方式(比如lua的while + switch)一条条指令执行相应的操作即可。
一个函数的第一个指令的builtin并不是第一个执行的builtin,因为还需要诸如一些当前函数帧初始化的操作,这些操作在这几个这几个ASM类型的builtin里:JSEntry、JSEntryTrampoline、InterpreterEntryTrampoline
Sparkplug是v9.1加入的(jit)编译器,在此之前已经有一个(jit)编译器TurboFan了,为啥还要加一个呢?它们的区别是什么呢?让我们带着这疑问往下看
断点
void BaselineCompiler::VisitSingleBytecode()
ps:类名为什么叫BaselineCompiler而不是SparkplugCompiler?不知道,但整个http://baseline-compiler.cc是被#if ENABLE_SPARKPLUG宏框住的,这块应该是Sparkplug的实现。
在d8窗口输入如下代码可以触发Sparkplug编译
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的生成逻辑在v8\src\builtins\http://builtins-number-gen.cc的63行
对比Ignition的builtin的生成逻辑(v8\src\interpreter\http://interpreter-generator.cc的870行),它们俩部分逻辑是重用的,核心区别是Ignition最后面是Dispatch,尾调用下一条指令,而Sparkplug则是return。
结论:
所以Sparkplug的意图是在Ignition和TurboFan间加入一个更中庸的方案,它编译开销比TurboFan开销小,还不是十分热点的地方也可以用,而TurboFan编译出来的代码跑得快,但它编译的开销大,所以更热点的地方才执行TurboFan编译。
builtin是V8实现的重要积木块,而这些积木块大多是TurboFan编译的,包括Ignition和Sparkplug的指令实现也是用TurboFan编译的builtin。同时TurboFan也是V8高性能的关键,其重要性不言而喻。
本文只是简单讲下个整体的处理框架:一次TurboFan编译抽象为一个Pipeline,Pipeline有一个个Phase,这些Phase大致分为四部分:
在创建节点处断点 F:\v8\v8\src\compiler\http://node.cc
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编译
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是类似的。
前一章的断点堆栈上,BytecodeGraphBuilder::CreateGraph往前还有一个PipelineImpl::CreateGraph栈帧。
通过PipelineImpl::CreateGraph的代码可以看到Ignition指令转TurboFan Graph Node是其中一个Phase:GraphBuilderPhase,还有和优化相关的Phase,比如InliningPhase,而更多优化Phase在这里:PipelineImpl::OptimizeGraph
优化完毕的Graph,不会直接转原生代码,而是先转到一个更底层的,和CPU架构相关的指令。我们还是通过断点来观测这个行为。
去掉前面的断点,在v8\src\compiler\backend\http://instruction-selector.cc如下代码下断点,点击“继续”
Instruction* InstructionSelector::Emit(Instruction* instr) {
instructions_.push_back(instr);
return instr;
}
主要的转换流程在InstructionSelector::SelectInstructions,也不难懂,这就不展开说了。
核心逻辑在CodeGenerator::AssembleArchInstruction,选择一个常用的OpCode下断点
去掉前面的断点,在v8\src\compiler\backend\x64\http://code-generator-x64.cc如下代码下断点,点击“继续”
CodeGenerator::CodeGenResult CodeGenerator::AssembleArchInstruction(
Instruction* instr) {
// ...
case kArchRet:
AssembleReturn(instr->InputAt(0));
break;
}