JSC 💕 ES6

更新:此帖的先前版本显示了来自ARES-6 1.0版 的性能数据。1.0版存在一个错误,其中“最差4次迭代”的得分实际上使用了前4次迭代。我们已修复此错误并将基准更新到1.0.1版。此帖中显示的性能结果现在使用包含修复的ARES-6 1.0.1版。

ES2015(也称为ES6),即2015年批准的JavaScript规范版本,凭借for-of解构扩展运算符尾调用以及更多功能,极大地提升了语言的表达能力。然而,强大的语言功能对语言实现者而言代价高昂。在ES6之前,WebKit花费了数年时间优化ES3和ES5代码中出现的惯用语。ES6需要全新的编译器和运行时功能,才能达到与ES5相同的性能水平。

Overall Performance Results

WebKit的JavaScript实现,名为JSC(JavaScriptCore),完整实现了ES6。ES6功能从一开始就设计为快速运行,尽管当时我们只有微基准测试来衡量这些功能的性能。虽然最初优化我们自己的微基准测试和像six-speed这样的微基准测试套件很有帮助,但为了真正为ES6调整我们的引擎,我们需要一个更全面的基准测试套件。在不知道这些功能将如何使用的情况下,很难优化新的语言功能。由于我们热爱编程,并且ES6有许多有趣的新语言功能可供编程,我们开发了自己的ES6基准测试套件。这篇帖子描述了我们第一个ES6基准测试ARES-6的开发过程。我们使用ARES-6来推动JSC中的重要优化工作,这篇帖子描述了其中的三项:高吞吐量生成器、新的Map/Set实现和幻影扩展。帖子最后通过性能数据分析展示了JSC的性能与其他ES6实现相比如何。

ARES-6 基准测试

该套件由我们自己用ES6编写的两个子测试和我们导入的两个使用ES6的子测试组成。第一个子测试名为Air,是WebKit B3 JITAir::allocateStack阶段的ES6移植版。第二个子测试名为Basic,是使用生成器实现的ECMA-55 BASIC标准的ES6实现。第三个子测试名为Babylon,是Babel的JavaScript解析器的导入版。第四个子测试名为ML,是导入自mljs机器学习框架中的前馈神经网络库。ARES-6多次运行Air、Basic、Babylon和ML基准测试,以收集大量关于浏览器启动、预热和卡顿的数据。本节描述了这四个基准测试以及ARES-6的基准测试运行方法。

Air

Air是JSC allocateStack 编译器阶段的ES6重新实现,以及运行该阶段所需的所有汇编中间表示。Air努力忠实地使用箭头函数、类、for-of以及Map/Set等新功能。Air不因为担心它们可能变慢而避开任何功能,而是希望通过观察Air和其他基准测试如何使用它们来学习如何使这些功能变得快速。

下一节介绍了Air的动机和设计。您可以在此处浏览源代码。

动机

在编写Air时,大多数JavaScript基准测试都使用ES5或更早版本的语言。ES6测试主要依赖于微基准测试或将现有测试转换为ES6。我们使用更大的基准测试来避免对小段代码进行过度优化。我们也避免更改现有基准测试,因为那种方法没有限制原则:如果可以更改基准测试以使用某个功能,那是否意味着我们也可以更改它以删除我们不喜欢的功能的使用?我们认为,避免陷入创建只强化某个JS引擎已擅长之处的基准测试的陷阱的最佳方法之一,是从基本原理出发创建新的基准测试。

我们最近才完成了新的JavaScript编译器,名为B3。B3的后端名为Air,是CPU密集型程序,在C++中结合使用了面向对象和函数式惯用语。此外,它还高度依赖高速的Map和Set。它甚至使用了定制的Map/Set实现——比WebKit其余部分更多。这使得Air成为ES6基准测试的绝佳选择。ARES-6中的Air基准测试是JSC Air的忠实ES6实现。它毫不手软:就像最初的C++ Air将表达性作为首要任务一样,ES6 Air在任何有助于使代码更具可读性的地方都大量使用现代ES6惯用语。与最初的C++ Air不同,ES6 Air不利用对编译器的深入理解来简化代码编译。

设计

Air运行C++ Air中计算成本较高的阶段之一,即Air::allocateStack()。它通过选择如何在堆栈帧中布局堆栈槽,将抽象的堆栈引用转换为具体的堆栈引用。这需要活跃性分析干扰图

Air比大多数其他功能更依赖于三个主要的ES6功能

  • 箭头函数。与带有lambda的C++版本一样,Air使用函数式风格迭代大多数非平凡数据结构。这是因为函数式风格允许回调修改正在迭代的数据:如果回调返回非空值,forEachArg()将用该值替换参数。这在使用for-of时是不可能的。Air使用forEachArg()和箭头函数通常是这样的
   inst.forEachArg((arg, role, type, width) => ...)
  • For-of。许多Air数据结构都适合for-of迭代。虽然最内层循环倾向于使用函数式迭代,但几乎所有外层逻辑都大量使用for-of。例如
   for (let block of code) // Iterate over the basic blocks in a program
       for (let inst of block) // Iterate over the instructions in a block
           ...
  • Map/Set。Air::allocateStack()中的活跃性分析依赖于Map和Set。例如,我们使用一个以基本块为键的liveAtHead Map。其值是活跃堆栈槽的Set。这是一种相对粗略的活跃性处理方式,但这正是原始Air::LivenessAnalysis的工作方式。因此,我们认为这相当忠实于一个明智的程序员可能使用Map和Set的方式。

Air还使用了一些其他ES6功能。例如,它轻微使用了Proxy。它广泛使用了类、let/const和Symbols。Symbols被用作枚举元素,因此它们经常出现在switch语句的case中。

Air的运行流程相当简单:我们对四个IR负载进行200次allocateStack运行。

每个IR负载都是一大段ES6代码,用于构建一个Air Code对象,包含基本块、临时变量、堆栈槽和指令。这些负载是通过运行Air::dumpAsJS()阶段生成的。这是我们在C++版Air中编写的一个阶段,它将生成构建IR负载的JS代码。在运行C++ allocateStack阶段之前,我们执行Air::dumpAsJS()来捕获负载。我们生成的四个负载是用于其他四个主要JS基准测试中最热门的函数。

  • Octane/GBEmu,executeIteration函数。
  • Kraken/imaging-gaussian-blur,gaussianBlur函数。
  • Octane/Typescript,scanIdentifier函数。
  • Air(是的,它是自引用的),一个由我们的分析器识别为ACLj8C的匿名闭包。

这些负载允许Air在这些实际函数上精确重放allocateStack

Air验证其结果。我们为C++ Air和ES6 Air都增加了Code哈希能力,并且我们断言每个负载在allocateStack之后看起来与原始C++ allocateStack之后的样子相同。我们还在allcoateStack之前验证负载哈希是否正确,以帮助在负载初始化期间捕获错误。我们尚未测量哈希所需的时间,但它是一个O(N)操作,而allocateStack更接近O(N^2)。我们怀疑,除非存在某些引擎病理问题,否则哈希应该比allocateStack快得多,并且allocateStack应该是花费大部分时间的地方。

总结

在编写Air时,我们对可用的ES6基准测试并不满意。Air广泛使用ES6功能,希望通过研究此基准测试和其他基准测试,我们能学习到可能的优化策略。

Air完全不使用生成器。我们几乎将它们用于forEachArg迭代,但这会是一种非常不寻常的生成器使用方式,因为Air的forEach函数更像是map而不是for-of。关键在于允许调用者修改条目。我们认为在这种情况下,函数式迭代比for-of和生成器更自然。但这让我们开始思考:我们该如何使用生成器?

Basic

Web编程需要考虑异步性。但回溯到我们一些人刚开始用BASIC编程语言学习编程时,我们不必担心这个问题:如果你想从用户那里获取输入,你会说INPUT,程序会阻塞直到用户输入内容。从某种意义上说,这正是生成器的意义所在:当你发出yield指令时,你的代码会阻塞直到其他事情发生。

Basic是ECMA-55 BASIC的ES6实现。它将解释器实现为递归的抽象语法树(AST)遍历,其中递归函数是生成器,以便它们在遇到BASIC的INPUT命令时可以yield。 词法分析器也写成了一个生成器。Basic测试了多种风格的生成器,包括具有多个yield点和递归yield*调用的函数。

下一节描述了Basic如何在词法分析器和AST遍历中使用生成器。您可以在此处浏览源代码。

词法分析器

词法分析器通常被写成一个lex函数,该函数维护一些状态并在每次调用时返回下一个token。Basic使用生成器来实现lex,以便它可以将其所有状态都存储在局部变量中。

Basic的词法分析器是一个生成器函数,内部包含多个嵌套函数。它包含了八个yield的使用,其中没有一个是递归的。

AST 遍历

遍历AST是实现解释器的一种简单方法,这也是Basic的自然选择。在这种方案中,程序被表示为一棵树,每个节点都关联着代码,这些代码可以递归调用树中的子节点。Basic使用普通的对象字面量来创建节点。例如

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

解析器中的这段代码创建了一个AST节点,其求值函数是Basic.NumberPow。如果没有INPUT,Basic可以对所有AST使用普通函数。但INPUT需要阻塞;因此,我们用yield来实现它。这意味着所有语句级别的AST节点都使用生成器。这些生成器可以使用yield*递归地相互调用。

AST遍历解释器包含18个生成器函数,其中有两个yield调用(在INPUTPRINT中)以及一个yield*调用(在Basic.Program中)。

工作负载

每个Basic基准测试迭代运行五个简单的Basic程序

  • Hello world!
  • 打印数字1..10。
  • 打印一个随机数。
  • 打印随机数直到打印出100。
  • 找出2到1999之间的所有素数。

解释器使用固定种子,因此随机部分总是以相同方式运行。Basic基准测试运行200次迭代。

总结

我们编写Basic是因为我们想看看积极尝试使用生成器会是什么样子。在Basic之前,我们只有微基准测试,我们担心这只会将我们的优化工作集中在简单情况上。Basic以一些不明显的方式使用生成器,例如拥有多个生成器函数、许多yield点和递归生成器调用。Basic还使用了for-of、类、Map和WeakMap。

Babylon 和 ML

对我们来说,ARES-6既包含我们编写的测试,也包含我们导入的测试,这一点很重要。Babylon和ML是我们导入的两个测试,它们经过了少量修改,使其无需Node.js风格模块或ES6模块即可在浏览器中运行。(不包含ES6模块测试的原因是,在撰写本文时,<script type="module">并未在所有主流浏览器中默认启用。)为ARES-6编写新测试很重要,因为它允许我们以有趣和复杂的方式使用ES6。导入我们未编写的程序对于性能分析也至关重要,因为它确保我们衡量已在实际应用中使用ES6的有趣程序的性能。

Babylon是ARES-6的一个有趣的测试,因为它轻度使用了类。我们认为许多人会通过采用类来初步接触ES6,因为许多ES5程序编写方式易于转换为类。我们在WebKit项目中亲身见证了这一点,当时Web Inspector将其所有ES5伪类都转换为使用ES6类语法。您可以在此处浏览ARES-6中的Babylon源代码。Babylon基准测试运行200次迭代,每次迭代都包括解析四个JS程序。

ML基准测试导入的前馈神经网络库严重依赖ES6类。它使用了ml-matrix库,该库几乎完全使用ES6类以明确的面向对象风格实现矩阵操作。您可以在此处浏览ARES-6中的ML源代码。ML基准测试运行60次迭代,每次迭代都包括使用各种激活函数不同的样本数据集执行分析。

基准测试方法

Air、Basic和Babylon各运行200次迭代,ML运行60次迭代。每次迭代都执行相同的工作。我们在第一次迭代之前模拟页面重新加载,以最大限度地减少代码在第一次迭代开始时受益于JIT优化的机会。ARES-6以三种不同的方式分析这四个基准测试

  • 启动性能。我们希望ES6代码从一开始就快速运行,即使我们的JIT尚未有机会执行优化。ARES-6报告的首次迭代得分就是60或200次迭代中第一次迭代的执行时间。
  • 最差情况性能。JavaScript引擎有许多不同类型的卡顿。GC、JIT和各种自适应优化都存在导致程序有时运行时间远超正常水平的风险。我们不希望我们的ES6优化引入新型卡顿。ARES-6报告的最差4次迭代得分是60或200次迭代中最差的4次迭代的平均执行时间,不包括用于衡量启动性能的第一次迭代。
  • 吞吐量。如果您编写了一些ES6代码,并且它运行了足够长的时间,那么它最终应该会受益于我们所有的优化。ARES-6报告的平均得分是所有迭代(不包括第一次迭代)的平均执行时间。

ARES-6的每次重复都为4个子测试(Air、Basic、Babylon和ML)中的每一个产生3个分数(以毫秒为单位):首次迭代最差4次迭代平均,总计12个分数。所有12个分数的几何平均值是该次重复的总分。ARES-6运行6次重复,并报告这13个分数的平均值及其95%置信区间。由于这些分数是执行时间的度量,所以分数越低越好,因为这意味着基准测试在更短的时间内完成。

优化

我们构建ARES-6是为了拥有可以调整ES6实现的基准测试。ARES-6的构建目的是让我们能够研究新语言功能对我们引擎的影响。本节描述了我们对JavaScriptCore进行重大更改的三个领域,以提高我们在ARES-6上的得分。我们改进了生成器实现,使生成器函数能够完全访问我们的优化JIT。我们重写了Map/Set实现,以便我们的JIT编译器可以内联Map/Set查找。最后,我们添加了重要的新逃逸分析功能,以消除与扩展运算符一起使用rest参数时的对象分配。

高吞吐量生成器

ES6生成器的性能对JavaScript引擎的整体性能至关重要。随着ES6的普及,我们预计生成器将被频繁使用。生成器是一种语言支持的方式,用于挂起和恢复执行上下文。它们可以用于实现值流和自定义迭代器,并且是ES2017中asyncawait的基础,后者将臭名昭著的异步Promise编程模型简化为直接风格编程模型。为了实现高性能,我们重新设计的生成器实现的首要目标是在我们所有的JIT层中都有一个健全而简单的编译策略。

生成器必须在yield表达式处挂起和恢复执行上下文。为此,JSC需要保存和恢复其执行状态:指令指针和当前堆栈帧。虽然我们需要保存逻辑JavaScript指令指针以便在程序中的适当点恢复执行,但机器的指令指针可能会改变。如果函数在较高JIT层(如基线、DFG和FTL)中编译,我们必须在恢复时选择最佳层中编译的代码。此外,如果编译后的代码被去优化,指令指针可能会失效。

JSC的字节码是一种3AC风格(三地址码)的IR(中间表示),它在虚拟寄存器上操作。字节码允许“无限”数量的寄存器,并且堆栈帧包含用于存储每个寄存器的槽。实际上,使用的虚拟寄存器数量很少。我们生成器实现的关键在于它是对JSC字节码的转换。这完全隐藏了生成器的复杂性,使其不影响引擎的其余部分。在字节码生成器化之前的阶段(解析、从AST生成字节码)可以将yield视为函数调用——这对这些阶段来说是最简单的事情。最棒的是,生成器化之后的阶段无需了解任何关于生成器的信息。这是一个很棒的结果,因为我们已经拥有庞大的解释器和JIT编译器基础设施来消费这些字节码。

在生成原始字节码时,JSC的字节码编译器对待生成器,大多将其视为具有正常控制流的程序,其中每个“yield”点只是一个接受参数并产生值的表达式。然而,这并非yield在机器层面的实现方式。为了将这种初始形式的字节码转换为能够正确恢复状态的版本,我们重写了生成器的字节码,将函数转换为状态机。

为了在yield点正确恢复指令指针,我们的字节码转换在每个生成器函数的顶部插入一个switch语句。它对一个整数进行switch操作,该整数表示生成器在被调用时应在程序何处恢复。其次,我们必须有一种方法来在每个yield点恢复每个虚拟寄存器。事实证明,JSC已经有了做到这一点的机制。我们将这个问题转化为将原始字节码中的每个字节码虚拟寄存器转换为JSC环境记录数据结构中的闭包变量的问题。每个转换后的生成器都会分配一个闭包来存储所有生成器状态。每个yield点将每个活跃虚拟寄存器保存到闭包中,每个恢复点从闭包中恢复每个活跃虚拟寄存器。

由于这种字节码转换仅通过添加闭包和switch语句来改变程序,因此这种方法轻而易举地达到了我们可以在优化编译器层进行编译的目标。JSC的优化编译器在优化switch语句和闭包变量方面已经做得很好。生成器的调度仅凭使用switch字节码就获得了JSC所有的switch降低优化。这包括我们B3 JIT出色的switch降低优化,以及在较低JIT层和解释器中该优化较不激进的版本。DFG也对闭包进行了大量优化。这包括推断闭包变量的类型,在大多数情况下将对闭包的加载和存储优化为单个指令,以及正确建模闭包访问的别名效应。我们无需在优化编译器中引入新构造即可获得这些好处。

示例

让我们通过一个JavaScript生成器函数的示例来演示字节码转换

function *generator()
{
    var temp = 20;
    var result = yield 42;
    return result + temp;
}

JSC生成以下图中所示的字节码序列,该序列表示控制流图

Pre-Generatorification bytecode

当AST到字节码编译器遇到yield表达式时,它只是发出特殊的op_yield字节码操作。这个操作码是一个。它不被我们任何执行引擎识别。但它具有明确的语义,这使我们能够将其视为闭包和switch的字节码语法糖。

Generatorification

脱糖(Desugaring)由生成器化(generatorification)字节码阶段执行。上图解释了生成器化如何工作。生成器化重写字节码,以便不再有op_yield语句,并且生成的函数使用switch语句选择要执行的代码。闭包风格的属性访问字节码用于保存/恢复op_yield语句曾经所在位置的状态。

以前,JSC字节码是不可变的。它用于将信息从字节码生成器(在遍历AST时作为副产品写入字节码)传递给编译器,编译器会消费它以创建其他副产品(机器码或其他编译器IR)。生成器化改变了这一点。为了方便这个阶段,我们为JSC创建了一个通用字节码重写工具。它允许我们插入和删除任何类型的字节码指令,包括跳转。它允许我们对字节码流执行复杂的控制流编辑。我们使用它来简化生成器化,并期待将来使用它来实现其他疯狂的语言功能。

为了允许在op_yield处挂起和恢复执行上下文,重写器在op_enter操作码之后插入op_switch_imm操作码。在op_yield处,转换后的函数保存表示虚拟指令指针的整数,然后使用op_ret从函数返回。然后,在恢复时,我们使用插入的op_switch_imm和保存的整数,跳转到紧跟在挂起上下文的op_yield之后的位置。

为了保存和恢复活跃寄存器,此过程执行活跃性分析,以查找op_yield处的活跃寄存器,然后插入op_put_to_scopeop_get_from_scope操作以分别保存和恢复它们的状态。这些操作码是我们闭包对象模型的一部分,这在这里恰好适用,因为JSC的闭包只是静态知道其字段及其布局方式的对象。这允许快速分配和快速访问,我们已经为此花费了大量时间来优化实际的JS闭包。在此图中,生成器化执行活跃性分析并发现变量tempop_yield点是活跃的。因此,重写器发出op_put_to_scopeop_get_from_scope来保存和恢复temp。这不会破坏我们优化编译器推断temp值的能力,因为我们之前已经解决了保存到闭包的变量的相同问题。

总结

生成器化重写使用字节码脱糖,将生成器的整个概念封装到一个字节码阶段中,该阶段发出符合JSC惯用法的字节码。这使得我们的整个优化基础设施能够加入生成器优化行列。使用生成器的程序现在可以在我们所有的JIT层中运行。这项更改落地时,它将Basic的平均得分提高了41%。它还全面改善了Basic:即使是首次迭代得分也快了3%,因为我们的低延迟DFG优化JIT旨在即使对于启动代码也能提供提升。

Generator: Safari-to-WebKit Nightly comparison

上图中的数据是在一台2014年中期、2.8 GHz Core i7、16GB RAM、15英寸MacBook Pro上获取的,运行macOS Sierra 10.12.0版。Safari 10.0.2是首个搭载JavaScriptCore完整ES6实现的Safari版本。Safari 10.0.2包含本文所述的任何优化。上图显示,自实现ES6以来,我们在JSC运行Basic的性能方面取得了显著提升。我们在首次迭代得分方面实现了1.2倍的提升,在最差4次迭代得分方面实现了1.4倍的提升,在平均得分方面实现了2.8倍的提升。

脱糖(Desugaring)是一种经典技术。当复杂的编译器算法被限制在其各自的阶段时,最容易理解,因此我们将字节码转变为一个成熟的编译器IR,并将生成器化作为该IR上的一个编译器阶段来实现。我们新的字节码重写器功能强大,足以支持多种类型的阶段。它可以插入和删除任何类型的字节码指令,包括跳转,从而实现复杂的控制流编辑。虽然目前仅用于生成器,但该重写工具将来可用于实现复杂的ECMAScript功能。

快速 Map 和 Set

Map和Set是ES6中两个新的API,它们使编程语言更具表现力。在ES6之前,JavaScript没有官方的哈希表API。虽然一直可以使用JavaScript对象作为哈希表,但仅限于使用字符串Symbol作为键,这远不如能够以任何值作为键的哈希表有用。ES6通过添加接受任何JavaScript值作为键的新Map和Set类型,弥补了这一古老的语言缺陷。

Air和Basic都使用了Map和Set。分析显示,迭代和查找是迄今为止最常见的操作,这与我们在语言其他方面的经验相当一致。例如,我们一直发现优化属性加载比优化属性存储更重要,因为加载更为常见。本节将介绍我们新的Map/Set实现,我们针对ARES-6的迭代和查找模式对其进行了优化。

快速查找

为了实现快速查找,我们需要让我们的JIT编译器了解我们哈希表的内部工作原理。这样做主要的性能优势来自于将哈希表查找内联到函数的IR中。这避免了调用C++代码的需求。它还允许我们通过利用编译器的智能更有效地实现查找。为了理解原因,让我们更详细地分析哈希表查找。JSC的哈希表实现基于线性探测算法。当您在JS程序中执行map.has(key)时,我们实际上在底层执行了一些抽象操作。让我们将这些操作分解为伪代码

let h = hash(key);
let bucket = map.findBucket(h);
let has = !isEmptyBucket(bucket);
return has;

您可以将findBucket(h)操作视为

let bucket = startBucket(h);
while (true) {
    if (isEmptyBucket(bucket))
        return emptyBucketSentinel;

    // Note that the actual key comparison for ES6 Map and Set is not triple equals.
    // But it's close. We can assume it's triple equals for the sake of this explanation.
    if (bucket.key === key)
        return bucket;

    h = nextIndex(h);
}

这里有许多可以基于我们关于key的信息进行优化的地方。编译器通常会知道key的类型,从而允许它发出更高效的hash函数,并在findBucket循环内部进行更高效的严格相等(triple equals)比较。C代码必须处理所有可能类型的JavaScript key值。然而,在编译器内部,如果我们知道key的类型,我们可能能够发出一个只需要单个机器比较指令的严格相等比较。key上的hash函数也受益于了解key的类型。C++实现的hash函数必须处理所有类型的key。这意味着它必须对key进行一系列类型检查以确定如何进行哈希。原因是我们将数字、字符串和对象进行哈希的方式不同。JIT编译器通常能够证明key的类型,从而使我们能够避免通过多个分支来学习key的类型。

仅这些更改就已使Map和Set快得多。然而,由于编译器现在了解哈希表的内部工作原理,它可以用几种巧妙的方式进一步优化代码。让我们考虑Map API的一种常见用法

if (map.has(key))
    return map.get(key);
...

为了理解我们的编译器优化如何发挥作用,让我们看看我们的DFG(数据流图)IR如何表示这个程序。JSC使用DFG IR来执行高级JavaScript特定优化,包括基于推测和自修改代码的优化。这个哈希表程序将被大致转换为以下DFG IR(实际的DFG IR转储包含更多信息)

    BasicBlock #0:
        k: GetArgument("key")
        m: GetArgument("map")
        h: Hash(@k)
        b: GetBucket(@m, @h, @k)
        c: IsNonEmptyBucket(@b)
        Branch(@c, True:#1, False:#2)

    BasicBlock #1:
        h2: Hash(@k)
        b2: GetBucket(@m, @h2, @k)
        r: LoadFromBucket(@b2)
        Return(@r)

    BasicBlock #2:
        ...

DFG IR允许作为clobberize分析的一部分,精确建模每个操作的效果。我们教clobberize如何处理我们所有新的哈希表操作,如GetBucketIsNonEmptyBucketHashLoadFromBucket。DFG的CSE(公共子表达式消除)阶段利用这些数据运行:它可以看到相同的HashGetBucket操作执行了两次,而中间没有任何可能改变map状态的操作。许多其他DFG阶段使用clobberize来理解操作的别名和效果。这解锁了循环不变代码外提和许多其他优化。

在这个例子中,优化后的程序将是这样的

    BasicBlock #0:
        k: GetArgument("key")
        m: GetArgument("map")
        h: Hash(@k)
        b: GetBucket(@m, @h, @k)
        c: IsNonEmptyBucket(@b)
        Branch(@c, True:#1, False:#2)

    BasicBlock #1:
        r: LoadFromBucket(@b)
        Return(@r)

    BasicBlock #2:
        ...

我们的编译器能够将hasget中冗余的哈希表查找优化为单次查找。在C++中,哈希表API会竭尽全力提供避免此处这种冗余查找的方法。由于ES6只提供了非常简单的Map/Set API,因此很难避免冗余查找。幸运的是,我们的编译器通常会为您消除它们。

快速迭代

当我们编写Basic和Air时,我们经常发现自己在迭代Map和Set,因为编写这样的代码是自然和方便的。因此,我们希望Map和Set的迭代速度快。然而,如何做到这一点并不立即显而易见,因为ES6的Map和Set按插入顺序迭代键。此外,如果Map或Set在迭代期间被修改,迭代器必须反映这些修改。

我们需要使用一种可以快速迭代的数据结构。一个显而易见的选择是使用链表。迭代链表速度很快。然而,在链表中测试某个元素是否存在是O(n)的操作,其中n是列表中元素的数量。为了兼顾哈希表的快速查找和链表的快速迭代,我们选择了一种混合方法:组合式链表哈希表。每当有东西添加到Map或Set时,它都会添加到链表的末尾。链表中的每个元素都是一个桶。哈希表内部查找缓冲区中的每个条目都指向链表中的一个桶。

这最好通过图示来理解。考虑以下程序

let map = new Map;
map.set(1, "one");
map.set(2, "two");
map.set(3, "three");

传统的线性探测哈希表会是这样

Traditional linear probing hashtable

然而,在我们的方案中,我们需要知道迭代哈希表的顺序。因此,我们的组合式链表哈希表将是这样

Combo linked-list hashtable

如前所述,当在迭代Map或Set同时更改它时,迭代器必须迭代新添加的值,并且不得迭代已删除的值。使用链表数据结构进行迭代,可以自然地实现这一要求。在JSC内部,Map和Set迭代器只是哈希表桶的包装器。桶是垃圾回收的,所以即使桶已从哈希表中移除,迭代器仍可以持有该桶。当一个项从哈希表中删除时,通过更新被删除桶的邻居指向彼此而不是被删除桶,该桶将从链表中移除。关键在于,被删除的桶仍将指向其邻居。这使得迭代器对象仍可以指向被删除的桶,然后找到非删除的桶。向这样的迭代器请求其值时,迭代器将沿着其邻居指针遍历,直到找到下一个非删除的桶。这里的关键见解是,被删除的桶总可以通过连续追逐其邻居指针来找到下一个非删除的桶。请注意,这种指针追逐可能会导致迭代器到达列表末尾,在这种情况下,迭代器将关闭。

例如,考虑之前的程序,现在键2已被删除

let map = new Map;
map.set(1, "one");
map.set(2, "two");
map.set(3, "three");
map.delete(2);

现在,哈希表将是这样

Combo linear probing with entry 2 deleted

让我们考虑当有一个指向键2的桶的迭代器时会发生什么。当在该迭代器上调用next()时,它将首先检查它指向的桶是否已被删除,如果是,它将遍历链表直到找到第一个未删除的条目。在这个例子中,迭代器会注意到键2的桶已被删除,它将遍历到键3的桶。它会看到键3未被删除,然后它将生成其值。

总结

我们发现在我们编写的ES6代码中大量使用了Map/Set,因此我们决定优化这些数据结构,以进一步鼓励它们的使用。我们的Map/Set重写以一种最自然的方式表示了哈希表的底层数据结构,以便我们的垃圾回收器和DFG编译器进行推理。这项重写提交时,它使ARES-6的整体性能提高了8%。

幻影扩展

我们发现自己使用的另一个ES6特性是rest参数和扩展运算符的组合。这是一种直观的编程模式:使用rest参数将一些参数收集到一个数组中,然后使用扩展运算符将它们转发给另一个函数调用。例如,这是我们在Air基准测试中使用它的一种方式

visitArg(index, func, ...args)
{
    let replacement = func(this._args[index], ...args);
    if (replacement)
        this._args[index] = replacement;
}

对rest参数进行扩展运算符的朴素实现将导致此代码运行速度比所需更慢,因为扩展运算符要求对其参数执行迭代器协议。由于函数visitArg正在创建args数组,DFG编译器精确地知道将存储哪些数据。具体来说,它将填充除visitArg的前两个参数之外的所有参数。我们选择为这种编程模式实现一个优化,因为我们认为rest参数的扩展将成为一种常见的ES6编程惯用语。在JSC中,我们还为这种编程惯用语的ES5变体实现了优化

foo()
{
    bar.apply(null, arguments);
}

DFG可以发出的理想代码,用于设置visitArg内部对func的调用的堆栈帧,是从其自身堆栈帧上的参数到func堆栈帧的memcpy。为了能够发出这样的代码,DFG编译器必须证明这种优化是健全的。为此,DFG必须证明几件事。它首先必须确保数组迭代器协议不可观察。它通过确保以下几点来证明它不可观察:

通过执行这样的证明,DFG精确地知道协议将做什么,并且可以在内部模拟其行为。其次,DFG必须证明args数组自从被堆栈中的值填充以来没有改变。它通过执行逃逸分析来做到这一点。逃逸分析将对以下问题给出保守的答案:args数组自函数入口创建以来是否已更改?如果答案是“否”,那么DFG不需要分配args数组。它可以在设置func的堆栈帧时简单地执行memcpy。这是巨大的速度提升,原因有几个。主要的提速来自于避免执行高开销的迭代器协议。次要的改进是避免了为args数组进行堆对象分配。当DFG成功执行此优化时,我们称之为将Spread转换为PhantomSpread。此优化利用了DFG不仅能够优化对象分配,而且能够在推测执行失败并回退到无优化执行字节码时重新实例化它们的能力。

Air: Safari-to-WebKit Nightly comparison

上图中的数据是在一台2014年中期、2.8 GHz Core i7、16GB RAM、15英寸MacBook Pro上获取的,运行macOS Sierra 10.12.0版。幻影扩展是我们DFG JIT编译器的一个重要新添功能。幻影扩展优化、Map/Set的重写以及其他整体JSC改进表明,自Safari 10.0.2以来,我们已将Air的平均得分加速了近2倍。至关重要的是,我们并没有以牺牲首次迭代和最差4次迭代得分的方式来实现这种加速。这两项得分也取得了进步。

性能结果

我们认为,保持JavaScriptCore性能透明的一个好方法是与现有最佳性能基线进行比较。因此,我们定期将我们的性能与JavaScriptCore的过去版本以及其他JavaScript引擎进行比较。本节展示了JavaScriptCore与Firefox和Chrome的JavaScript引擎相比如何。

本节中的图表使用了在2016年末、2.9 GHz Core i5、8GB RAM、13英寸MacBook Pro上获取的数据,运行macOS High Sierra Beta 1。

Overall Performance Results

上图显示了三种不同浏览器(Safari 11 (13604.1.21.0.1)、Firefox 53.0.3和Chrome 58.0.3029.110)的ARES-6总分。我们的数据显示,我们比Chrome快近1.8倍,比Firefox快近5倍。

Detailed Performance Results

上图显示了所有四个基准测试及其三个得分的详细结果。它表明JavaScriptCore不仅在总分上,而且在每个单独子测试的所有三个得分上,都是运行ARES-6最快的引擎。

结论

ES6是JavaScript语言的一个重大改进,我们在创建ARES-6时很高兴对其进行了一次测试。编写Air和Basic,并导入Babylon和ML,使我们更好地掌握了ES6功能可能的使用方式。向我们的测试库中添加新基准测试总是能教会我们新的东西。从长远来看,只有我们不断添加新基准测试而不是过度优化同一套陈旧基准测试,这才能对我们有利。展望未来,我们计划为ARES-6添加更多子测试。我们认为增加更多测试将使我们保持诚实,不至于过度调整特定ES6代码模式。如果您有令人兴奋的ES6(或ES7及更高版本)代码,并且认为值得包含在ARES-6中,我们很乐意听取您的意见。您可以通过提交错误报告或在Twitter上联系FilipSaamYusuke来与我们联系。