xiaofeihe

译文:v8-Speeding-up

原文链接:https://v8.dev/blog/spread-elements

Speeding up spread elements

在V8团队三个月的实习期间,海当致力于提高[…array]、 […string]、[…set]、 […map.keys()] 和 […map.values()]的性能(当扩展运算符位于数组的开头时)。他甚至把Array.from(iterable)也做得更快了。本文解释了他更改的一些细节,这些细节包含在V8中,从v7.2开始。

Spread elements

扩展运算符是数组的组件,其形式是...iterable。它们是在ES 2015中引入的,作为从可迭代对象创建数组的一种方法。例如,数组[1,…arr,4,…b]来创建一个数组,其第一个元素是1,然后是数组arr的元素,然后是4,最后是数组b的元素。

1
2
3
4
const a = [2, 3];
const b = [5, 6, 7];
const result = [1, ...a, 4, ...b];
// → [1, 2, 3, 4, 5, 6, 7]

另一个例子是,任何字符串都可以被扩展以创建其字符数组(Unicode代码点)。

1
2
3
const str = 'こんにちは';
const result = [...str];
// → ['こ', 'ん', 'に', 'ち', 'は']

类似地,任何集合都可以被扩展以创建其元素数组,并按插入顺序排序。

1
2
3
4
5
const s = new Set();
s.add('V8');
s.add('TurboFan');
const result = [...s];
// → ['V8', 'TurboFan']

一般说来,数组中的扩展运算符...x,假定x提供了一个迭代器(通过x[Symbol.iterator]()访问)。然后使用这个迭代器获取要插入到结果数组中的元素。

简单的用例将数组arr扩展到一个新的数组中,而不在[…arr]之前或后面添加任何进一步的元素,这被认为是ES 2015中浅层克隆arr的一种简洁、惯用的方法。不幸的是,在V8,这个性能远远落后于它的ES5对应。海的实习目标就是改变这种状况!

Why is (or were!) spread elements slow?

浅克隆数组arr的方法有很多种。例如,可以使用arr.slice(),或者arr.concat(),或者[…arr]。或者,您可以编写自己的克隆函数,使用标准的for-loop

1
2
3
4
5
6
7
8
9
function clone(arr) {
// Pre-allocate the correct number of elements, to avoid
// having to grow the array.
const result = new Array(arr.length);
for (let i = 0; i < arr.length; i++) {
result[i] = arr[i];
}
return result;
}

理想情况下,所有这些选项都具有相似的性能特征。不幸的是,如果您在V8中选择 [...arr],它可能比克隆速度慢(或曾经)慢。原因是V8本质上将 [...arr] 转成如下所示的迭代。

1
2
3
4
5
6
7
8
9
10
11
function(arr) {
const result = [];
const iterator = arr[Symbol.iterator]();
const next = iterator.next;
for ( ; ; ) {
const iteratorResult = next.call(iterator);
if (iteratorResult.done) break;
result.push(iteratorResult.value);
}
return result;
}

由于几个原因,此代码通常比克隆速度慢。

  1. 它需要在开始时通过加载和计算 Symbol.iterator 属性来创建迭代器。

  2. 它需要在每一步创建和查询迭代器 Result 对象。

  3. 它通过调用 Push 在迭代的每一步生成结果数组,从而重复地重新分配备份存储。

使用这种实现的原因是,如前所述,传播不仅可以在数组上进行,而且可以在,使用这种实现的原因是,如前所述,传播不仅可以在数组上进行,而且可以在。尽管如此,V8应该足够聪明地识别正在传播的对象是否是一个数组,以便它能够在较低的级别执行元素提取,从而执行元素提取。

  1. 避免创建迭代器对象

  2. 避免创建迭代器结果对象

  3. 避免不断增长,从而重新分配结果数组(我们预先知道元素的数量)

对于快速数组,我们使用 csa 实现了这个简单的想法,即具有六种最常见元素类型之一的数组。优化适用于常见的实际场景,其中扩展发生在数组文字的开头,例如 [...foo]。如下图所示,这种新的快速路径对于扩展长度为100,000的数组产生了大约3×性能的改进,使其比手写的克隆循环快25%左右。

Note:虽然这里没有显示,但是当扩展元素后面跟着其他组件时,快速路径也适用(例如 [.arr,1,2,3],但当它们前面有其他的(例如 [1,2,3,.arr])时就不是了。

Tread carefully down that fast path

这显然是一个令人印象深刻的加速,但我们必须非常小心,什么时候它是正确的走这条快速的道路:JavaScript 允许程序员以各种方式修改对象(甚至数组)的迭代行为。因为扩展元素被指定为使用迭代协议,所以我们需要确保这些修改得到认可。我们这样做是为了避免在原始迭代机制发生变异时完全避免快速路径。例如,这包括以下情况。

Own Symbol.iterator property

通常,数组 arr 没有自己的 Symbol.iterator 属性,所以当查找该符号时,将在数组的原型中找到它。在下面的示例中,通过直接在 arr 上定义 Symbol.iterator 属性绕过了原型。修改后,在arr上查找 Symbol.iterator 将生成一个空迭代器,因此 arr 的扩展不会产生任何元素,数组的计算结果为空数组。

1
2
3
4
5
6
const arr = [1, 2, 3];
arr[Symbol.iterator] = function() {
return { next: function() { return { done: true }; } };
};
const result = [...arr];
// → []

Modified %ArrayIteratorPrototype%

下一个方法也可以在%ArrayIteratorPrototype%,数组迭代器的原型(影响所有数组)上直接修改。

1
2
3
4
5
6
Object.getPrototypeOf([][Symbol.iterator]()).next = function() {
return { done: true };
}
const arr = [1, 2, 3];
const result = [...arr];
// → []

Spreading strings, sets, and maps

跳过迭代器对象和避免增长结果数组的想法同样适用于传播其他标准数据类型。实际上,对于原始字符串、集合和映射,我们实现了类似的快速路径,每次都要注意在存在修改的迭代行为时绕过它们。

对于集合,快速路径不仅支持直接扩展集合([.set]),还支持扩展其键迭代器([.set.key()])和值迭代器([.set.value()])。在我们的微观基准中,这些操作现在比以前快了大约18×。

map的快速路径类似,但不支持直接传播map([.map]),因为我们认为这是一种不常见的操作。出于同样的原因,两种快速路径都不支持 entries() 迭代器。在我们的微基准中,这些操作现在比以前快了大约14×。

对于扩展字符串([[…string]),我们测量了一个大约5×改进,如下图所示的紫色和绿色线。请注意,这甚至比涡轮风扇优化的循环(涡轮风扇理解字符串迭代,并可以为它生成优化的代码),以蓝色和粉红色线表示。在每种情况下都有两幅图的原因是微基准测试在两种不同的字符串表示(一字节字符串和两字节字符串)上运行。

Improving Array.from performance

幸运的是,我们的扩展运算符的快速路径可以在 Array.from 中重用,在这种情况下,Array.From 是用一个可迭代的对象调用,并且没有映射函数。例如,Array.From([1,2,3])。重用是可能的,因为在这种情况下,Array.From 的行为与扩展行为完全相同。它带来了巨大的性能改进,如下所示,一个数组的100倍。

Conclusion

V8 v7.2/Chrome 72大大提高了扩展运算符在数组前面出现的性能。例如 [...x] 或者 [...x, 1, 2]。这种改进适用于spreading arrays、primitive strings、sets、maps keys、maps values,以及通过扩展到 Array.From(X)