为什么 array.prototype.slice() 在子类数组上这么慢?
Why is array.prototype.slice() so slow on sub-classed arrays?
在节点 v14.3.0 中,我发现(在对非常大的数组进行一些编码工作时)子 class 数组会导致 .slice()
速度降低 20 倍。虽然,我可以想象围绕非 subclassed 数组可能会有一些编译器优化,但我根本不明白 .slice()
为什么比手动复制元素慢 2 倍以上从一个数组到另一个数组。这对我来说根本没有意义。谁有想法?这是一个错误还是 would/could 可以解释它的某些方面?
为了测试,我创建了一个 100,000,000 个单位的数组,其中填充了递增的数字。我用 .slice()
制作了数组的副本,然后通过遍历数组并将值分配给新数组来手动制作副本。然后我 运行 对 Array
和我自己的空子class ArraySub
进行了这两个测试。以下是数字:
Running with Array(100,000,000)
sliceTest: 436.766ms
copyTest: 4.821s
Running with ArraySub(100,000,000)
sliceTest: 11.298s
copyTest: 4.845s
两种方式的手动复制大致相同。 .slice()
复制在子 class 上慢 26 倍,比手动复制慢 2 倍以上。为什么会这样?
而且,这是代码:
// empty subclass for testing purposes
class ArraySub extends Array {
}
function test(num, cls) {
let name = cls === Array ? "Array" : "ArraySub";
console.log(`--------------------------------\nRunning with ${name}(${num})`);
// create array filled with unique numbers
let source = new cls(num);
for (let i = 0; i < num; i++) {
source[i] = i;
}
// now make a copy with .slice()
console.time("sliceTest");
let copy = source.slice();
console.timeEnd("sliceTest");
console.time("copyTest");
// these next 4 lines are a lot faster than this.slice()
const manualCopy = new cls(num);
for (let [i, item] of source.entries()) {
manualCopy[i] = item;
}
console.timeEnd("copyTest");
}
[Array, ArraySub].forEach(cls => {
test(100_000_000, cls);
});
仅供参考,在 Chrome 浏览器中 运行 时,此 jsperf.com test 中的结果类似。 运行 Firefox 中的 jsperf 显示出类似的趋势,但没有 Chrome.
中的差异那么大
这里是 V8 开发人员。你看到的是相当典型的:
常规数组的内置 .slice()
函数经过大量优化,采用了各种快捷方式和专业化(它甚至对仅包含数字的数组使用 memcpy
,因此复制使用 CPU 的矢量寄存器一次可以处理多个元素!)。这使它成为最快的选择。
在自定义对象(如子类数组,或 let obj = {length: 100_000_000, foo: "bar", ...}
)上调用 Array.prototype.slice
不符合快速路径的限制,因此它由 .slice
builtin,速度慢得多,但可以处理你扔给它的任何东西。这不是 JavaScript 代码,因此它不会收集类型反馈,也无法动态优化。好处是它每次都能为您提供相同的性能,无论如何。这种性能实际上并不 糟糕 ,与您通过替代方案获得的优化相比,它只是相形见绌。
你自己的实现,像所有 JavaScript 函数一样,获得了动态优化的好处,所以虽然它自然不能立即内置任何花哨的快捷方式,但它可以适应手头的情况(比如它正在操作的对象类型)。这就解释了为什么它比通用内置函数更快,以及为什么它在您的两个测试用例中提供一致的性能。也就是说,如果您的场景更复杂,您可能会污染此函数的类型反馈,使其变得比通用内置函数慢。
使用 [i, item] of source.entries()
方法,您将以一些开销为代价非常简洁地接近 .slice()
的规范行为;一个普通的旧 for (let i = 0; i < source.length; i++) {...}
循环会快两倍左右,即使你添加一个 if (i in source)
检查来反映 .slice()
的 "HasElement" 每次迭代检查。
更一般地说:对于许多其他 JS 内置函数,您可能会看到相同的通用模式——这是 运行 在动态语言优化引擎上的自然结果。尽管我们很乐意让一切都变得更快,但有两个原因无法实现:
(1) 实施快速路径是有代价的:开发(和调试)它们需要更多的工程时间;当 JS 规范更改时,更新它们需要更多时间;它创建了大量的代码复杂性,这些复杂性很快变得难以管理,导致进一步的开发放缓 and/or 功能错误 and/or 安全错误;将它们发送给我们的用户需要更多的二进制大小,并且需要更多的内存来加载这些二进制文件;在任何实际工作开始之前,需要更多 CPU 时间来决定采用哪条路径;等。由于这些资源中的 none 是无限的,我们总是必须选择在哪里使用它们,在哪里不使用它们。
(2) 速度与灵活性从根本上是不一致的。快速路径很快,因为它们可以做出限制性假设。尽可能多地扩展快速路径,以便它们适用于尽可能多的情况是我们所做工作的一部分,但用户代码总是很容易构建一种情况,使得不可能采取快速的捷径路径快。
在节点 v14.3.0 中,我发现(在对非常大的数组进行一些编码工作时)子 class 数组会导致 .slice()
速度降低 20 倍。虽然,我可以想象围绕非 subclassed 数组可能会有一些编译器优化,但我根本不明白 .slice()
为什么比手动复制元素慢 2 倍以上从一个数组到另一个数组。这对我来说根本没有意义。谁有想法?这是一个错误还是 would/could 可以解释它的某些方面?
为了测试,我创建了一个 100,000,000 个单位的数组,其中填充了递增的数字。我用 .slice()
制作了数组的副本,然后通过遍历数组并将值分配给新数组来手动制作副本。然后我 运行 对 Array
和我自己的空子class ArraySub
进行了这两个测试。以下是数字:
Running with Array(100,000,000)
sliceTest: 436.766ms
copyTest: 4.821s
Running with ArraySub(100,000,000)
sliceTest: 11.298s
copyTest: 4.845s
两种方式的手动复制大致相同。 .slice()
复制在子 class 上慢 26 倍,比手动复制慢 2 倍以上。为什么会这样?
而且,这是代码:
// empty subclass for testing purposes
class ArraySub extends Array {
}
function test(num, cls) {
let name = cls === Array ? "Array" : "ArraySub";
console.log(`--------------------------------\nRunning with ${name}(${num})`);
// create array filled with unique numbers
let source = new cls(num);
for (let i = 0; i < num; i++) {
source[i] = i;
}
// now make a copy with .slice()
console.time("sliceTest");
let copy = source.slice();
console.timeEnd("sliceTest");
console.time("copyTest");
// these next 4 lines are a lot faster than this.slice()
const manualCopy = new cls(num);
for (let [i, item] of source.entries()) {
manualCopy[i] = item;
}
console.timeEnd("copyTest");
}
[Array, ArraySub].forEach(cls => {
test(100_000_000, cls);
});
仅供参考,在 Chrome 浏览器中 运行 时,此 jsperf.com test 中的结果类似。 运行 Firefox 中的 jsperf 显示出类似的趋势,但没有 Chrome.
中的差异那么大这里是 V8 开发人员。你看到的是相当典型的:
常规数组的内置 .slice()
函数经过大量优化,采用了各种快捷方式和专业化(它甚至对仅包含数字的数组使用 memcpy
,因此复制使用 CPU 的矢量寄存器一次可以处理多个元素!)。这使它成为最快的选择。
在自定义对象(如子类数组,或 let obj = {length: 100_000_000, foo: "bar", ...}
)上调用 Array.prototype.slice
不符合快速路径的限制,因此它由 .slice
builtin,速度慢得多,但可以处理你扔给它的任何东西。这不是 JavaScript 代码,因此它不会收集类型反馈,也无法动态优化。好处是它每次都能为您提供相同的性能,无论如何。这种性能实际上并不 糟糕 ,与您通过替代方案获得的优化相比,它只是相形见绌。
你自己的实现,像所有 JavaScript 函数一样,获得了动态优化的好处,所以虽然它自然不能立即内置任何花哨的快捷方式,但它可以适应手头的情况(比如它正在操作的对象类型)。这就解释了为什么它比通用内置函数更快,以及为什么它在您的两个测试用例中提供一致的性能。也就是说,如果您的场景更复杂,您可能会污染此函数的类型反馈,使其变得比通用内置函数慢。
使用 [i, item] of source.entries()
方法,您将以一些开销为代价非常简洁地接近 .slice()
的规范行为;一个普通的旧 for (let i = 0; i < source.length; i++) {...}
循环会快两倍左右,即使你添加一个 if (i in source)
检查来反映 .slice()
的 "HasElement" 每次迭代检查。
更一般地说:对于许多其他 JS 内置函数,您可能会看到相同的通用模式——这是 运行 在动态语言优化引擎上的自然结果。尽管我们很乐意让一切都变得更快,但有两个原因无法实现:
(1) 实施快速路径是有代价的:开发(和调试)它们需要更多的工程时间;当 JS 规范更改时,更新它们需要更多时间;它创建了大量的代码复杂性,这些复杂性很快变得难以管理,导致进一步的开发放缓 and/or 功能错误 and/or 安全错误;将它们发送给我们的用户需要更多的二进制大小,并且需要更多的内存来加载这些二进制文件;在任何实际工作开始之前,需要更多 CPU 时间来决定采用哪条路径;等。由于这些资源中的 none 是无限的,我们总是必须选择在哪里使用它们,在哪里不使用它们。
(2) 速度与灵活性从根本上是不一致的。快速路径很快,因为它们可以做出限制性假设。尽可能多地扩展快速路径,以便它们适用于尽可能多的情况是我们所做工作的一部分,但用户代码总是很容易构建一种情况,使得不可能采取快速的捷径路径快。