React 遭遇 V8 性能崩溃的故事

React 遭遇 V8 性能崩溃的故事

在这之前,我们讨论过 JavaScript 引擎如何通过使用内联缓存 (Inline Caches) 和形状 (Shapes) 优化 object 和数组的访问, 然后我们还特别展开讲解了引擎是如何加快原型属性的访问速度。这篇文章主要讲述 V8 如何选择 JavaScript 值在内存中的表现形式的优化方式,和这些优化是如何影响 Shape 机制的——这有助于解释近期发生的一个 React core 在 V8 中出现的性能断崖 (performance cliff)

注意:如果您喜欢看演示文稿而不是阅读文章,请欣赏下面的视频!如果不是,请跳过视频并继续阅读。

“JavaScript engine fundamentals: the good, the bad, and the ugly” 是 Mathias Bynens 和 Benedikt Meurer 在 AgentConf 2019 的现场演讲。

JavaScript 类型

每个 JavaScript 值的类型都一定是 8 个不同类型中的一个: Number, String, Symbol, BigInt, Boolean, Undefined, Null, 和 Object

除了一个显著的例外,这些类型都可以通过 typeof 操作符来查看:

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 中的类型分成两组:

  • 对象 (i.e. the Object type)。
  • 基本类型 (i.e. 所有非对象的值)。

就此来说,null 意味着 "不存在的对象" 的值, 而 undefined 代表着 "不存在" 的值。

跟着这条思路,Brendan Eich 按照 Java 的精神将 JavaScript 中 typeof 运算设计为任何值都返回 'object',比如所有的对象和 null。这就是为何尽管规范中有个单独的 Null 类型,但是 typeof null === 'object'依然成立。

类型表达

JavaScript 引擎必须能在内存中表达任意的 JavaScript 值。然而,有一点值得注意的地方,那就是 JavaScript 值的类型和值本身在 JavaScript 引擎中是分开表达的。

比如 42 这个值,在 JavaScript 中是一个 number 类型。

表现形式bits
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 将 number 数据标准化位 64 位浮点数,通常叫 双精度浮点数 和 Float64。然而这并不代表 JavaScript 引擎将 number 类型的数据一直都按照 Float64 的形式存储 – 这样做的话会非常的低效!引擎可以选择其他的内部表达形式,直到确定需要 Float64 特性的情况出现。

现实中 JavaScript 应用的大部分 number 类型都是有效的 ECMAScript 数组下标,比如说在 0 到 2³²−2 之间的整数。

array[0]; // Smallest possible array index.
array[42];
array[2 ** 32 - 2]; // Greatest possible array index.

JavaScript 引擎可以为这类 number 选择一个在内存中最佳的表达方式来优化根据下标访问数组元素操作的性能。对于处理器的访问内存操作来说,数组下标必须是一个能用补码形式表达的数字。用 Float64 的方式来表达数组下标是非常浪费的,因为引擎在每次访问数组元素时不得不在 Float64 和补码之间反复转换。

32 位补码表达形式不只在数组操作中很实用。一般来说,处理器执行整型操作要比浮点型操作快非常多。这就是下面这个例子中,第一个循环要比第二个循环快两倍的原因。

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

for (let i = 0.1; i < 1000.1; ++i) {
  // slow 🐌
}

这种情况在运算操作中也一样。在下面这个例子中,取模运算的性能取决于你的操作数是否为一个整型数据。

const remainder = value % divisor;
// Fast 🚀 if `value` and `divisor` are represented as integers,
// slow 🐌 otherwise.

如果所有的操作数都是整型,CPU 可以非常高效地计算出结果。当除数为 2 的指数时,V8 还有个额外的优化。如果操作数是浮点类型,这个计算将会复杂很多并且花费更长时间。

因为整型操作一般执行速度比浮点型要快非常多,看起来引擎应该一直使用补码形式来表达所有的整型数据和整型数据的运算结果。不幸的是,这样是违反 ECMAScript 规范的!ECMAScript 是用 Float64 来标准化的,所以 某些整型操作的结果实际上是浮点型。在下面的例子中,这点对 JS 引擎能产出正确结果很重要。

// Float64 has a safe integer range of 53 bits.
// Beyond that range,
// you must lose precision.
2 ** 53 === 2 ** 53 + 1;
// → true

// Float64 supports negative zeros, so -1 * 0 must be -0, but
// there’s no way to represent negative zero in two’s complement.
-1 * 0 === -0;
// → true

// Float64 has infinities which can be produced through division
// by zero.
1 / 0 === Infinity;
// → true
-1 / 0 === -Infinity;
// → true

// Float64 also has NaNs.
0 / 0 === NaN;

虽然等号左边的值都是整数,但等号右边的全是浮点数。这就是使用 32 位二进制补码无法正确执行上述操作的原因。JavaScript 引擎不得不特殊处理以确保整型计算能适当地回落到复杂的浮点结果。

对于小于 31 位的有符号整型,V8 有个被称为 Smi 的特别的表达方式。任何非 Smi 的数据将会被表达为 HeapObject,即一些在内存中的实体的地址。对于 number 来说,我们使用一个特殊的 HeapObject,或者叫 HeapNumber,来表达不在 Smi 范围内的 number 数据。

 -Infinity // HeapNumber
-(2**30)-1 // HeapNumber
  -(2**30) // Smi
       -42 // Smi
        -0 // HeapNumber
         0 // Smi
       4.2 // HeapNumber
        42 // Smi
   2**30-1 // Smi
     2**30 // HeapNumber
  Infinity // HeapNumber
       NaN // HeapNumber

正如上面例子所展示,一些 JavaScript number 被表达为 Smi,而其他的表达为 HeapNumber。V8 对 Smi 做了特殊的优化,因为在现实的 JavaScript 程序中小整型数据实在是太常用了。Smi 不需要在内存中为其分配专门的实体,而且通常可以使用快速的整型运算。

这里最重要的一点是,作为一个优化点,即便是一样的 JavaScript 类型但是在内存中表达形式可以完全不一样

Smi vs. HeapNumber vs. MutableHeapNumber

接下来说下这具体是如何执行的。首先你有如下的一个对象:

const o = {
  x: 42, // Smi
  y: 4.2, // HeapNumber
};

x 的值 42 可以被编码为 Smi,所以它可以被存储在对象自身中。而 y4.2 需要一个分开的实体来保存这个值,然后这个对象指向那个实体。

现在,我们执行下接下来的 JavaScript 片段:

o.x += 10;
// → o.x is now 52
o.y += 1;
// → o.y is now 5.2

在这个例子中,由于新值 52 也是 Smi,所以 x 的值可以直接被替换。

另一方面,y=5.2 的新值不属于 Smi,而且和之前的 4.2 也不同,所以 V8 分配了一个新的 HeapNumber 实体并将地址赋值给 y

HeapNumber 是无法被修改的,因为这样可以进行某些优化。举个例子,如果我们把 y 赋值给 x

o.x = o.y;
// → o.x is now 5.2

那么我们现在只需要指向相同的 HeapNumber 而不必为相同的值分配一个新的对象。

HeapNumber 不可变机制不好的一面是频繁修改非 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,总共创建了 6 个 HeapNumber 实例,其中 5 将会在循环结束后成为内存垃圾。

为了避免这个问题,V8 提供了一个优化更新非 Smi 的 number 字段的方法。当一个 number 字段保存了一个不再 Smi 范围内的值时,V8 在该对象的 shape 中将其标记为 Double 字段,并且分配一个被称为 MutableHeapNumber 的对象以 Float64 编码形式保存其值。

当该字段变化时,V8 不再需要去重新分配一个新的 HeapNumber,而是只需要更新 MutableHeapNumber 中的值即可。

但是,这种方法也有个问题。因为 MutableHeapNumber 的值可以修改,所以它们不应该被传递出去。

举个例子,如果你将 o.x 赋值给另外一个变量 y,你不会希望 y 值的改变也带来 x.o 的改变 – 这是违反 JavaScript 规范的!所以当 o.x 被访问时,这个数字必须得重新装箱成一个正常的 HeapNumber,然后再赋值给 y

对于浮点数来说,V8 在幕后完成了上面提到的所有“装箱”操作。但是因为小整型数据也使用 MutableHeapNumber 机制是非常浪费的,因此 Smi 是一个更加有效的表达方式。

const object = { x: 1 };
// → no 'boxing' for `x` in object

object.x += 1;
// → update the value of `x` inside object

为了避免低效,我们为了小整型数字所要做的事情就是将 shape 上的字段标记为 Smi 表达,然后只要满足小整型范围的更新就只执行数值替换。

Shape 的弃用和整合

那么如果一个字段一开始存的是 Smi 数据,但是后面又被更新成了一个小整数范围之外的数据该怎么办?比如下面这个例子,2 个结构相同的对象,其中 x 都为 Smi 表达的初始值:

const a = { x: 1 };
const b = { x: 2 };
// → objects have `x` as `Smi` field now

b.x = 0.2;
// → `b.x` is now represented as a `Double`

y = a.x;

那么一开始这两个对象都指向同一个 shape,其中 x 被标记为 Smi 表达。

b.x 修改为 Double 表达时,V8 分配了一个新的 shape 而且其中的 x 被指定为 Double 表达,并指向空 shape。V8 也会为属性 x 分配一个 MutableHeapNumber 来保存这个新的值 0.2。然后当再更新对象 b 指向这个新的 shape,并更改对象中的槽以指向偏移 0 处的先前分配的 MutableHeapNumber。最后,我们将旧的 shape 标记为废弃的并且将其从转变树 (transition tree) 中摘除。这是通过 'x' 从空 shape 到新创建的 shape 的转变 (transition) 来完成的。

此时我们还不能完全移除旧的 shape,因为它还在被 a 所使用,而且遍历内存去寻找所有指向了旧 shape 的对线并立刻更新他们的将是非常昂贵的。相反,V8 使用了一个偷懒的办法:任何对 a 的属性访问或者赋值都会先将其迁移到新的 shape 上。这个思路最终将使得废弃的 shape 变得不可抵达然后被垃圾回收器删除。

如果更改表示的字段不是链中的最后一个字段,则会出现更棘手的情况:

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

o.y = 0.1;

在这个例子中,V8 需要去寻找一个被称为 分离 shape(split shape) 的 shape,即指相关属性引入之前链中的最后一个 shape。在这里我们修改了 y,所以我们需要找到最后一个没有包含 yshape,在我们这个例子中就是引入了 x 的那个 shape

从分离 shape 开始,我们为 y 创建了一个可以重放所有之前的转变的新转变链 (transition chain),但是其中 'y' 被标记成 Double 表达。然后我们使用这个新的转变链并将旧的子树标记为废弃的。在最后一步我们把实例 o 迁移到了新的 shape,并使用了 MutableHeapNumber 来保存 y 的值。这样,新的对象就不会使用老的路径,而且一旦旧 shape 的引用小时,树中废弃的 shape 的那部分就会消失。

扩展性和完整性级别的转换

Object.preventExtensions() 可以阻止将新属性添加到对象上。如果你尝试去这么做,它将会抛出一个异常。(如果你不在严格模式下,异常不会抛出但也不会发生任何修改)

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

Object.sealObject.preventExtensions 作用相同,但是它还会将所有属性标记为不可配置,意味着你不能删除它们,或者改变它们的可枚举性,可以配置性或者可写性。

const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible
delete object.x;
// TypeError: Cannot delete property x

Object.freeze 也和 Object.seal 作用相同,但是它还会通过将属性标记为不可写来阻止现有属性被修改。

const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x

让我们考虑下这个具体的例子,两个对象都有一个属性 x,然后我们阻止任何对第二个对象进一步的扩展。

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

Object.preventExtensions(b);

如我们之前所知,一切从空 shape 转变到一个包含属性 'x'(以 Smi 形式表达) 的新 shape 开始。当我们阻止了对 b 的扩展,我们对新的 shape 进行了一个特殊的转变 – 将其标记为不可扩展。这个特殊的转变没有引入任何新的属性 — 它实际上只是个标记。

注意我们为何不能直接更新包含 x 的 shape,因为它被另外一个对象 a 所引用,而且依然是可扩展的。

React 的性能问题

让我们把所前面提到的东西放到一起,用我们所学的东西去理解这个 React issue #14365 。当 React 团队对一个真实的应用进行性能测试的时候,他们发现了一个影响 React 核心的奇怪的 V8 性能悬崖。这里有个简单的 bug 重现:

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

我们有个包含了 2 个 Smi 表达的字段。我们阻止了所有其他对这个对象的扩展,然后最终强制第二个字段变成 Double 表达。

如我们之前所学,它大致创造了以下配置:

所有属性都被表达为 Smi 形式,而且最终的转变是将这个属性标记为不可扩展的扩展性转变。

现在我们需要将 y 修改为 Double 表达,意味着我们需要重新开始找到分离 shape。在本例中,这是引入了 x 的那个 shape。但是现在 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 实例的扩展。

将上面的例子简化如下:

这里有 2 个实例共享一个 shape 树,一切运转如我们所想。但是接下来,当你储存这个真实的时间戳,V8 开始困惑于寻找分离 shape:

V8 指派了一个新的孤立 shape 给 node1,然后稍后 node2 也发生了同样的情况,导致了两个孤岛,每个孤岛都有着自己不相交的 shape。很多真实的 React 应用不止有 2 个,而是有超过成千上万个 FiberNodes。如你所想,这种情况对 V8 的性能来说不是什么好事。

幸运的是,我们已经在 V8 v7.4修复了这个性能悬崖,而且我们正在想办法让字段表达的改变更加高效来消除任何潜在的性能悬崖。在这个 fix 后,V8 现在做了正确的事:

这两个 FiberNode 实例指向了不可扩展且 actualStartTimeSmi 表达的 shape。当第一个对 node1.actualStartTime 的赋值发生时,一个新的转变链被创建并且之前的转变链被标记为废弃的:

注意为何扩展性转变现在会正确的在新链中重放。

在对 node2.actualStartTime 赋值后,所有的节点引用了新的 shape,而且转变树中废弃的部分可以被垃圾回收器清理。

注意:也许你会认为 shape 的废弃 / 迁移很复杂,那你是对的。实际上,我们怀疑这个机制导致的问题(在性能,内存占用和复杂度上)比它带来的帮助要多,尤其是因为使用指针压缩,我们将无法再使用它来把 double-valued(双精度?) 字段内联到对象中。所以,我们希望完全移除掉 V8 的 shape 废弃机制。You could say it's puts on sunglasses being deprecated. YEEEAAAHHH…

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

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 的 Bug 是 V8 规范导致的,开发者不应该为一个特定的 JavaScript 引擎做优化。尽管如此,当事情运转不正常时有个解决方案还是挺不错的。

记住 JavaScript 引擎会在幕后做一些 magic 的优化,而你可以通过尽可能避免类型混用来有效的帮助它执行这些优化。举个例子,不要用 null 来初始化 number 类型的字段,这不仅能避免使得所有字段表达跟踪带来收益全部失效,还能让你的代码变得更可读:

// Don’t do this!
class Point {
  x = null;
  y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;

换句话说,写可读的代码,然后性能自然就会提升。

最后总结

我们在这次深入探讨中涵盖了以下内容:

  • JavaScript 对“基本类型”和“对象”的区分,而且 typeof 是个骗子。
  • 即使具有相同 JavaScript 类型的值也可以在幕后具有不同的表示。
  • 在你的 JavaScript 程序中,V8 会尝试为每个属性寻找最佳的表达方式。
  • 我们讨论了 V8 如何处理 shape 废弃和迁移,包含了扩展性和转变的一些内容。
  • 基于这些知识,我们可以得出一些能帮助提升性能的 JavaScript 编码实用提示:
  • 永远用同样的方式初始化你的对象,这样 shape 机制可以更有效。
  • 使用合理的值来初始化你的字段,这样可以帮助 JavaScript 引擎更好地选择表达方式。

本文作者 Benedikt Meurer, Mathias Bynens,转载请注明来源链接:

原文链接:https://v8.dev/blog/react-cliff

本文链接:https://tie.pub/2019/09/react-cliff/