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

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

原文连接: The story of a V8 performance cliff in Reactreact

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

若是不想看文章,能够看这个演讲: “JavaScript engine fundamentals: the good, the bad, and the ugly”github


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引擎选择最佳的表示。


相关文章
相关标签/搜索