澳门新葡亰平台官网深入剖析 JavaScriptCore

by admin on 2020年4月25日

谷歌发布了新的JavaScript解释器Ignition,Chrome
53将在一些内存有限的Android设备上使用它。

理解JavaScript的工作原理是写出高效JavaScript代码的关键。忘记那些无关紧要的毫秒级改进:错误地使用对象属性可能导致简单的一行代码速度降低7倍。

最近开始涉及 JS
的解析和处理工作,所以专门研究了下这块。特别是动态类型的处理以及不同引擎对于平台无关的字节码的设计和处理会有很大的帮助。

谷歌资深软件工程师Ross
McIlroy在一篇博文中探讨了构建Ignition的原因。

考虑到JavaScript在软件堆栈所有级别中的普遍性,即使不是所有级别的基础设施,也可能会出现微不足道的减速,而不仅仅是网站的菜单动画。有许多的方法来编写高效的JavasScript代码,但在这篇文章里面,我们将着重介绍编译器友好的优化方法,这意味着源代码使编译器优化变得简单有效。我们将把讨论范围缩小到V8,即支持electron、node.js和google
chrome的JavaScript引擎。为了理解编译器友好的优化,我们首先需要讨论JavaScript是如何编译的。

JavaScriptCore 是 JavaScript
引擎,通常会被叫做虚拟机,专门设计来解释和执行 JavaScript 代码。最开始的
JavaScriptCore 是从 KJS(KDE 的 JavaScript 引擎)以及 PCRE
正则表达式的基础上开发的,是基于抽象语法树的解释器。2008 年重写了,叫做
SquirrelFish,后来是 SquirrelFish Extreme,又叫 Nitro。目前 JavaScript
引擎还有 Google 的 V8 ,Mozilla 的 SpiderMonkey。

V8目前没有解释器。初始执行的JavaScript代码最先被解析,并编译成一个未优化的状态。解析器和编译器的协同工作机制会导致部分代码后续
解析两次、甚至是三次。总之,按照谷歌的说法,V8将33%的页面启动时间用在了解析和编译代码上。这也导致了大量的内存开销。

JavaScript在V8中的执行可以分为三个阶段:

JavaScriptCore 还能够在 Objective-C 程序中来执行 JavaScript
的代码,也可以在 JavaScript 环境中插入自定义对象。

通过使用解释器,谷歌希望减少当前系统的内存使用。Ignition还是依赖于一个解析器,但JavaScript被编译成字节码,而不是原生代
码。McIlroy表示,“我们[使用Ignition]将代码编译成简洁的字节码,而不是编译成机器码,大大减少了结果代码。因此,我们可以减少系统的
内存使用,对于不常运行的函数而言尤其如此。”

源代码到抽象语法树:解析器将源代码生成抽象语法树(AST)抽象语法树到字节码:V8的解释器Ignition从抽象语法树生成字节码。请注意,生成字节码这一步在2017年以前是没有的。字节码到机器码:V8的编译器TurboFan从字节码生成一个图,用高度优化的机器代码替换字节码的部分。

下面是解析 JavaScript 源码解析编译的详细流程图:

由于字节码较小,所以可以编译全部源代码,而不用避免编译未使用的代码。也就是说,脚本只需要解析一次,而不是像当前的编译过程那样解析多次。

第一个阶段超出了本文的范围,但是第二个和第三个阶段对编写优化的JavaScript有直接的影响。

澳门新葡亰平台官网 101.png

McIlroy还提供了一些技术细节:

我们将讨论这些优化方法以及代码如何利用这些优化。通过了解JavaScript执行的基础知识,您不仅可以理解这些性能方面的建议,还可以学习如何发现自己的一些优化点。

  • Lexer:词法分析器,生成 tokens,大部分代码都在 parser/Lexer.cpp 里。

  • Parser:语法分析,基于 Lexer 的 tokens 生成语法树。手写了个 recusive
    descent parser 递归下降解析器,代码主要在 parser/Parser.cpp 里。

  • LLInt:Low Level Interpreter 执行 Parser 生成的 Byte code。代码在
    llint/ 里,使用汇编,在 offlineasm/ 里,可以编译为 x86 和 ARMv7
    的汇编和 C 代码。LLInt
    希望达成除了词法和语法分析外零启动消耗,同时遵守用 JIT
    在调用,堆栈和起存器的约定。

  • Baseline JIT:实时编译,性能不好用这个。在函数调用了 6
    次,或者某段代码循环了大于100次会被触发。BaseLine JIT 的代码在 jit/
    里。BaseLine JIT
    还对几乎所有堆的访问执行了复杂的多态内联高速缓存(Polymorphic inline
    caches)。多态内联缓存是 Smalltalk 社区优化动态分发的一个经典技术。

  • DFG JIT:低延迟优化
    JIT,更差性能就用这个生成更优化的机器码来执行。在函数被调用了60次或者代码循环了1000次会触发。在
    LLInt 和 Baseline JIT
    中会收集一些包括最近参数,堆以及返回值中的数据等轻量级的性能信息,方便
    DFG 进行类型判断。先获取类型信息可以减少大量的类型检查,推测失败 DFG
    会取消优化,也叫 OSR
    exit。取消可以是同步的也可以是异步的。取消后会回到 Baseline
    JIT,退回一定次数会进行重新优化,收集更多统计信息,看情况再次调用
    DFG。重新优化使用的是指数式回退策略应对一些怪异的代码。DFG 代码在
    dfg/ 里。

  • FTL:高吞吐量优化 JIT,全称 Faster Than Light,DFG 高层优化配合 B3
    底层优化。以前全称是 Fourth Tier LLVM 底层优化使用的是 LLVM。B3 对
    LLVM 做了裁剪,对 JavaScriptCore 做了特性处理,B3 IR 的接口和 LLVM
    IR 很类似。B3 对 LLVM 的替换主要是考虑减少内存开销,LLVM
    主要是针对编译器,编译器在这方面优化动力必然没有 JIT 需求高。B3 IR
    将指针改成了更紧凑的整数来表示引用关系。不可变的常用的信息使用固定大小的结构来表示,其它的都放到另外的地方。紧凑的数据放到数组中,更多的数组更少的链表。这样形成的
    IR 更省内存。Filip Pizlo 主导的这个改动,DFG JIT
    也是他弄的,为了能够更多的减少内存上的开销,他利用在 DFG 里已经做的
    InsertionSet 将 LLVM IR 里的 def-use
    干掉了,大概思路是把单向链表里批量插入新 IR 节点先放到 InsertionSet
    里,在下次遍历 IR 时再批量插入。Filip Pizlo 还把 DFG 里的
    UpsilonValue 替代 LLVM SSA 组成部分。B3 后面会把 LLVM
    的寄存器分配算法 Greedy 一直到 B3 中。

Ignition解释器使用低级的、体系结构无关的TurboFan宏汇编指令为每个操作码生成字节码处理程序。TurboFan将这些指令编
译成目标平台的代码,并在这个过程中执行低级的指令选择和机器寄存器分配。Ignition是一个寄存器机,每个字节码都将其输入和输出指定为显式寄存器
寻址;它不是一个栈式机,每个字节码消费输入,并把输出推送到一个隐式栈上。

实际上,第二和第三阶段是紧密耦合的。这两个阶段在即时范式中运行。为了理解JIT的重要性,我们将研究以前将源代码转换为机器代码的方法。

执行速度的对比:

其他浏览器供应商已经将使用解释器作为第一个步骤。Mozilla的SpiderMonkey和微软的Chakra
JavaScript引擎都是一开始就有解释器,而且它们都是连接到了一个两层的编译系统。

Just-in-Time (JIT) 范式

澳门新葡亰平台官网 202.png

从Chrome
53(预计在9月初发布稳定版本)开始,内存为512MB或不足512MB的Android设备将开始使用Ignition。要想深入了解Ignition,请查看谷歌发布的设计文档。

为了执行任意一段程序,计算机必须将源代码转换成机器可以运行的代码。

相对速度,越高表示越好。

查看英文原文:V8’s New JavaScript Interpreter Improves Memory
Consumption

有两种方法可以进行转换。

更多的说明可以参看 WebKit 官网 JavaScriptCore 的 Wiki
首页部分:

稿源:澳门新葡亰平台官网 ,infoQ   
作者 David
Iffland,译者
谢丽

第一种选择是使用解释器。解释器可以有效地逐行翻译和执行。

  • API:JavaScriptCore 对外的接口类
  • assembler:不同 CPU 的汇编生成,比如 ARM 和 X86
  • b3:ftl 里的 Backend
  • bytecode:字节码的内容,比如类型和计算过程
  • bytecompiler:编译字节码
  • Configurations:Xcode 的相关配置
  • Debugger:用于测试脚本的程序
  • dfg:DFG JIT 编译器
  • disassembler:反汇编
  • heap:运行时的堆和垃圾回收机制
  • ftl:第四层编译
  • interpreter:解释器,负责解析执行 ByteCode
  • jit:在运行时将 ByteCode 转成机器码,动态及时编译。
  • llint:Low Level
    Interpreter,编译四层里的第一层,负责解释执行低效字节码
  • parser:词法语法分析,构建语法树
  • profiler:信息收集,能收集函数调用频率和消耗时间。
  • runtime:运行时对于 js 的全套操作。
  • wasm:对 WebAssembly 的实现。
  • yarr:Yet Another Regex Runtime,运行时正则表达式的解析

第二种方法是使用编译器。编译器在执行之前立即将所有源代码转换为机器语言。

ScriptController 会调用 JavaScriptCore 的 evaluate 和 checkSyntax
两个接口。DOM 节点的 JSBindings 通过回溯到 JSC::JSNonFinalObject 实现和
JavaScriptCore 的绑定。

下面,我们将阐述两种方法的优点和缺点。

VM 是 JavaScript 的 Runtime 环境。GlobalObject
是全剧对象,负责管理执行环境和 Object 的。ExecState 是执行脚本的对象,由
GlobalObject 管理的,负责记录脚本执行上下文。

解释器的优点、缺点

接口执行脚本,创建一个 ProgramExecutable
对象来表示要执行的脚本负责编译成 ByteCode,调用 Interpreter 执行这个
ByteCode。

解释器使用read-eval-print loop(REPL,交互式解释器)的方式工作 ——
这种方式有许多的优点:

Binding 是 WebCore 为 JavaScriptCore
提供的封装层。这一层的定义可以在这里找到:
参照的是 W3C Web IDL

易于实现和理解及时反馈更合适的编程环境

使用 IDL 定义接口规范,WebKit 使用一组 perl 脚本来转换 IDL,初始脚本是
generate-binding.pl。生成接口与 DOM 组件关联是通过 JSNode
来的。执行脚本和 Frame 的 setDocument 会更新 document 对象到
JavaScriptCore,通过 JSDomWindowBase::updateDocument 更新到 JSC::Heap
里。

然而,这些好处是以缓慢执行为代价的:

词法和语法分析程序 JavaScriptCore
是自己编写的。词法分析会把文本理解成很多 token,比如 a = 5;
就会被识别成下面这些 token,VARIABLE EQUAL CONSTANT
END。然后通过语法分析,输出语法树。

eval的开销,而不是运行机器代码。

澳门新葡亰平台官网 303.png

无法跨程序的对各个部分进行优化。

需要先设计好 Node,Node 的类关系图如下:

更正式地说,解释器在处理不同的代码段时不能识别重复的工作。如果你通过解释器运行同一行代码100次,解释器将翻译并执行同一行代码100次,没有必要地重新翻译了99次。

澳门新葡亰平台官网 404.png

总结一下,解释器简单、启动快,但是执行慢。

举个包含加和乘的 statement 是如何组成 AST 的

编译器的优点、缺点

o.x * o.x + o.y * o.y

编译器会在执行前翻译所有的源代码。

澳门新葡亰平台官网 505.png

随着复杂性的增加,编译器可以进行全局优化。这为编译器提供了比解释器唯一的优势
—— 更快的执行时间。

heap 代表的是可见数据,pure 代表的是抽象说明。

总结一下,编译器是复杂的、启动慢,但是执行快。

下面看看 Object 的构造

即时编译(JIT)

var o = new Object();o.f = 1;o.g = 2;o.h = 3;

即时编译器尝试结合了解释器和编译器的优点,使代码转换和执行都变得更快。

澳门新葡亰平台官网 606.png

基本思想是避免重复转换。首先,探查器会通过解释器先跑一遍代码。在代码执行期间,探查器会跟踪运行几次的热代码段和运行很多次的热代码段。

接下来看看 function 的

JIT将热代码片段发送给基线编译器,尽可能的复用编译后的代码。

function foo { if (o.f && o.f print; if (o.g && o.g print;}

JIT同时将热代码片段发送给优化编译器。优化编译器使用解释器收集的信息来进行假设,并且基于这些假设进行优化。

澳门新葡亰平台官网 707.png

但是,如果这些假设无效,优化编译器将执行 去优化,丢弃优化的代码。

还有些其它的 parser 比如 Esprima 提供了一个在线 demo 可以在线
Parser。实现的代码在这里:
主要使用的是递归下降和运算符优先级混合式来做的 parser。

优化和去优化的过程是昂贵的。由此产生了一类JavaScript的优化方法,下面将详细描述。

我在 HTN ( js 的 AST 的
builder:
分词和语法树生成使用的是状态机处理递归下降。

JIT需要存储优化的机器代码和探查器的执行信息等,自然会引入内存开销。尽管这一点无法通过优化的JavaScript来改善,但激发了V8的解释器。

WebKit 的性能目录里有用 ES6 标准实现的 ECMA-55 BASIC
这个词法语法分析的测试。代码在:

V8的编译

程序会被表示成树,每个节点都有与其相关的代码,这些代码都可以递归的调用树中的子节点。Basic
的节点的结构是这样子的:

V8的解释器和编译器执行以下功能:

{evaluate: Basic.NumberPow, left: primary, right: parsePrimary()}

解释器将抽象语法树转换为字节码。字节码队列随后会被执行,并且通过内联缓存收集反馈。这些反馈会被解释器本身用于随后的解析,同时,编译器会利用这些反馈来做推测性的优化。编译器根据反馈将字节码转换为特定于体系结构的机器码,从而推测性地优化字节码。V8的解释器

Basic 是以不那么常见的方式使用生成器的,比如多个生成器函数,很多 yield
points 和递归生成器调用。Basic 也有 for-of,类,Map 和
WeakMap。所以上的测试 ES6 程序通过 js 写的程序帮助理解 ES6 的解析过程。

  • Ignition

ProgramExecutable 的初始化会生成 Lexer,Parser 和字节码。入口是从 JS
Binding 那层里调用 ScriptController::evaluateInWorld
进来,这个方法里的参数 sourceCode 就是 js 的代码的来源,方法内通过调用
runtime/Completion.cpp 里的方法 evaluate 来进入 executeProgram
方法的。总的来说 ProgramExecutable 主要是把代码编成字节码,Interpreter
是来执行字节码的。

JIT编译器显示了开销内存消耗。Ignition通过实现三个目标来解决这个问题:减少内存使用、减少启动时间和降低复杂性。

JSValue result = vm.interpreter->executeProgram(source, exec, thisObj);

这三个目标都是通过将AST转换为字节码并在程序执行期间收集反馈来实现的。

executeProgram 方法将源码生成 ProgramExecutable 这个对象。

字节码被当做源代码对待,省去了在编译期间重新解析JavaScript的需要。这意味着使用字节码,TurboFan的去优化过程不再需要原始的代码了。作为基于程序执行反馈的优化示例,内联缓存允许V8优化对具有相同类型参数的函数的重复调用。具体来说,内联缓存存储函数的输入类型。类型越少,需要的类型检查就越少。减少类型检查的数量可以显著提高性能。

ProgramExecutable* program = ProgramExecutable::create(callFrame, source);

AST和字节码都会暴露给TurboFan。

这个对象里有 StringView 对象,program->source
这样就可以对源码进行操作了。在那之前会先判断下是否这段 js
代码仅仅只是一个 JSON 对象,如果是就地按照 JSON
对象处理,不然就按照普通的 js 代码来处理。

V8的编译器 – TurboFan

普通处理会先编译成字节码,过程是先初始化全局属性:

在2008年发布时,V8引擎最初直接将源代码编译为机器代码,跳过了中间字节码表示。在发布时,V8就比竞争对手快了10倍。

JSObject* error = program->initializeGlobalProperties(vm, callFrame, scope);

然而,到今天,TurboFan接受了Ignition的字节码,比它发布的时候快了10倍。V8的编译器经过了一系列的迭代:

处理的结果都会记录在 callFrame 里。主要是通过 JSGlobalObject 这个对象的
addFunction 和 addVar 方法记录 Parser 出那些在全局空间的那些 let,const
和 class 的全局属性,或者 var,let 和 const 的全局变量。

2008 – Full-Codegen

接下来会创建一个 codeBlock:

具有隐藏类和内联缓存,快速遍历AST的编译器缺点:无优化的即时编译

ProgramCodeBlock* codeBlock; { CodeBlock* tempCodeBlock; JSObject* error = program->prepareForExecution<ProgramExecutable>(vm, nullptr, scope, CodeForCall, tempCodeBlock); EXCEPTION_ASSERT(throwScope.exception() == reinterpret_cast<Exception*>; if (UNLIKELY return checkedReturn; codeBlock = jsCast<ProgramCodeBlock*>(tempCodeBlock); }

2010 – Crankshaft

这里 ProgramExecutable 会通过 prepareForExecution 方法里调用
prepareForExecutionImpl 来创建一个新的 CodeBlock。

使用类型反馈和去优化,优化即时编译器。缺点:
不能扩展到现代JavaScript,严重依赖去优化,有限的静态类型分析,与Codegen紧密耦合,高移植开销

CodeBlock* codeBlock = newCodeBlockFor(kind, function, scope, exception);resultCodeBlock = codeBlock;EXCEPTION_ASSERT(!!throwScope.exception() == !codeBlock);if (UNLIKELY(!codeBlock)) return exception;if (Options::validateBytecode codeBlock->validate();if (Options::useLLInt setupLLInt(vm, codeBlock);else setupJIT(vm, codeBlock);installCode(vm, codeBlock, codeBlock->codeType(), codeBlock->specializationKind;

2015 – TurboFan

这里看到,如果 Options 是指定 LLInt 的话就会调 setupLLInt 方法去设置这个
codeBlock,不然就通过 JIT 来。LLInt 在 LLIntSlowPaths.cpp 里通过 C
函数封装了 LowLevelInterpreter.asm 里的执行指令的汇编。触发 JIT
优化是通过热点探测方法,LLInt 会在字节码的 loop_hint 循环计数和 ret
函数返回指令的时候进行统计,结果保存在 ExcutionCounter
里。当函数或循环体执行一定次数时,通过 checkIfThresholdCrossedAndSet
方法得到布尔值结果来决定是否用 JIT 编译。

用类型和范围分析优化即时编译器

CodeBlock 有 GlobalCode,EvalCode,FunctionCode,ModuleCode
这些类型,用于 LLInt 和 JIT。编译后的 ByteCode 会存在 UnlinkedCodeBlock
里。

根据Google慕尼黑技术讲座,TurboFan优化了峰值性能、静态类型信息使用、编译器前端、中间和后端分离以及可测试性。最终沉淀出一个关键的贡献:”节点海”。

总的来说 JavaScriptCore 是采用了类型推测和分层编译的思想,解析成字节码后
LLInt 的作用就是让 js
代码能够早点执行,由于解释的效率不高,所以达到一定条件可以并行的通过
Baseline JIT 编译成更高效的字节码来解释,如果到了更加容易使得 LLInt
解释效率差的情况就会并行使用 DFG JIT ,DFG 编译的结果也有代表 On stack
replacement 的 osrExitSites
数组,这样的字节码的解释性能会更好,如果假设解释失败还可以再回到
Baseline,也不会有什么问题。启用 FTL
的条件就更高了,可能函数调用了好几万次也都不会开启。

在节点海中,节点表示计算,变表示依赖关系。

分层编译的主要思想是先把源码解释成一种内部的表示也就是字节码,将其中使用率高的字节码转成汇编代码,汇编代码可以由
CPU 直接执行来最大程度的提高性能。

与控制流图不同的是,节点海可以放宽大多数操作的评估顺序。与CGF一样,有状态操作的控制边和效果边在需要时会约束执行顺序。

LLInt,Baseline JIT 和 DFG 三者会同时运行。可以在 jsc.cpp
里添加日志观察他们的工作状态

Titzer进一步完善了这个定义,使之成为一个节点汤,其中控制流子图进一步放宽。这提供了许多优点—例如,这避免了冗余代码的消除。

澳门新葡亰平台官网 808.png

通过自下而上或自上而下的图转换,图缩减被应用于这一系列节点。

箭头指示的是堆栈替换 on-stack replacement,简称
OSR。这个技术可以将执行转移到任何 statement 的地方。OSR
可以不管执行引擎是什么都在去解析字节码状态,并且能够重构它去让其它引擎继续执行,OSR
entry 是进入更高层优化,OSR exit 是降至低层。LLInt 到 Baseline JIT 时
OSR 只需要跳转到相应的机器代码地址就行,因为 Baseline JIT
每个指令边界上的所有变量的表示和 LLInt 是一样的。进入 DFG JIT
就会复杂些,DFG
通过将函数控制流图当作多个进入点,一个是函数函数启动入口一个是循环的入口。

TurboFan遵循4个步骤将字节码转换为机器码。请注意,以下管道中的优化是根据Ignition的反馈执行的。

如果每个函数都用像 DFG JIT
来优化那么消耗太大,这就类似每次运行就来个完整的源码编译成本地应用那样。如果每个语句都只执行一次,那么
JIT 编译就会比 LLInt 是时间要长,如果执行的次数越多,那么 LLInt
这种解释方式就会比编译方式要差,LLInt
的大部分时间都消耗在分派下个字节码指令的操作上了。

将程序表示为JavaScript操作符。将程序表示为中间运算符。将程序表示为机器操作符。使用顺序约束安排执行顺序。创建一个传统的控制流图。

全称 data flow graph JIT 数据流图
JIT。是一种推测优化的技术。会开始对一个类型做出一个能够对性能好的假设,先编译一个版本,如果后面发现假设不对就会跳转回原先代码,称为
Speculation failure。DFG 是并发编译器,DFG pipeline
的每个部分都是同时运行的,包括字节码解析和分析。

TurboFan的在线JIT风格的编译和优化意味着 V8从源代码到机器代码的转换
结束了。

澳门新葡亰平台官网 909.png

如何优化你的JavaScript

上图是 DFG JIT 的优化 pipeline。开始 DFG 会把字节码转成 DFG CPS
格式,这个格式会描述变量和临时数据之间的数据流关系。再用分析信息来推测类型,通过推测的类型减少类型的检查。接着进行传统的编译器方面的优化,最后编译器通过
DFG CPS 格式直接生成机器码。

TurboFan的优化通过减轻糟糕的JavaScript的影响来提高JavaScript的网络性能。然而,了解这些优化可以提供进一步的加速。

DFG JIT 会将字节码解析成 SSA 形式,按执行路径收集类型信息。会使用
inlining 和 value profiling 等一些静态分析技术。类型推导把 value profile
里的常用的类型作为后面用的类型来预测,在 SpeculatedType.h
里定义里一些数据类型,符合 Data-flow analysis 规范,具体实现在
DFGPredictionPropagationPhase.cpp 里。Baseline JIT
次数超过一定数量就会生成一个新的类型,可能会触发 DFG 也可能会让 Baseline
JIT 再执行几次。

下面是利用V8中的优化来提高性能的7个技巧。前四个重点是减少去优化。

DFG 的 register allocator 中汇编程序使用的是二地址,不用 op dest, src1,
src2 这样三地址形式,只需要 op src1, src2
这样的二地址形式,结果存在第一个操作数里。

Tip1: 在构造函数中声明对象属性

DFG JIT 将低效字节码转成更优化的高效形式。DFG
结合传统编译器的优化,比如寄存器的分配,控制流图的简化,公共子表达式消除,死代码消除和稀疏条件常量传播等。但是正儿八经的编译器是需要知道变量类型和堆里面的对象的结构的,这样才能更好的优化。DFG
JIT 使用的是 LLInt 和 Baseline JIT
里分析出的变量类型来推断的。比如下面的例子

更改对象属性会产生新的隐藏类。以google i/o 2012中的以下示例为例。

function plusThe { return x + 1; }
class Point { constructor(x, y) { this.x = x; this.y = y; }}var p1 = new Point(11, 22); // hidden class Point createdvar p2 = new Point(33, 44);p1.z = 55; // another hidden class Point created

这里的 x 从代码上看是看不出类型的,它可能是字符串也可能是 integer 或者
double。LLInt 和 Baseline JIT
会从函数参数或者来自堆的读取来收集每个变量的值信息。DFG 不会一开始就编译
plusThe 这个函数,它会等到这个函数被执行了很多次,让 LLInt 和 Baseline
JIT
尽可能的把类型信息收集全。根据这些信息来假设一个类型,一个确定的类型会节省空间和执行时间。如果假设的检查失败,就通过
OSR 把执行转移到 Baseline JIT 上。

正如你所见,p1和p2现在有不同的隐藏类了。这阻碍了TurboFan的优化尝试:具体来说,任何接受Point对象的方法现在都是去优化的。

DFG JIT 可以优化 long-running 运行的代码性能,但是会影响 short-running
的程序,DFG 编译的时间和 DFG 生成代码的质量相比较来说还是过大。想让 DFG
生成更好的代码都会降低代码速度,还会增加 shorter-running 代码的延迟。DFG
不可能同时成为低延迟优化和 high-throughput
代码编译器。这时就需要再多一层来做较重的优化,同时让 DFG
保持自己的轻体重,使得 longer-running 和 shorter-running
代码能够保持平衡。

所有这些函数都使用两个隐藏类重新优化。对对象形状的任何修改都是如此。

澳门新葡亰平台官网 1010.png

Tip2: 保持对象属性不变

新的一层 FTL 实际上是 DFG Backend 的替换。会先在 DFG 的 JavaScript
函数表示转换为静态单一指派 格式上做些 JavaScript 特性的优化。接着把 DFG
IR 转换成 FTL 里用到的 B3 的 IR。最后生成机器码。

更改对象属性的顺序会导致新的隐藏类,因为对象形状中是包含顺序的。

DFG 的机器代码生成器很快,但没有低级优化。对这块的优化采用先转成 SSA
形式,执行比如自动执行循环不变量代码来提高执行速率的循环不变量代码移动(loop-invariant
code motion)的优化技术,这些优化完成后就可以 straight-forward linear 和
一对多(DFG SSA 的每个指令都可以产生)的转换为也基于 SSA 的没有
JavaScript 语言特性的 B3 的
IR。总的来说过程就是把源码生成字节码,接着变成 DFG CPS IR,再就是 DFG
SSA IR,最后成 B3 的 IR,JavaScript
的动态性就是在这些过程中一步步被消除掉的。

const a1 = { a: 1 }; # hidden class a1 createda1.b = 3;const a2 = { b: 3 }; # different hidden class a2 createda2.a = 1;

澳门新葡亰平台官网 1111.png

上面的代码中,a1和a2有不同的隐藏类。修复顺序允许编译器重用同一个隐藏类。因为添加的字段用于生成隐藏类的id

代码在 LLInt,Baseline JIT 和 DFG JIT 运行一段时间才会调用 FTL。FTL
在并发线程中会从它们那收集分析信息。short-running 代码是不会导致 FTL
编译的,一般超过10毫秒的函数会触发 FTL 编译。

Tip3:修复函数参数类型

FTL 通过并发编译减小对启动速度的影响。

函数根据特定参数位置的值类型更改对象形状。如果此类型发生更改,则函数将去优化并重新优化。

B3 的全称 Bare Bones Backend,实现了大吞吐量同时缩短总体 FTL
编译时间。B3 的 IR
是一个低级别的中间表示,低级别意味着需要大量内存去表示每个函数,大量的内存意味着编译器在分析函数时需要扫描大量内存。B3
有两个 IR,一个叫 B3 IR 另一个机器级别的叫 Assembly IR,简称 Air。这两个
IR
的目标是代表低级操作,同时能最大限度的减少分析代码所需要的内存访问。达到这个目标需要减少
IR 对象整体大小,减少表示典型操作的 IR 对象数量,减少 IR 的 pointer
chasing,最后时减少 IR 的总数。

在看到四种不同的对象形状后,该函数会变成megamorphic,TurboFan将不会再尝试优化这个函数。

B3 转换成 Air 会通过执行反向贪婪模式匹配来巧妙地针对每个 B3
操作选择正确的 Air 序列。具体的实现可以参看 B3LowerToAir.cpp 的代码。

看下面这个例子:

下面来看看 B3 IR 的所做的优化。

function add(x, y) { return x + y}add(1, 2); # monomorphicadd("a", "b"); # polymorphicadd(true, false);add([], []);add({}, {}); # megamorphic
  • Strength
    reduction:主要包括控制流程图的简化,常量合并,消除死代码,剪除整数溢出的检查,还有其它简化规则。实现文件是
    B3ReduceStrength.cpp。
  • Flow-sensitive 常量合并:在 B3FoldPathConstants.cpp 文件里。
  • 全局共用子表达式消除:实现文件 B3EliminateCommonSubexpressions.cpp。
  • Tail duplication:B3DuplicateTails.cpp
  • SSA 修正:B3FixSSA.cpp
  • 优化常量实际的位置:B3MoveConstants.cpp

第9行过后,TurboFan将不会再优化add这个函数。

Air 里做的优化包括去除死代码,控制流图的简化以及修正部分寄存器 stalls
和溢出代码症状。Air 最重要的优化是寄存器分配。

Tip4:在脚本作用域中声明类

B3 的动态语言支持靠的是 Patchpoints,还有一半功劳是操作码的 Check
family,用来实现栈上替换,即 OSR。JavaScript
是种动态语言,没有可以快速执行的类型和操作,FTL JIT
就使用推测行为方式把这些操作转换成快速代码。

不要在函数作用域中声明类。以下面这个例子为例:

Air
在寄存器分配器的选择上使用了经典的图形着色寄存器分配器,叫迭代寄存器合并,简称
IRC。

function createPoint(x, y) { class Point { constructor(x, y) { this.x = x; this.y = y; } } return new Point(x, y);}function length(point) { ...}

Web Inspector 工具是 WebKit
里提供的调试工具,通过这个工具可以很好的观察哪些函数甚至哪些条件执行了或者没有执行。最重要的是可以直观的观察变量类型,还能够跟踪值的继承链。

每一次createPoint这个函数被调用的时候,一个新的Point原型会被创建。

澳门新葡亰平台官网 1212.png

每一个新的原型都对应着一个新的对象形状,所以每一次length函数都会看到一个新的point的对象形状。

JavaScript
是动态类型语言,任何变量都可以是任何类型,表达式可以有任何类型,函数可以返回任何类型等等。由于不能立刻得到类型就没法确定地址字节大小,需要在运行时去反复推断。静态语言就不一样,一开始明确了类型,比如目标平台
int 类型占用4个字节,它对应的对象是个地址,偏移量是0,访问 int
时需要将对象地址加上4个字节。所以语言解释系统只要用数组和位移来存变量和方法地址就行了,这样几个机器语言的指令就可以对其执行各种操作。

跟之前一样,当看到4个不同的对象形状的时候,函数会变得megamorphic,TurboFan将不会再尝试优化

下面来个例子:

length函数。

let x = 3;print;x = ["These", "is", "array with string"];print;const justReturn =  => { return x; };justReturn;justReturn([1, 2, 3]);justReturn("I am bad!");

在脚本作用域中声明class
Point,我们可以避免每一次调用createPoint的时候,生成不同的对象形状。

JavaScript
没有类型限制,语法不错就没问题,如果是静态类型语言的话就没法像上面的例子一样把数组赋给数字类型的变量。在
Type Profiler 中会显示类型名为 String? 和 Integer?
这样后面跟着问号符号的表示,这是在有多个不同类型被分配给相同变量,函数参数传递和函数返回时发生的。查看时可以看到所有这些被收集的类型的信息。

下一个tip是V8引擎里的奇淫巧技。

那么 JavaScriptCore 是如何分析变量类型的呢?先看段 js 代码:

Tip5:使用for…in

function add { return x + y;}

这是V8引擎中的一个怪异行为。这一特性之前包含在最初的Crankshaft里面,后来被移植到了Ignition
and Turbofan.

对应的字节码:

for…in循环比函数迭代、带箭头函数的函数迭代和for循环中的object.keys快4-6倍。

function add: 10 m_instructions; 3 parameter; 1 variable[ 0] enter [ 1] get_scope loc0[ 3] add loc1, arg1, arg2[ 8] ret loc1

接下来两个Tip是对之前两种说法的反驳。由于现代V8引擎的改变,这两种说法已经不成立了。

下面看看开启了 Type Profiler 后的字节码是什么样的:

Tip6:无关字符不影响性能

function add: 40 m_instructions; 3 parameter; 1 variable[ 0] enter [ 1] get_scope loc0// 分析函数参数[ 3] op_profile_type arg1 [ 9] op_profile_type arg2// 分析 add 表达式的操作数[15] op_profile_type arg1[21] op_profile_type arg2[27] add loc1, arg1, arg2// 分析返回语句,收集这个函数的返回类型信息[32] op_profile_type loc1[38] ret loc1

Crankshaft过去是使用一个函数的字节数来决定是否内联一个函数的。而TurboFan是建立在AST上的,他使用AST节点的数量来决定函数的大小。

下面是进入 DFG 时转换成 DFG IR 的代码:

因此,无关的字符,比如空白,注释,变量名长度,函数签名等,不会影响函数的性能。

0: SetArgument1: SetArgument2: SetArgument3: JSConstant(JS|PureInt, Undefined)4: MovHint5: SetLocal6: JSConstant(JS|PureInt, Weak:Cell: 0x10f458ca0 Function)7: JSConstant(JS|PureInt, Weak:Cell: 0x10f443800 GlobalScopeObject)8: MovHint9: SetLocal10: GetLocal(JS|MustGen|PureInt, arg1)11: ProfileType12: GetLocal(JS|MustGen|PureInt, arg2)13: ProfileType14: ProfileType15: ProfileType16: ValueAdd(@10, @12, JS|MustGen|PureInt)17: MovHint(@16, loc1)18: SetLocal(@16, loc1)19: ProfileType20: Return

Tip7:Try/catch/finally 不是毁灭性的

现在还有很多 ProfileType,这些操作会有很大的消耗,DFG
会推测参数类型是整数,接下来会在这个假设下将 ProfileType
的那些操作移除掉。

Try代码块以前容易出现高昂的优化-去优化的周期。如今,当在Try块中调用函数时,turbofan不再显示出显著的性能影响。

1: SetArgument2: SetArgument3: JSConstant(JS|PureInt, Undefined)4: MovHint7: JSConstant(JS|PureInt, Weak:Cell: 0x10f443800 GlobalScopeObject)8: MovHint10: GetLocal12: GetLocal16: ArithAdd(Int32:@10, Int32:@12)17: MovHint(@16, loc1)20: Return

结论

在对类型的处理上 V8 使用了一个结合 C++
使用类和偏移位置思想的隐藏类来解决通过字符串匹配找属性的算法。做法是将有相同属性名和属性值的对象保存在同一个组的隐藏类里,这些属性在隐藏类里有着同样的偏移值,这样这个组里的对象能够共用这个隐藏类的信息。访问属性的过程是得到隐藏类的地址,根据属性名得到偏移值,通过偏移值和隐藏类地址得到属性地址。那么这个过程是否可以加速呢?答案是肯定的,通过
Inline Cache
缓存之前查找结果来减少方法和属性的哈希表查找时间。当一个对象或属性类型出现多种时缓存就会不断更新,V8
就会退到先前按照哈希表查找的方式来。

总之,优化方法通常集中在减少去优化和避免不可优化的megamorphic函数上。

JavaScriptCore 是基于寄存器的虚拟机 register-based
VM。这种实现方式不用频繁入栈,出栈和三地址的指令集,所以效率高,但移植性弱点。

通过对V8引擎框架的理解,我们还可以推断出上面没有列出的其他优化方法,并尽可能重用方法来利用内联。现在您已经了解了JavaScript编译及其对日常JavaScript使用的影响。

三地址和二地址的指令集,基本都是使用基于寄存器的架构来实现的,要求除了
load 和 store 外的运算指令的源都要是寄存器。

原文链接:原文标题:How JavaScript works: Optimizing the V8 compiler
for efficiency本文首发于公众号:符合预期的CoyPan

下面的代码

i = a + b;

上面这句转换成机器指令样子

add i, a, b

这样的形式就是三地址指令,很多的代码都是这样的二元运算然后再赋值,三地址正好可以分配两地址给二元运算的两个源,剩下一个地址给赋值目标。ARM
处理器的主要指令集就是三地址的。那么二地址是怎么处理的呢?将上面的代码换成下面的样子:

i += a;i += b;

机器指令形式就变成

add i, aadd i, b

这样一个源同时也作为赋值目标,X86 系列的处理器就是采用的二地址。

有了二地址和三地址,那么有一地址么。

add aadd b

这就是一地址,只有操作源,那么目标呢?目标 i
是隐藏目标,这种运算的目标称为累加器的专用寄存器,运算都是依赖更新累加器的状态来完成。

那么零地址呢?JVM 就是采用的这种。那么就先看下一段 JAVA 代码:

class QuickCalculate { byte onePlusOne() { byte x = 1; byte y = 1; byte z =  ; return z; } }

转换成字节码:

iconst_1 // Push 整数常量1istore_1 // Pop 到局部变量1,相当于 byte x = 1; 这句iconst_1 // 再 Push 整数常量1 istore_2 // Pop 到局部变量2,相当于 byte y = 1; 这句iload_1 // Push x,这时 x 已经作为整数存在局部变量1里。iload_2 // Push y,这时 y 已经作为整数存在局部变量1里。iadd // 执行加操作,栈顶就变成了 x + y,结果为一个整数int2byte // 将整数转化成字节,结果还是占32位istore_3 // Pop 到局部变量3里,byte z = iload_3 // Push z 的值使之可以被返回ireturn // 返回结果,return z

整个过程我放到了注释里。可以看到零地址形式的指令集就是基于栈架构的。这种架构的优势是可以用更少的空间存更多的指令,所以空间不是很富足时这种架构是可取的,不过零地址要完成一件事会比基于寄存器指令架构的二地址,三地址指令要多很多指令,执行效率还会低。

JavaScriptCore 采用 SquirrleFish
之前的解释器他们都是树遍历式的,解释器会递归遍历树,在遍历树上的每个节点的操作都是根据解释其每个字节点返回的值来的。这种操作即不是基于栈也不是基于寄存器。举个例子

i = x + y * z

按 AST 的后序遍历,最上面的 = 符号依赖子节点 + 符号节点返回的值,+
符号依赖 x 节点和 *
符号节点的值,依此递归下去,这样最开始获取到值的就是最低一级的运算,在这个例子里就是
y 和 z 的运算的结果返回给 * 符号节点。这种就是典型的后序遍历。CRuby 1.9
之前也是用的这种方式解释执行的。

赋值符号 =
符号的左侧叫做左值,右侧的值叫做右值。左值也可能是复杂的表达式,比如数组或者结构体。根据求值顺序,对于二元运算的节点,是先遍历左子节点的。所以当左值是复杂表达式需要计算时是会优先进行计算的。再看个左值是数组的例子:

public class LeftFirstTest { public static void main(String[] args) { int[] arr = new int[1]; int x = 3; int y = 5; arr[0] = x + y; }}

arr[0] = x + y; 对应的字节码:

// 左值,数组下标aload_1 iconst_0 // 右值iload_2 // xiload_3 // yiadd iastore // 赋值符号节点进行赋值

可以看到左值是先计算的。

从树遍历到基于栈的解释,实际上是一个将 AST 树打平的过程。方法是后序遍历
AST 时使用 Reverse Polish Notation
这种后缀记法生成一个序列,成为一个线性结构,后面再解释执行这个操作序列。

JVM 是 Java 语言的虚机。Android 也是用的 Java 语言,那么它的虚机也是 JVM
吗?答案是否定的,Android 使用的虚机叫 Dalvik
VM,这款虚机在很多设计上都与 JVM
兼容,字节码是二地址和三地址并用的方式,是基于寄存器的架构。Dalvik VM
用在移动端为了能够更加高效,开始就没有顾及太多可移植性,这样基于寄存器架构的优势就能够更好的发挥出来了。想更多了解
Dalvik VM 可以通过 Dan Bornstein 做的一个 Dalvik 的实现原理的演讲 Dalvik
VM Internals。

JVM 和 Dalvik VM 的主要区别是后者字节码指令数量和内存更少。JVM
每个线程有一个 Java 栈用来记录方法调用的 activation
record,每调用一个方法就会分配一个新栈帧,方法返回就 Pop
出栈帧。每个栈帧会有局部变量区,istore
这样的指令用来移动局部变量和参数到局部变量区。每个栈帧还会有求值栈,这个栈用来存储求值的中间结果和调用其他方法的参数等,使用
iconst 这样的指令来进行数据的移动,还可以通过 iadd,imul
这样的指令在求值栈中 Pop 出值进行求值,然后再把结果 Push 到栈里。

Dalvik VM 的每个线程有程序计数器和调用栈,方法的调用和 JVM
一样是会分配一个新的帧,不同的是 Dalvik VM 使用的是虚拟寄存器来替代 JVM
里的局部变量区和求值栈。方法调用会有一组自己的虚拟寄存器,常用的是 v0 –
v15,有些指令可以使用 v0 –
v255。只在虚拟寄存器中进行指令操作,数据移动少多了,保存局部变量的存储单元也会少很多。Dalvik
VM 的寄存器每次方法调用会一组自己的,不过在 X86
架构中寄存器是全局的,这样 X86 需要考虑 calling
converntion,就是需要保护一些寄存器的状态,在调用时需要处理这些,而
Dalvik VM
调用完方法后,那些寄存器值会恢复成调用前的可以很好的避免这样的问题。

V8 没有中间的字节码,而是直接编译 JavaScript
生成机器码。不过在内部也用了表达式栈来简化代码生成,在编译过程中使用虚拟栈帧来记录局部变量和栈的状态。生成代码的过程中会有窥孔优化用来去除多余的入栈和出栈,把栈操作转成寄存器操作,生成的代码看起来就和基于寄存器的代码类似了。

这就有个疑问了,零地址要做多次入栈和出栈操作,执行效率低,那么为什么还会有虚拟机比如
JVM 字节码还要用零地址形式呢?

像 X86
刚开始时的寄存器很多不是通用寄存器,由于让编译器去决定程序里那么多的变量该怎么装到寄存器里,那些应该应该映射到一个寄存器,哪些应该换出,这并不容易,JVM
采用零指令这种堆栈结构的原因就是不信任编译器的寄存器分配,所以使用堆栈结构,这样就可以避开寄存器分配这样的难题。不过后来
IBM
公开了他们的图染色寄存器分配算法,这样编译器的分配能力得到了很大的进步,所以现在都是编译器来主导寄存器分配。

很多主流高级语言虚拟机(high-level language virtual machine,简称 HLL
VM)比如 JVM,CPython
都采用的基于栈的架构。这样做主要是因为不用考虑临时变量分配空间,只需要求值栈来做,这样的编译器更容易实现。还有就是可以更容易在硬件较差的机器上运行,前面也讲到基于栈这种架构指令对于存储空间的要求更少。最后就是考虑移植,复杂指令计算机(Complex
Instruction Set Computer,简称
CISC)的通用寄存器比较少,32位只有8个32位通用寄存器。精简指令集计算机(Reduced
Instruction Set Computer,简称
RISC)通用寄存器数量会多些,32位有16个寄存器。源架构寄存器数量通常和实际机器通用寄存器数量不一致而实现映射麻烦,基于栈架构里是没有通用寄存器的,实现虚机时可以就可以很容易自由的分配实际机器寄存器,移植起来自然也就容易多了。

处理器来说多是基于寄存器架构的,但是对于虚机来说,由于基于栈架构需要执行更多的
load 或 store 这样的指令,这样 Instruction dispatch
的次数和内存访问的次数会更多,所以基于寄存器的架构在虚机里同样也更优些,这也是为什么对于闭环的苹果来说会在
JavaScriptCore
的虚机里采用性能更好的基于寄存器的架构,而不去顾及移植性。

各个 JavaScript 引擎介绍:

  • SpiderMonkey:用于 Mozilla Firefox,是最早的 JavaScript
    引擎,基于栈的字节码。Parser 使用的手写纯递归下降式。

  • KJS:KDE 的引擎,用于 Konqueror 浏览器。树遍历解释器。Parser
    使用的是 bison。

  • Rhino:使用 Java编写,开发源码,也是 Mozilla 的,相当于 Java 版的
    SpiderMonkey,当时的想法是想把 JavaScript
    做成服务端的脚本语言来用。Parser 是手写的纯递归下降式
    rhino/src/org/mozilla/javascript/Parser.java 。

  • Chakra:也叫 JScript 微软的 Internet Explorer 和 Microsoft Edge
    在使用。

  • JavaScriptCore:苹果开发的开源 JavaScript 引擎,用在 Safari
    等浏览器中。Safari 是带有 JIT 的 JavaScriptCore 的,而 UIWebView
    是没有的。

  • V8:Google 开发的开源引擎,用于 Chrome。Parser
    使用的是手写纯递归下降加运算符优先级混合式。V8 使用了
    2-pass,会先收集一些上下文信息增加预测的准确性。V8
    开始时是不用字节码会直接编译成机器码,Dart VM
    也是这样设计的,他们一个问题的回答 Why didn’t Google build a
    bytecode VM targetable by multiple languages including Dart?
    同样适用于 V8。不过 V8 5.9 启用了 Ignition 字节码解释器,自此几大 JS
    引擎都用了字节码。启用字节码的考虑主要是希望能够减少机器码对内存空间的占用。由于机器码占用的空间也很大,所以不好都缓存下来,不然内存和磁盘都吃不消,序列化和反序列化时间都太长,这样每次编译的机器码都不是完整的只会缓存最外的一层,如果代码最外层包了一层,启动代码每次都是不同的调用就会每次都编译,会导致缓存没有作用。由于字节码通过比较好的设计后能够做到比机器码紧凑,这样引入
    Ignition 后内存明显的下降了。TurboFan 再对 Ignition
    字节码来进行解释。

根据下面关键子去查找,可以了解更多的相关的虚机技术。

源码到中间代码

  • Recusive-descent parser:递归下降式 parser
  • Operator precedence parser:运算符优先级 parser
  • Deferred parser:延迟 parser

中间代码到目标代码

  • Tiered compilation:多层编译
  • Background compilation:后台编译
  • Type feedback:类型反馈
  • Type specialization:类型特化
  • SSA-form IR:静态单赋值形式

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图