为什么 array.prototype.slice() 在子类数组上这么慢?

jfr*_*d00 10 javascript arrays v8 subclass performance-testing

在节点 v14.3.0 中,我发现(在对非常大的数组进行一些编码工作时)对数组进行子分类会导致.slice()速度降低 20 倍。虽然,我可以想象可能会有一些围绕非子类数组的编译器优化,但我完全不明白的是,为什么.slice()比手动将元素从一个数组复制到另一个数组慢 2 倍以上。这对我来说根本没有意义。谁有想法?这是一个错误还是有某些方面可以/可以解释它?

为了测试,我创建了一个 100,000,000 单位的数组,其中填充了递增的数字。我制作了一个数组.slice()的副本,然后通过迭代数组并将值分配给新数组来手动制作一个副本。然后我为 anArray和我自己的空子类运行了这两个测试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
Run Code Online (Sandbox Code Playgroud)

手动复制两种方式大致相同。该.slice()拷贝是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);
});
Run Code Online (Sandbox Code Playgroud)

仅供参考,在 Chrome 浏览器中运行时,jsperf.com 测试中也有类似的结果。在 Firefox 中运行 jsperf 显示出类似的趋势,但没有 Chrome 中那么大的差异。

jmr*_*mrk 9

V8 开发人员在这里。你所看到的是相当典型的:

.slice()常规数组的内置函数进行了大量优化,采用了各种快捷方式和专业化(它甚至可以memcpy用于仅包含数字的数组,因此使用 CPU 的向量寄存器一次复制多个元素!)。这使它成为最快的选择。

调用Array.prototype.slice自定义对象(如子类数组,或只是let obj = {length: 100_000_000, foo: "bar", ...})不符合快速路径的限制,因此它由.slice内置函数的通用实现处理,速度慢得多,但可以处理您扔给它的任何东西。这不是 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 规范改变时更新它们需要更多时间;它会产生大量代码复杂性,这些代码很快就会变得无法管理,从而导致进一步的开发速度减慢和/或功能错误和/或安全错误;将它们传送给我们的用户需要更多的二进制文件,并且需要更多的内存来加载这些二进制文件;在任何实际工作开始之前,需要更多的 CPU 时间来决定采用哪条路径;等等。因为这些资源都不是无限的,我们总是必须选择在哪里使用它们,在哪里不使用它们。

(2) 速度与灵活性根本不符。快速路径很快,因为它们可以做出限制性假设。尽可能地扩展快速路径以便它们适用于尽可能多的情况是我们工作的一部分,但是用户代码总是很容易构建一种情况,使得无法采用快速路径的快捷方式路径快。

  • 这也表明动态优化有多好。 (2认同)