V8源码入门

2023-04-26 19:06:52 浏览数 (1)

本文所用的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,如下函数断点
代码语言:javascript复制
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;
}

0 人点赞