阅读 822

[译] React遇到V8引擎性能瓶颈的故事

原文:v8.dev/blog/react-…

受疫情影响,在家翻译了一篇挺有意思的文章(之前有人翻译过,我感觉读起来有些吃力),这个文章尽可能的还原了当时的场景,描述了react团队研发fiber新架构时遇到的一个V8翻译时的性能问题,最后解决方式是react团队打了补丁,V8团队也改进了机制,皆大欢喜~我增删了一部分内容,帮助初入前端者理解.

正文

本文介绍了V8如何为各种JavaScript值选择最佳的内存表示形式,这些都有助于解释React核心中最近的V8性能陷阱。

JavaScript类型

每个JavaScript的值(目前)有八种不同类型之一:Number,String,Symbol,BigInt,Boolean,Undefined,Null,和Object。

除了一个值得注意的例外,这些类型可以通过typeof操作符在JavaScript中观察到:

typeof 42; // → 'number'

typeof 'foo'; // → 'string'

typeof Symbol('bar'); // → 'symbol'

typeof 42n; // → 'bigint'

typeof true; // → 'boolean'

typeof undefined; // → 'undefined'

typeof null; // → 'object' 🤔

typeof { x: 42 }; // → 'object'

typeof null返回'object',而不是'null',尽管Null它本身是一种。要了解原因,请考虑将所有JavaScript类型的集合分为两组:

1.对象(即Object类型)

2.基本类型(即任何非对象值)

这样,null意味着“无对象值”,而undefined意味着“无值”。

遵循这种思路,Brendan Eich设计了JavaScript,效仿Java,所有值(即所有对象和值)typeof返回其类型,而没有单独的null类型。所以typeof null === 'object'

V8引擎如何在内存中表示值(Value representation)

JavaScript引擎必须能够在内存中表示任意JavaScript值。但是必须注意,值的JavaScript类型 与 JavaScript引擎在内存中表示值 的方式不同。

例如,42,该值具有number类型。

typeof 42; // → 'number'

有几种表示整数的方法,例如42在内存中:

二进制补码8位 0010 1010

二进制补码32位 0000 0000 0000 0000 0000 0000 0010 1010

压缩二进制编码的十进制(BCD) 0100 0010

32位IEEE-754浮点 0100 0010 0010 1000 0000 0000 0000 0000

64位IEEE-754浮点 0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

ECMAScript将数字标准化为64位浮点值,也称为双精度浮点或Float64。但是,这并不意味着JavaScript引擎一直都以Float64表示形式存储数字-这样做效率极低!引擎可以选择其他内部表示形式,只要可观察到的行为与Float64完全匹配即可。

现实世界中的JavaScript应用程序中的大多数数字碰巧都是有效的ECMAScript数组索引,即介于0到2³²−2范围内的整数值。

举例:数组索引:

array[0]; // 最小的数组索引.
array[42];
array[2**32-2]; // 最大的数组索引.
复制代码

JavaScript引擎可以为此类数字选择最佳的内存表示形式,以优化通过索引访问数组元素的代码。为了使处理器执行内存访问操作,数组索引必须以二进制补码(整数的一种编码)形式可用。相反,将数组索引表示为Float64会很浪费,因为每当有人访问数组元素时,引擎就不得不在Float64和二进制补码之间来回转换。

32位二进制补码表示不仅对数组操作有用。通常,处理器执行整数运算要比浮点运算快得多。这就是为什么在下一个示例中,第一个循环的速度很容易是第二个循环的两倍。

for (let i = 0; i < 1000; ++i) {
  // 快 🚀
}

for (let i = 0.1; i < 1000.1; ++i) {
  // 慢 🐌
}
复制代码

操作也是如此。下一段代码中模运算符的性能取决于您是否要处理整数。

const remainder = value % divisor;
// 快 🚀 如果 `value` 和 `divisor` 都是被表示成整数,
// 慢 🐌 其他情况.
复制代码

如果两个操作数均表示为整数,则CPU可以非常有效地计算结果。对于8 divisor是2的幂的情况,V8具有额外的快速路径。对于以浮点数表示的值,计算要复杂得多,并且要花费更长的时间。

由于整数运算通常比浮点运算执行得快得多,因此似乎引擎可以始终对所有整数和整数运算的所有结果使用二进制补码。不幸的是,那将违反ECMAScript规范!ECMAScript在Float64上实现了标准化,因此某些整数运算实际上会产生浮点数。在这种情况下,JS引擎产生正确的结果很重要。

即使左侧的值为整数,右侧的所有值为浮点数。这就是为什么使用32位二进制补码无法正确执行上述操作的原因。JavaScript引擎必须格外小心,以确保整数运算适当地产生准确的Float64结果。

对于31位有符号整数范围内的小整数(直译,其实就是引擎可表示的整数),V8 将其特殊的表示为Smi。

除了Smi,任何其他的值都表示为 HeapObject,这是内存中某个实体的地址。对于数字,我们使用一种特殊的HeapObject,即HeapNumber,来表示不在Smi范围内的数字。

如以上示例所示,一些JavaScript数字都表示为Smi,而其他JavaScript数字表示为HeapNumbers。V8特别针对Smi进行了优化,因为小整数在现实世界中的JavaScript程序中非常常见。Smi不需要分配为内存中的专用实体,并且通常可以启用快速整数运算。

这里最重要的一点是,即使是具有相同JavaScript类型的值,也可以为了优化而在后台以完全不同的方式表示

Smi vs HeapNumber vs MutableHeapNumber(被标记为变化的)

这是V8引擎的工作方式。假设您有以下对象:

const o = {
  x: 42,  // Smi
  y: 4.2, // HeapNumber
};
// x的值42被表示为Smi,因此它可以被存储在对象本身的内部。
// y的值4.2需要一个单独的entity(HeapNumber类型)来保存该值,并且对象指向该entity(HeapNumber类型)。
复制代码

现在,我们运行以下JavaScript代码段:

o.x += 10;
// → o.x is now 52
o.y += 1;
// → o.y is now 5.2
复制代码

在这种情况下,x由于新值52也适合该Smi范围,因此可以就地更新的值。

但是,y的新值=5.2不适合Smi,并且也不同于先前的值4.2,因此V8必须为分配新的HeapNumber实体y。

HeapNumber类型是不可变的,因此可以进行某些优化。例如,如果我们将y的值分配给x:

o.x = o.y;
// → o.x is now 5.2
复制代码

…我们现在可以链接到相同的object,HeapNumber不会为相同的值分配新的object。

HeapNumbers不可变的一个缺点是,Smi经常使用范围之外的值更新字段会很慢,如以下示例所示:

// Create a `HeapNumber` instance.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
  // Create an additional `HeapNumber` instance.
  o.x += 1;
}
复制代码

第一行将创建一个HeapNumber具有初始值的实例0.1。循环体中改变这个值1.1,2.1,3.1,4.1,最后5.1,其中一共创建有六个HeapNumber,一旦循环结束前五个就是垃圾。

为了避免此问题,V8还提供了一种非Smi直接更新的方法,以进行优化。当数字字段的值超出Smi范围时,V8 给这个Shape标记为Double field,并给一个MutableHeapNumber类型的值,该值为编码Float64的实际值。

当字段的值更改时,V8不再需要分配新的HeapNumber,而只需更新MutableHeapNumber即可。

但是,这种方法也有一个陷阱。由于MutableHeapNumber类型的值可以更改,因此请勿将它们传出去,这一点很重要。
比如,如果您分配o.x给其他变量y,那么您肯定不希望下次y更改时,o.x也更改值-这将违反JavaScript规范!因此,在o.x访问该数字时,必须将该数字重新放进HeapNumber类型,然后再将这个数字值给y。

对于浮点数,V8在幕后默默地执行着所有上述“装箱”魔术(指HeapNumber和MutableHeapNumber类型来回变)。但是对于小整数,采用这种方法会很浪费,因为Smi是一种更有效的表示方法。

为避免效率低下,对于小整数,我们要做的就是将Shape上的字段标记为Smi表示形式,并在该小整数的位置简单地更新数值。

const object = { x: 1 };
object.x += 1;
复制代码

Shape(形状)弃用和迁移

那么,如果一个字段最初包含一个Smi,但后来却变成一个浮点数怎么办?

像在这种情况下,两个对象都使用相同的Shape,其中属性x最初表示为Smi类型

const a = { x: 1 };
const b = { x: 2 };
// → 目前两个对象的 `x` 的field都是 `Smi`

b.x = 0.2;
// → `b.x` 现在变成了 `Double` field

y = a.x;
复制代码

首先从指向相同Shape的两个对象开始,其中x将其标记为Smi表示形式:

当b.x改变为Double field,过程是 V8分配一个新的Shape,其中x被分配Double field,并且其指向回空的Shape。

V8还分配了一个MutableHeapNumber以保存该x属性的新值0.2。然后,我们更新对象b以指向该新Shape,并更改对象中的x以指向放0.2的内存。最后,我们将旧Shape标记为已弃用,断开与b的链接。这是通过'x'从空Shape到新创建的Shape的新过渡来完成的。

我们目前无法完全删除旧Shape,因为它仍然被a所使用

注意,废弃的 shape 没引用了才会被V8惰性的垃圾回收器删除。

如果更改表示形式的字段不是属性链中的最后一个字段,则会发生棘手的情况:

const o = {
  x: 1,
  y: 2,
  z: 3,
};

o.y = 0.1;
复制代码

在那种情况下,V8需要找到所谓的分割Shape,这是在引入相关属性之前链中的最后一个Shape。在这里我们要进行更改y,因此我们需要找到最后一个没有y的形状,在我们的示例中就是引入的Shape(x)。

从分割Shape(Shape(x))开始,我们创建一个新的过渡链,y该链重播所有先前的过渡,但'y'标记为Double表示形式。然后,我们将此新过渡链用于y,将旧子树标记为已弃用。在最后一步中,我们o使用a MutableHeapNumber来保存ynow 的值,将实例迁移到新形状。这样,新对象不会采用旧路径,并且一旦所有对旧形状的引用都消失了,树中已弃用的形状部分就会消失。

preventExtensions方法

Object.preventExtensions()防止将新属性添加到对象。如果尝试,它将引发异常。(如果您不在严格模式下,它不会抛出,但是它会无声地执行任何操作。)

const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible
复制代码

让我们考虑这个具体的示例,它有两个都具有单个属性的对象x,然后在这里防止对第二个对象的任何进一步扩展。

const a = { x: 1 };
const b = { x: 2 };

Object.preventExtensions(b);
复制代码

它开始时就像我们已经知道的那样,从空的形状过渡到容纳该属性的新形状'x'(表示为Smi)。当我们阻止对的扩展时b,我们将执行特殊过渡到标记为不可扩展的新形状。这种特殊的过渡不会引入任何新属性,实际上只是一个标记。

请注意,我们不能直接把x更新Shape形状,因为另一个对象a仍需要它,后者仍然是可扩展的。

React性能问题

让我们放在一起,并使用我们学到的知识来理解React团队遇到的问题。当React团队描述了一个实际应用程序时,他们发现V8的性能异常,影响了React的运行。这是该错误的简化再现:

const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;
复制代码

我们有一个带有两个具有Smi表示形式的字段的对象。我们阻止对该对象的任何进一步扩展,并最终强制第二个字段进行Double表示。

正如我们之前所了解的,这大致创建了以下设置:

这两个属性都标记为Smi表示形式,最后的过渡是可扩展性过渡,以将Shape标记为不可扩展。

现在,我们需要更改y为Double表示形式,这意味着我们需要再次从找到分割Shape(就是Shape(x))开始。但是现在V8变得混乱了,因为拆分Shape是可扩展的,而当前Shape被标记为不可扩展。在这种情况下,V8并不知道如何正确重新过渡。因此,V8本质上只是放弃了尝试去理解这一点,而是创建了一个单独的Shape,该形状未连接到现有的Shape树且未与任何其他对象共享,可以将其视为孤立的Shape:

您可以想象如果这发生在许多对象上,那将是非常糟糕的,因为这会使整个Shape系统无用。

在React的情况下,发生的事情是这样的:每个属性FiberNode都有几个字段,应该在打开性能分析时保留时间戳。

class FiberNode {
  constructor() {
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();
复制代码

这些字段(例如actualStartTime)使用0或初始化-1,因此以Smi表示形式开始。但后来,来自的实际浮点类型的时间戳performance.now()存储在这些字段中,导致它们变为Double表示形式,因为它们不适合Smi。最重要的是,React还阻止了FiberNode实例的扩展。

最初,示例如下所示:

有两个实例共享Shape,但是,当您存储实时时间戳时,V8会迷惑地寻找Shape(actualStartTime):

V8为node1分配了一个新的孤立Shape,node2一段时间后又发生了同样的事情,导致了两个孤立空间,每个孤立空间都有自己不相交的Shape。许多现实世界中的React应用程序不仅仅只有两个,而是成千上万个FiberNode。您可以想象,这种情况对于V8的性能并不是特别好。

幸运的是,我们已经在V8 v7.4中修复了此性能陷阱,并且正在考虑使字段表示形式的更改更easy,以消除任何剩余的性能陷阱。通过修复,V8现在可以做正确的事情:

两个FiberNode实例指向不可扩展Shape,其中'actualStartTime'是一个Smi字段。当第一个任务node1.actualStartTime发生时,将创建一个新的过渡链,并将前一个链标记为已弃用:

请注意,现在如何在新链中正确重放可扩展性转换。

将分配给后node2.actualStartTime,两个节点都引用新形状,并且垃圾回收器可以清除已弃用的Shape。

React团队规避方法

React 团队在他们那边也通过确保FiberNode的所有的时间和持续时间字段都被初始化为 Double Field来规避这个问题。

class FiberNode {
  constructor() {
    // Force `Double` representation from the start.
    this.actualStartTime = Number.NaN;
    // Later, you can still initialize to the value you want:
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();
复制代码

如果不想用Number.NaN,可以使用任何不适合该Smi范围的浮点值,包括0.000001,Number.MIN_VALUE,-0,和Infinity。

值得指出的是,具体的React错误是特定于V8的,并且一般来说,开发人员不应针对特定版本的JavaScript引擎进行优化。不过,当事情不起作用时,hack还是很不错的。

请记住,JavaScript引擎在后台执行了一些黑魔法,如果可能的话,您可以通过不混合类型来帮助它。例如,请勿使用初始化您的数字变量为null,因为这会禁用字段表示跟踪的所有好处,并且会使代码可读性变差:

// 下面是错误的示范!!!不要这么写代码
class Point {
  x = null;
  y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;
复制代码

换句话说,编写可读代码,性能将随之而来!(这里的意思是说计量像TS一样约束静态类型?)

总结

在此深入探讨中,我们介绍了以下内容:

1.JavaScript区分“基本类型”和“对象”,并且typeof不够完美(会把null认为成对象)。

2.即使具有相同JavaScript类型的值在后台也可以具有不同的表示形式。(举例:number类型会存储整数或浮点数)

3.V8试图找到JavaScript程序中每个属性的最佳表示形式。

4.我们已经讨论了V8如何处理 形状 (Shape) 弃用和迁移,包括可扩展性转换。

基于这些知识,我们确定了一些实用的JavaScript编码技巧,可以帮助提高性能:

1.始终以相同的方式初始化对象,以使 形状 (Shape) 有效。

2.为您的字段选择合理的初始值,以帮助JavaScript引擎选择表示形式。