【转载】V8提升了Class新特性的初始化效率

472 阅读11分钟

20220526171100.jpg

图片来源:截图自 v8.dev/logo

文章来源:V8 官方文档

  从 v7.2 版本发布以来,Class关键字已经能够直接在V8引擎中直接使用了,v8.2版本之后,Class中的私有属性方法也能够直接使用。在提案于 2021 年进入第 4 阶段后,开始改进对 V8 中新类特性的支持的工作——在此之前,有两个主要问题影响了它们的使用:

  1. 类和其私有方法的初始化要比普通属性的初始化慢得多。
  2. 当 Node.js 和 Deno 等嵌入器使用启动快照来用于自身和用户应用程序加速启动时,类初始化会发生崩溃。

  第一个问题已经在v9.7版本中得以修复,第二个问题是在10.0版本中被修复,具体的修复内容可以查看The post

类中的优化字段

  为了消除普通属性初始化和类字段初始化之间的性能差距,我们针对后者更新了现有的 内联缓存(IC)系统。在v9.7 之前,V8 总是使用昂贵的运行时来进行类字段初始化。在 v9.7 中,当V8认为初始化模式足够可预测时,它会使用新的 IC策略 来加速类初始化,就像它对普通属性所做的那样。

初始化性能对照表

1.png

解释执行性能对照表

类字段的原生实现

  为了实现私有字段,V8 使用了内部私有符号 # ——它们是类似于标准符号的内部 V8 数据结构,除了在用作属性时不可枚举。以这个类为例:

class A {
  #a = 0;
  b = this.#a;
}

  V8 将收集类字段初始化中的属性字段,如#a = 0 和 b = this.#a,同时生成一个合成实例成员函数,其中初始化器作为函数体。生成这个合成函数的字节码是这样的:

// Load the private name symbol for `#a` into r1
LdaImmutableCurrentContextSlot [2]
Star r1

// Load 0 into r2
LdaZero
Star r2

// Move the target into r0
Mov <this>, r0

// Use the %AddPrivateField() runtime function to store 0 as the value of
// the property keyed by the private name symbol `#a` in the instance,
// that is, `#a = 0`.
CallRuntime [AddPrivateField], r0-r2

// Load the property name `b` into r1
LdaConstant [0]
Star r1

// Load the private name symbol for `#a`
LdaImmutableCurrentContextSlot [2]

// Load the value of the property keyed by `#a` from the instance into r2
LdaKeyedProperty <this>, [0]
Star r2

// Move the target into r0
Mov <this>, r0

// Use the %CreateDataProperty() runtime function to store the property keyed
// by `#a` as the value of the property keyed by `b`, that is, `b = this.#a`
CallRuntime [CreateDataProperty], r0-r2

我们试着将前面代码段类的初始化与下面代码中的类初始化进行比较:

class A {
  constructor() {
    this._a = 0;
    this.b = this._a;
  }
}

  从技术实现本质上讲,这两个类其实并不等同,即使忽略 this.#a 和 this._a 之间的可见性差异。该规范要求“定义”语义而不是“设置”语义。也就是说,类字段的初始化并不会触发 setter方法 或 Proxy代理中的set钩子。所以第一个类的近似值应该使用 Object.defineProperty() 这种特殊的赋值方式而不是简单的赋值来初始化属性。另外,如果私有字段已经存在于实例中,它应该抛出这部分信息(如果正在初始化的目标在基本构造函数中被覆盖为另一个实例)

下面是这种私有属性类赋值的实现方式:

class A {
  constructor() {
    // %AddPrivateField() 这个字节码调用大致转换为:
    // 注意下面这段是伪代码
    const _a = %PrivateSymbol('#a')
    if (_a in this) {
      throw TypeError('Cannot initialize #a twice on the same object');
    }
    Object.defineProperty(this, _a, {
      writable: true,
      configurable: false,
      enumerable: false,
      value: 0
    });
    // %CreateDataProperty() 这个字节码调用大致转换为:
    Object.defineProperty(this, 'b', {
      writable: true,
      configurable: true,
      enumerable: true,
      value: this[_a]
    });
  }
}

  为了在提案最终确定之前实现指定的语义,V8 会在 运行时 进行方法函数的调用,因为这样函数的调用会更加灵活。如上面的字节码所示,公共字段的初始化是通过 %CreateDataProperty() 运行时调用实现的,而私有字段的初始化是通过 %AddPrivateField() 实现的。由于调用运行时会产生很大的开销,因此类字段的初始化与普通对象属性的初始化分配相比就要要慢得多了。

  然而,在大多数情况中,这两种语义差异其实是微不足道的。而在这些情况下能够拥有优化的属性分配,会使得性能更好——因此在提案最终确定后创建了一个更优化的实现方式。

优化私有属性和公共计算属性

  为了加快私有属性和公共计算属性的初始化,该实现引入了一种新机制,以便在处理这些操作时执行 内联缓存 (IC) 系统。这种新机制分为三个协作部分:

  • 在字节码生成器中,用了一个新的字节码 DefineKeyedOwnProperty,当表示类字段初始化器的 ClassLiteral::Property AST节点生成代码时,就会产生改字节码

  • 在 TurboFan JIT(一种运行时编译器) 中,对应的 IR 操作码 JSDefineKeyedOwnProperty,JSDefineKeyedOwnProperty可以从新的字节码中编译出来。

  • 在内联缓存系统中,新的 DefineKeyedOwnIC 用于新字节码的解释器处理程序以及从新 IR 操作码编译的代码。为了简化实施,新IC重用了 KeyedStoreIC 中的一些代码,这些代码原本是用于普通属性的。

当V8遇到这种类型的类时:

class A {
  #a = 0;
}

#a = 0 会生成下面的字节码

// Load the private name symbol for `#a` into r1
LdaImmutableCurrentContextSlot [2]
Star0

// Use the DefineKeyedOwnProperty bytecode to store 0 as the value of
// the property keyed by the private name symbol `#a` in the instance,
// that is, `#a = 0`.
LdaZero
DefineKeyedOwnProperty <this>, r0, [0]

  当初始化器执行足够多次时,V8 会为每个正在初始化的字段分配一个反馈向量槽。该插槽包含要添加的字段的键(在私有字段的情况下是私有名称符号)和一对隐藏类,由于字段初始化,实例已在这些隐藏类之间转换。在随后的初始化中,IC 使用反馈来查看字段是否在具有相同隐藏类的实例上以相同的顺序初始化。如果初始化与 V8 之前看到的模式匹配(通常是这种情况),V8 会采用快速路径并使用预生成的代码执行初始化,而不是调用运行时来初始化,从而加快执行速度。如果初始化与 V8 之前看到的模式不匹配,它会回退到运行时调用,这种情况的速度就会变慢。

优化公共属性

为了加快命名公共类字段的初始化,V8重用了现有的 DefineNamedOwnProperty 字节码,它的作用是在解释器中或者是从 JSDefineNamedOwnProperty IR 操作码来调用 DefineNamedOwnIC

现在当 V8 遇到这个类时:

class A {
  #a = 0;
  b = this.#a;
}

它为 b = this.#a 初始化器生成以下字节码:

// Load the private name symbol for `#a`
LdaImmutableCurrentContextSlot [2]

// Load the value of the property keyed by `#a` from the instance into r2
// Note: LdaKeyedProperty is renamed to GetKeyedProperty in the refactoring
GetKeyedProperty <this>, [2]

// Use the DefineKeyedOwnProperty bytecode to store the property keyed
// by `#a` as the value of the property keyed by `b`, that is, `b = this.#a;`
DefineNamedOwnProperty <this>, [0], [4]

  原始的 DefineNamedOwnIC 机制不能简单地插入到公共属性的处理中,因为它最初仅用于对象字面量初始化。以前它的作用就是初始化一些对象字面量,但是当类扩展其构造函数覆盖目标的基类时,该机制可以在用户定义的对象上初始化类:

class A {
  constructor() {
    return new Proxy(
      { a: 1 },
      {
        defineProperty(object, key, desc) {
          console.log('object:', object);
          console.log('key:', key);
          console.log('desc:', desc);
          return true;
        }
      });
  }
}

class B extends A {
  a = 2;
  #b = 3;  // Not observable.
}

// object: { a: 1 },
// key: 'a',
// desc: {value: 2, writable: true, enumerable: true, configurable: true}
new B();

  为了处理实现这些目标,我们对 IC 进行了修补,以便在当它看到正在初始化的对象是proxy、如果正在定义的字段已经存在于对象上,或者如果该对象只有一个隐藏类时,回退到运行时状态。如果边缘情况变得足够普遍,仍然可以对其进行优化,但到目前为止,为了实现的简单性,似乎更好的方式就是比较它们的性能,做出选择。

优化私有方法

私有方法的执行流程

  在规范中,私有方法被描述为是实例的一部分而不是类的一部分。然而,为了节省内存,V8 的实现将私有方法与私有标识符号一起存储在与类关联的上下文中。调用构造函数时,V8 仅在实例中存储对该上下文的引用,并以私有标识符号作为键值标识。

  当访问私有方法时,V8 从执行上下文开始遍历上下文关系链以找到类上下文,从找到的上下文中读取一个静态已知的槽以获取该类的私有标识符号,然后检查实例是否具有该属性,并用这个标识符号键入,看看实例是否是从这个类创建的。如果符号标识检查通过,V8 从同一上下文中的另一个已知插槽加载私有方法并完成访问。

比如下面这个代码片段

class A {
  #a() {}
}

生成的字节码:

// Load the private brand symbol for class A from the context
// and store it into r1.
LdaImmutableCurrentContextSlot [3]
Star r1

// Load the target into r0.
Mov <this>, r0
// Load the current context into r2.
Mov <context>, r2
// Call the runtime %AddPrivateBrand() function to store the context in
// the instance with the private brand as key.
CallRuntime [AddPrivateBrand], r0-r2

  可以看到由于还调用了运行时函数 %AddPrivateBrand(),该构造函数的初始化的速度要比只有公共方法的类的构造函数慢得多。

优化私有属性标识符#

  为了加快私有属性标识符的装载,在大多数情况下,V8会重用 DefineKeyedOwnProperty

// Load the private brand symbol for class A from the context
// and store it into r1
LdaImmutableCurrentContextSlot [3]
Star0

// Use the DefineKeyedOwnProperty bytecode to store the
// context in the instance with the private brand as key
Ldar <context>
DefineKeyedOwnProperty <this>, r0, [0]

构造函数不同方法导致的初始化性能不同:

  有一点需要注意的是:如果该类是派生类并且调用了 Super(),该类的私有属性标识符装载是在 Super 调用之后:

class A {
  constructor() {
    // This throws from a new B() call because super() has not yet returned.
    this.callMethod();
  }
}

class B extends A {
  #method() {}
  callMethod() { return this.#method(); }
  constructor(o) {
    super();
  }
};

  如前所述,在初始化标识符时,V8 还会在实例中存储对类上下文的引用。此引用不用于标识符检查,而是用于调试器从实例中检索私有方法列表,而不知道它是从哪个类构造的。当在构造函数中直接调用 super() 时,V8 可以简单地从上下文寄存器中加载上下文(这是上面字节码中的 Mov <context>r2Ldar <context> 所做的)来执行初始化,但是 super( ) 也可以从嵌套的箭头函数调用,而该函数又可以从不同的上下文调用。在这种情况下,V8 回退到一个运行时函数(仍然命名为 %AddPrivateBrand())来在上下文链中查找类上下文,而不是依赖于上下文寄存器。例如,对于下面的 callSuper方法函数

class A extends class {} {
  #method() {}
  constructor(run) {
    const callSuper = () => super();
    // ...do something
    run(callSuper)
  }
};

new A((fn) => fn());

下面是V8的生成的字节码:

// Invoke the super constructor to construct the instance
// and store it into r3.
...

// Load the private brand symbol from the class context at
// depth 1 from the current context and store it into r4
LdaImmutableContextSlot <context>, [3], [1]
Star4

// Load the depth 1 as an Smi into r6
LdaSmi [1]
Star6

// Load the current context into r5
Mov <context>, r5

// Use the %AddPrivateBrand() to locate the class context at
// depth 1 from the current context and store it in the instance
// with the private brand symbol as key
CallRuntime [AddPrivateBrand], r3-r6

  在这种情况下,运行时调用的成本又增加了,因此与仅使用公共方法初始化类的实例相比,初始化此类的实例仍然会更慢。可以使用专用字节码来实现 %AddPrivateBrand() 所做的事情,但是由于在嵌套箭头函数中调用 super() 非常罕见,我们再次以性能为代价换取了实现的简单性。

最后

这篇文章所提到的修改点都能在Node.js 18.0.0 release中查看。之前Node为了将私有字段包含在嵌入式引导快照程序中的同时,提高构造函数的性能,因此会在一些内置类中用symbol属性来代替私有字段。具体可以看这篇文章,不过随着V8改进了对类功能的支持,Node重新使用了私有字段,并且性能也没有下降。

个人感言

文章讲述了V8在支持新特性的过程中不断提升其初始化性能,其中就包括内联缓存的使用,这部分的内容我们团队会做更详细的介绍,当前读完这篇文章后你能够明白一点:减少在constructor初始化和重复初始化类属性,比如下面

class Demo {
    name: 'Demo',
    constructor() {
        this.name = 'Demo2';
        this.address = 'DFC';
    }
}

这种操作可能会破坏V8在初始化类的效率(虽然为了提升这种效率还不如优化下网络和资源大小啥的😄😄 )。