ES6 功能完备

截至 r202125,JavaScriptCore 支持 ECMAScript 6 (ES6) 语言规范中的所有新功能。所有新的 ES6 功能均可在最新的 WebKit NightlySafari 技术预览版中获取。虽然我们已经实现了模块,但尚未最终确定面向 Web 的 API。ES6 为 JavaScript 语言添加了大量强大的功能,所有 JavaScriptCore 贡献者都对 JavaScript 的未来充满期待。我们一直努力不仅实现新的 ES6 功能,而且确保这些功能表现出色。然而,今天我们想讨论 ES6 功能开发中一个不同但同样重要的方面,即保持现有 ES5 网站和应用程序的当前性能。

ES6 中的 RegExp 更改

ES6 为 JavaScript 正则表达式添加了令人难以置信的可定制性。它允许开发者通过 Symbol 属性方法自定义 String.prototype 函数如何处理 RegExp 参数。例如,String.prototype.match() 尝试在其第一个参数上使用 Symbol.match 属性来执行匹配[1]。默认情况下,RegExp.prototype 为每个相应的 String.prototype 操作(Symbol.replaceSymbol.searchSymbol.match 等)提供了函数,如果开发者需要自定义行为,可以更改这些函数。此外,更重要的是,ES6 规定了每个 RegExp.prototype 函数如何访问或调用所有 RegExp 属性,例如 flagsglobalexec。如果简单地实现,这些新功能虽然为希望使用它们的开发者提供了更多功能,但对于不使用它们的网页来说,会带来性能成本。

JavaScriptCore 虚拟机 (VM) 的目标之一是确保开发者不会为他们不使用的功能付出代价。对于 RegExp 函数,我们的目标意味着现有网页不应看到页面 RegExp 代码的性能受到影响。为了保持相同的性能,JavaScriptCore 需要尽可能避免查找和执行函数和 getter。例如,如果 JavaScriptCore 能够确保每次调用 String.prototype.match() 都传递一个 RegExp 对象,该对象不会覆盖或修改 match 的任何相关属性(Symbol.matchexecglobalunicode),那么它就可以使用更快、专门的实现来执行 match()。在知道这些属性没有改变的情况下,专门的实现可以避免潜在昂贵的属性查找,并且可以直接从 RegExp 对象加载 match 函数所需的任何信息。这也允许专门的实现内联 exec 函数。

JavaScriptCore VM 采用多层引擎设计,其中每一层都进行渐进式更高级的优化。JavaScriptCore 的最高层对代码正在做什么进行推测,这使得原本不可能的优化成为可能。有关 JavaScriptCore 分层架构的更多信息,可以在以前的博客文章[2][3]中找到。为了在引擎的优化层中对 RegExp 对象上的属性进行推测,我们需要一种不产生可观察效果的属性查找方式。

为此,添加了一个新的字节码 TryGetById,其行为类似于标识符上普通属性查找的字节码 GetById。如果属性未设置或是一个正常值(JavaScript 有一些具有特殊效果的特殊属性,例如 [].length,它们被排除在外),那么 TryGetById 只返回适当的值。否则,如果属性是访问器,TryGetById 返回我们内部的 GetterSetter 对象,该对象与预期的原始值进行比较。由于访问器具有与其关联的 getter 和 setter,GetterSetter 对象是 VM 用于保存这些函数的内部对象。就其本身而言,TryGetById 并不能解决与 ES6 行为相关的性能问题,因为 TryGetById 的工作是证明这些值是 VM 期望的。然而,随着代码进入 VM 的优化编译器,从 TryGetById 在较低层中收集的信息使我们能够删除执行专门代码所需的几乎所有检查。

结构

在详细介绍 TryGetById 如何优化之前,了解结构(Structures)的概念很有用。熟悉结构(也常被称为形状或映射)概念的读者可以跳到下一节。结构起源于 20 世纪 80 年代,作为 Smalltalk 和 Self 语言的优化[4],它是一种表示对象上属性内存布局的方式。在 JavaScript 中,存储对象属性的一种简单方法是让每个对象都是一个从标识符到其相应值/访问器的哈希映射。虽然使用哈希映射确实可行,但它没有利用对象常用的方式。在 JavaScript 中,通过调用构造函数创建对象非常常见,这会添加多个属性。大多数情况下,由相同函数构造的对象将共享相同的属性集。结构是一种利用这些对象之间相似性的方式。考虑以下示例:

function Point(i, j) {
    this.x = i;
    this.y = j;
}

let p1 = new Point(1, 2);
let p2 = new Point(3, 4);

尽管 p1p2 具有不同的值,但它们都以相同的方式初始化。即,当调用 new Point() 时,会分配一个新的空对象 o。然后将属性 x 添加到该对象,接着是属性 y。在上述示例中,当代码将 this.x 赋值给 i 时,VM 首先在 o 的结构上查找属性 x。由于 o 最初是一个空对象,它将具有所有没有属性的普通 JavaScript 对象共享的初始结构。该结构不会有属性 x 的条目。因此,它需要被添加。

由于结构是不可变的,为了向对象添加 x 属性,VM 需要将对象的结构替换为另一个,该结构除了对象的所有旧属性外,还包含 x 的条目。这种结构的替换就是我们所说的转换。除了添加新属性之外,还有许多其他原因可能需要进行转换,例如删除属性、更改属性的特性(通过 Object.defineOwnProperty)或更改原型。

每当执行结构转换以添加新属性时,VM 首先会查看是否有其他共享相同结构的对象添加了与我们即将添加的属性相同标识符的属性。如果存在这样的结构,则会重用它。另一方面,如果不存在,则分配一个新的结构,在将新属性添加到新结构之前,复制当前结构上的所有属性。然后,我们将对象的结构指针更改为新结构。

在 JavaScriptCore 中,对象属性存储在一个类似数组的对象中,称为 Butterfly。Butterfly 由对象指向。结构存储一个从标识符到 Butterfly 中该属性值存储位置的偏移量的哈希映射。每个新属性都被简单地赋予下一个空闲偏移量。一旦 VM 完全初始化了新结构,它就会在旧结构上标记,任何其他添加相同标识符的新属性转换都应重用新结构。

例如,在上面的图中,我们可以看到在调用 new Point(3, 4) 的每一行,一个 Point 实例是如何转换的。每当添加一个新属性时,Point 实例都会改变其结构并向 Butterfly 添加一个新的偏移量。请注意,当属性 x 添加到结构 1 时,它被标记为转换为结构 2。类似地,当属性 y 添加到结构 2 时,它被标记为转换为结构 3。当后续的 Point 实例被创建和初始化时,它将重用相同的结构 1、2 和 3,并且将具有与第一个 Point 实例相同的 Butterfly 布局。

使用结构提供了两个主要好处。首先,它可以节省大量内存。如果程序分配数千个 Point,没有结构,每个 Point 都需要保存一个其属性的哈希表。使用结构,只需恒定量的内存来映射属性,并且每个对象只需要一个其属性的数组。此外,大多数情况下,具有相同属性的对象也具有相同的原型。因此,JavaScriptCore 不会将指向原型的指针存储在每个对象中,而是在这些对象共享的公共结构上仅存储一份原型指针的副本。下图显示了 Set 实例对象的原型链可能是什么样子。结构的第二个也是可能更重要的好处来自于:共享结构的每个对象在其 Butterfly 中都具有相同的属性布局。JavaScriptCore 利用这一事实在整个引擎中执行各种优化。

对象属性条件和自适应观察点

由于 TryGetById 参与了 GetById 使用的相同内联缓存系统(您可以在以前的博客文章中了解更多信息),因此 TryGetById 可以利用 GetById 拥有的所有优化。当缓存位于对象本身上的属性(也称为自有属性)时,内联缓存相对简单。第一次访问后,为了确保所有后续访问都快速,VM 所需要做的就是用属性的结构和偏移量重新修补几条指令。下次执行 GetById/TryGetById 时,程序将检查新对象是否与上一个对象具有相同的结构,然后 VM 可以从缓存的偏移量加载属性,跳过在结构上查找偏移量的哈希表查找。验证对象结构是 VM 知道的一种操作,被称为结构检查,并用于许多不同的目的。

从原型加载属性,就像所有新的 ES6 RegExp 更改一样,是一个更难的问题。虽然结构检查保证任何具有该结构的对象都不具有所需属性,但它不保证对象的原型也具有或不具有该属性。自 Safari 9.0 发布以来,我们为原型设计的内联缓存通过添加两个新概念——对象属性条件和自适应观察点——变得更加强大。特别是,对象属性条件和自适应观察点允许 JavaScriptCore 在某些情况下,完全消除所有堆加载以及优化代码中几乎所有用于属性访问的结构检查。

对象属性条件

对象属性条件 (OPC) 是一种约束,用于在原型链上查找属性时验证某些堆访问。对象属性条件,顾名思义,包含一个对象和该对象上的某些条件。GetById/TryGetById 使用三种属性条件:存在 (Presence)、缺失 (Absence) 和等价 (Equivalence)。存在条件表示 OPC 中的对象具有给定标识符的属性。另一方面,缺失条件表示 OPC 中的对象不具有该标识符的属性。等价观察点将在稍后讨论。对于对象上的任何 GetById/TryGetById,需要在基对象和具有属性的原型之间的原型链中的每个对象上有一个 OPC。当原型链上没有对象具有所需属性时,则需要在原型链中的每个对象上有一个 OPC。

function foo() {
    let r = new Set();
    console.log(r.toString());
}

在上面的示例中,如果我们要快速加载新创建的 Set 实例对象的 toString 属性,我们将需要两个 OPC 和一个结构检查。第一个 OPC 表示 Object.prototype 对于 toString 属性具有存在条件,第二个表示 Set.prototype 对于 toString 属性具有缺失条件。有了这些条件和结构检查,我们的 GetById/TryGetById 内联缓存可以直接加载 Object.prototype.toString 的当前值。请注意,单个结构检查就足够了,因为结构同时告诉我们对象的原型以及该对象没有 toString 属性。

自适应观察点

仅仅因为一个 OPC 在创建时是有效的,并不意味着它以后也会保持有效。程序员完全有可能删除之前存在的属性,或者在原型链上添加一个具有相同标识符的属性,从而截断旧属性。这就是自适应观察点发挥作用的地方。每当 VM 为某个对象创建一个 OPC 时,它还会创建一个自适应观察点,附加到条件对象结构上,以确保条件保持有效。如果具有被观察对象结构的某个对象发生转换,自适应观察点就会被触发。一旦触发,自适应观察点会检查其 OPC 在被观察对象上是否仍然有效。如果转换发生在被观察对象上并使 OPC 失效,则任何依赖于该条件的代码都将被丢弃。否则,自适应观察点会将其自身重新定位到被观察对象的新结构上。下图说明了当在 Set 实例对象上查找 toString 属性时,自适应观察点和对象属性条件如何与原型链交互。

一旦某些代码升级到 JavaScriptCore 的优化编译器之一,VM 就会开始利用前面提到的等价条件。等价条件本质上是一个更强的存在条件。它不仅告诉我们一个属性 p 存在于一个对象上,而且还告诉我们 p 具有一个特定值。知道 GetById/TryGetById 可能返回一个常量,这使得我们的优化编译器能够进行许多原本不可能的优化。

随着 VM 将一段代码提升到优化编译器,VM 会检查 GetById/TryGetById 在较低层中遇到的每个情况。如果 VM 发现每种情况都是从同一原型对象上的存在条件加载的,VM 会尝试将该存在条件转换为等价条件。为了确保等价条件保持有效,持有等价条件的自适应观察点需要确保对被观察对象的任何属性存储都不会替换该条件属性的现有值。尽管检查对被观察对象的每次存储会带来显著的成本,但在实践中,对原型对象的存储非常罕见,并且优化编译器带来的收益往往更大。

既然我们了解了 JavaScriptCore 执行属性加载优化的方式,那么让我们看看为什么可以使用 TryGetById 来提高正则表达式的性能。由于大多数代码不会更改 RegExp.prototype 对象上的属性,VM 通常能够通过等价条件将相关属性上的所有 TryGetById 转换为常量。我们的优化编译器随后能够识别出我们专门代码之前的所有预检查都是冗余的,并将其消除。在之前的 String.prototype.match 示例中,我们可以消除对 Symbol.matchRegExp.prototype.execRegExp.prototype.globalRegExp.prototype.unicode 都未被覆盖的检查。生成的优化代码只需对参数的 RegExp 对象执行一次结构检查,然后就可以直接进入快速的专用代码。

结论

JavaScriptCore 团队对 ES6 中的所有新功能感到非常兴奋,我们认为开发者将从中受益匪多。展望未来,我们将继续加快这些功能的速度,并计划随着我们的进展发布更多博客文章。目前,您可以在 WebKit nightly 或 Safari 技术预览版中查看我们 ES6 功能的当前实现。一如既往,请告诉我们您的想法,并告知您遇到的任何问题或错误。