关于React的一个V8性能瓶颈背后的故事

译注: 原文作者是Mathias Bynens, 他是V8开发者,这篇文章也发布在V8的博客上。他的相关文章质量非常高,如果你想了解JavaScript引擎内部是如何工作的,他的文章一定不能错过。后面我还会翻译他的其他文章,一方面是他文章质量很高,另外一方面是我想学习一下他们是怎么写文章的,通过翻译文章,让我可以更好地消化知识和模仿写作技巧, 最后奇文共赏!

原文链接: The story of a V8 performance cliff in React

之前我们讨论过Javascript引擎是如何通过Shape(外形)和内联缓存(Inline Caches)来优化对象和数组的访问的, 我们还特别探讨了Javascript引擎是如何加速原型属性访问. 这篇文章讲述V8如何为不同的Javascript值选择最佳的内存表示(representations), 以及它是如何影响外形机制(shape machinery)的。这些可以帮助我们解释最近React内核出现的V8性能瓶颈(Performance cliff)问题

如果不想看文章,可以看这个演讲: “JavaScript engine fundamentals: the good, the bad, and the ugly”


JavaScript 类型

每一个Javascript值都属于以下八个类型之一(目前): Number, String, Symbol, BigInt, Boolean, Undefined, Null, 以及 Object.

但是有个总所周知的例外,在JavaScript中可以通过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. Object类型)
  • 原始(primitives)类型 (i.e. 任何非对象值)

因此, null可以理解为”无对象值”, 而undefined则表示为“无值”.

译注:也就是,null可以理解为对象类型的’undefined’;而undefined是所有类型的’undefined’

遵循这个思路,Brendan Eich 在设计Javascript的时候受到了Java的影响,让typeof右侧所有值(即所有对象和null值)都返回’object’. 这就是为什么typeof null === 'object'的原因, 尽管规范中有一个单独的Null类型。


值的表示

Javascript引擎必须能够在内存中表示任意的Javascript值. 然而,需要注意的是,Javascript的值类型和Javascript引擎如何在内存中表示它们是两回事.

例如值 42,Javascript中是number类型。

typeof 42;
// → 'number'

在内存中有很多种方式来表示类似42这样的整型数字:

表示
8-bit二进制补码 0010 1010
32-bit二进制补码 0000 0000 0000 0000 0000 0000 0010 1010
压缩二进制编码十进制(packed binary-coded decimal (BCD)) 0100 0010
32-bit IEEE-754 浮点数 0100 0010 0010 1000 0000 0000 0000 0000
64-bit 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 to 2³²−2之间的整型值.

array[0]; // 最小合法的数组索引.
array[42];
array[2**32-2]; // 最大合法数组索引.

JavaScript引擎可以为这类数字选择最优内存表示,来优化通过索引访问数组元素的代码。为了让处理器可以执行内存访问操作,数组索引必须是二进制补码. 将数组索引表示为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 🚀 如果 `value` 和 `divisor` 都表示为整型,
// slow 🐌 其他情况

如果两个操作数都表示为整型,CPU就可以非常高效地计算结果。如果divisor是2的幂, V8还有额外的快速通道(fast-paths)。对于表示为浮点树的值,计算则要复杂的多,并且需要更长的时间.

因为整型操作通常都比浮点型操作要快得多,所以引擎似乎可以总是使用二进制补码来表示所有整型值和整型的计算结果。不幸的是,这会违反ECMAScript规范!ECMAScript是在Float64基础上进行标准化,因此实际上某些整数操作也可能会输出浮点数。在这种情况下,JS引擎输出正确的结果更重要。

// Float64 的安全整型范围是 53 bits. 超过这个返回会失去精度,
2**53 === 2**53+1;
// → true

// Float64 支持-0, 索引 -1 * 0 必须是 -0, 但是二进制补码是没办法表示-0.
-1*0 === -0;
// → true

// Float64 可以通过除以0来得到无穷大.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true

// Float64 还有NaN.
0/0 === NaN;

尽管左侧都是整型,右侧所有值却都是浮点型。这就是为什么32位二进制补码不能正确地执行上面这些操作。所以JavaScript引擎必须特别谨慎,以确保整数操作可以适当地回退,从而输出花哨(符合规范)的Float64结果。

对于31位有符号整数范围内的小整数,V8使用一个称为Smi(译注: Small Integer)的特殊表示。其他非Smi的表示称为HeapObject,即指向内存中某些实体的地址。对于数字,我们使用的是一个特殊类型的HeapObject, 尚且称为HeapNumber, 用来表示不在Smi范围内的数字.

 -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数字表示为Smi,而另一些则表示为HeapNumber. V8特意为Smi优化过,因为小整数在实际JavaScript程序中太常见了。Smi不需要在内存中额外分配专门的实体, 可以进行快速的整型操作.

这里更重要的一点是,即使是相同Javascript类型的值,为了优化,背后可能会以完全不同的方式进行表示




Smi vs. HeapNumber vs. MutableHeapNumber

下面介绍它们底层是怎么工作的。假设你有下列对象:

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

x的值42可以被编码为Smi,所以你可以在对象自己内部进行保存。另一方面,值4.2则需要一个单独的实体来保存,然后对象再指向这个实体.

现在开始执行下面的Javascript片段:

o.x += 10;
// → o.x 现在是 52
o.y += 1;
// → o.y 现在是 5.2

这种情况下,x的值可以被原地(in-place)更新,因为新的值52还是符合Smi的范围.

然而,新值y=5.2不符合Smi,且和之前的值4.2不一样,所以V8必须分配一个新的HeapNumber实体,再赋值给y。

HeapNumber是不可变的,这也让某些优化成为可能。举个例子,如果我们将y的值赋给x:

o.x = o.y;
// → o.x 现在是 5.2

…我们现在可以简单地链接到同一个HeapNumber,而不是分配一个新的.

HeapNumbers不可变的一个缺点是,频繁更新字段不在Smi范围内的值会比较慢,如下例所示:

// 创建一个 `HeapNumber` 实例.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
// 创建另一个 `HeapNumber` 实例.
o.x += 1;
}

第一行通过初始化值0.1创建一个HeapNumber实例。循环体将它的值改变为1.12.13.14.1、最后是5.1,这个过程总共创建了6个HeapNumber实例,其中5个会在循环结束后被垃圾回收。

为了避免这个问题,V8也提供了一种机制来原地更新非Smi数字字段作为优化。当一个数字字段保存的值超出了Smi的范围后,V8会在Shape中将这个字段标记为Double, 并且分配一个称为MutableHeapNumber实体来保存实际的值。

译注: 关于Shape是什么,可以阅读这篇文章, 简单说Shape就是一个对象的‘外形’,JavaScript引擎可以通过Shape来优化对象的属性访问。

现在当字段的值变动时,V8不需要在分配一个新的HeapNumber,而是直接原地更新MutableHeapNumber.

然而,这种方式也有一个缺陷。因为MutableHeapNumber的值可以被修改,所以这些值不能安全传递给其他变量

举个例子,如果你将o.x赋值给其他变量y,你可不想下一次o.x变动时影响到y的值 —— 这违反了JavaScript规范!因此,当o.x被访问后,在将其赋值给y之前,必须将该数字重新装箱(re-boxed)成一个常规的HeapNumber

对于浮点数,V8会在背后执行所有上面提到的“包装(boxing)”魔法。但是对于小整数来说,使用MutableHeapNumber就是浪费,因为Smi是更高效的表示。

const object = { x: 1 };
// → 不需要‘包装’x字段

object.x += 1;
// → 直接在对象内部更新

为了避免低效率,对于小整数,我们必须在Shape上将该字段标记为Smi表示,只要符合小整数的范围,我们就可以简单地原地更新数字值。


Shape 废弃和迁移

那么,如果一个字段初始化时是Smi,但是后续保存了一个超出小整数方位的值呢?比如下面这种情况,两个对象都使用相同的Shape,即x在初始化时表示为Smi:

const a = { x: 1 };
const b = { x: 2 };
// → 对象现在将 `x`字段 表示为 `Smi`

b.x = 0.2;
// → `b.x` 现在表示为 `Double`

y = a.x;

一开始两个对象都指向同一个Shapex被标记为Smi表示:

b.x修改为Double表示时,V8会分配一个新的Shape,将x设置为Double表示,并且它会指向回空Shape(译注:Shape是树结构)。另外V8还会分配一个MutableHeapNumber来保存x的新值0.2. 接着我们更新对象b指向新的Shape,并且修改对象的x指向刚才分配的MutableHeapNumber。最后,我们标记旧的Shape为废弃状态,并从转换树(transition tree)中移除。这是通过将“x”从空Shape转换为新创建的Shape的方式来完成的。

这个时候我们还不能完全将旧Shape删除掉,因为它还被a使用着,而且你不能着急遍历内存来找出所有指向旧Shape的对象,这种做法太低效。所以V8采用惰性方式: 对于a的任意属性的访问和赋值,会首先迁移到新的Shape。这样做, 最终可以将废弃的Shape变成‘不能到达(unreachable)’, 让垃圾回收器释放掉它。

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

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

o.y = 0.1;

这种情况,V8需要找到所谓的分割Shape(split shape), 即相关属性在被引入到Shape链之前的Shape。这里我们修改的是y,所以我们可以找到引入y之前的最后一个Shape,在上面的例子中这个Shape就是x

分割Shape(即x)开始,我们为y创建一个新的转换链, 它将y标记为Double表示,并重放(replay)之前的其他转换. 我们将对y应用这个新的转换链,将旧的树标记为废弃。在最后一步,我们将实例o迁移到新的Shape,现在使用一个MutableHeapNumber来保存y的值。后面新创建的对象都不会使用旧的Shape的路径,一旦所有旧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类似,只不过它将所有属性标记为non-configurable, 这意味着你不能删除它们, 或者改变它们的ConfigurableEnumerableWritable属性。

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.freezeObject.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并标记为’不可扩展’。这个特殊的转换不会引入任何新的属性 —— 它只是一个标记

注意,我们不能原地更新xShape,因为它还被a对象引用,a对象还是可扩展的。


React的性能问题

让我们将上述所有东西都放在一起,用我们学到的知识来理解最近的React Issue #14365. 当React团队在分析一个真实的应用时,他们发现了V8一个影响React 核心的奇怪性能问题. 下面是这个bug的简单复现:

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

一开始我们这个对象有两个Smi表示的字段。接着我们还阻止了对象扩展,最后还强制将第二个字段转换为Double表示。

按照我们上面描述的,这大概会创建以下东西:

两个属性都会被标记为Smi表示,最后一个转换是可扩展性转换,用于将Shape标记为不可扩展。

现在我们需要将y转换为Double表示,这意味着我们又要开始找出分割Shape. 在这个例子中,分割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()输出的时间戳,这些时间戳实际是浮点型的。因为不符合Smi范围,它们需要转换为Double表示。恰好在这里,React还阻止了FiberNode实例的可扩展性。

上面例子的初始状态如下:

按照我们设想的一样, 这两个实例共享了同一个Share树. 后面,当你保存真正的时间戳时,V8找到分割Shape就迷惑了:

V8给node1分配了一个新的孤儿Shapenode2同理,这样就生成了两个孤岛,每个孤岛都有自己不相交的Shape。大部分真实的React应用有上千上万个FiberNode。可以想象到,这种情况对V8的性能不是特别乐观。

幸运的是,我们在V8 v7.4修复了这个性能问题, 我们也正在研究如何降低修改字段表示的成本,以消灭剩余的性能瓶颈. 经过修复后,V8可以正确处理这种情况:

两个FiberNode实例都指向了’actualStartTime’为Smi的不可扩展Shape. 当第一次给node1.actualStartTime赋值时,将创建一个新的转换链,并将之前的链标记为废弃。

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

在赋值node2.actualStartTime之后,两个节点都引用到了新的Shape,转换树中废弃的部分将被垃圾回收器回收。

在Bug未修复之前,React团队通过确保FiberNode上的所有时间和时间段字段都初始化为Double表示,来缓解了这个问题:

class FiberNode {
constructor() {
// 在一开始强制为Double表示.
this.actualStartTime = Number.NaN;
// 后面依旧可以按照之前的方式初始化值
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}

const node1 = new FiberNode();
const node2 = new FiberNode();

除了Number.NaN, 任何浮点数值都不在Smi的范围内, 可以用于强制Double表示。例如0.000001, Number.MIN_VALUE, -0Infinity

值得指出的是,这个具体的React bug是V8特有的,一般来说,开发人员不应该针对特定版本的JavaScript引擎进行优化。不过,当事情不起作用的时候有个把柄总比没有好。

记住这些Javascript引擎背后执行的一些魔法,如果可能,尽量不要混合类型,举个例子,不要将你的数字字段初始化为null,因为这样会丧失跟踪字段表示的所有好处。不混合类型也可以让你的代码更具可读性:

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

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

译注:如果你使用Typescript,应该开启strictNull模式

换句话说,编写高可读的代码,是可以提高性能的!

总结

我们深入讨论了下列内容:

  • JavaScript 区分了‘原始类型’和‘对象类型’,typeof是一个骗子
  • 即使是相同Javascript类型的值,底层可能有不同的表示
  • V8尝试给你的Javascript程序的每个属性找出一个最优的表示
  • 我们还讨论了V8是如何处理Shape废弃和迁移的,另外还包括扩展性转换

基于这些知识,我们总结出了一些可以帮助提升性能的JavaScript编程技巧:

  • 始终按照一致的方式初始化你的对象,这样Shape会更有效
  • 为字段选择合理的初始值,以帮助JavaScript引擎选择最佳的表示。