Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is array.prototype.slice() so slow on sub-classed arrays?

In node v14.3.0, I discovered (while doing some coding work with very large arrays) that sub-classing an array can cause .slice() to slow down by a factor 20x. While, I could imagine that there might be some compiler optimizations around a non-subclassed array, what I do not understand at all is how .slice() can be more than 2x slower than just manually copying elements from one array to another. That does not make sense to me at all. Anyone have any ideas? Is this a bug or is there some aspect to this that would/could explain it?

For the test, I created a 100,000,000 unit array filled with increasing numbers. I made a copy of the array with .slice() and I made a copy manually by then iterating over the array and assigning values to a new array. I then ran those two tests for both an Array and my own empty subclass ArraySub. Here are the numbers:

Running with Array(100,000,000)
sliceTest: 436.766ms
copyTest: 4.821s

Running with ArraySub(100,000,000)
sliceTest: 11.298s
copyTest: 4.845s

The manual copy is about the same both ways. The .slice() copy is 26x slower on the sub-class and more than 2x slower than the manual copy. Why would that be?

And, here's the code:

// 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);
});

FYI, there's a similar result in this jsperf.com test when run in the Chrome browser. Running the jsperf in Firefox shows a similar trend, but not as much of a difference as in Chrome.

like image 857
jfriend00 Avatar asked Jun 03 '20 23:06

jfriend00


People also ask

Is array slice slow?

slice() to slow down by a factor 20x. While, I could imagine that there might be some compiler optimizations around a non-subclassed array, what I do not understand at all is how . slice() can be more than 2x slower than just manually copying elements from one array to another.

Does slice work on array?

JavaScript Array slice() The slice() method returns selected elements in an array, as a new array. The slice() method selects from a given start, up to a (not inclusive) given end. The slice() method does not change the original array.

Does slice affect original array?

slice returns a piece of the array but it doesn't affect the original array. splice changes the original array by removing, replacing, or adding values and returns the affected values.

Is JavaScript slice destructive?

slice() is a method of Array Object and String Object. It is non-destructive since it return a new copy and it doesn't change the content of input array.


1 Answers

V8 developer here. What you're seeing is fairly typical:

The built-in .slice() function for regular arrays is heavily optimized, taking all sorts of shortcuts and specializations (it even goes as far as using memcpy for arrays containing only numbers, hence copying more than one element at a time using your CPU's vector registers!). That makes it the fastest option.

Calling Array.prototype.slice on a custom object (like a subclassed array, or just let obj = {length: 100_000_000, foo: "bar", ...}) doesn't fit the restrictions of the fast path, so it's handled by a generic implementation of the .slice builtin, which is much slower, but can handle anything you throw at it. This is not JavaScript code, so it doesn't collect type feedback and can't get optimized dynamically. The upside is that it gives you the same performance every time, no matter what. This performance is not actually bad, it just pales in comparison to the optimizations you get with the alternatives.

Your own implementation, like all JavaScript functions, gets the benefit of dynamic optimization, so while it naturally can't have any fancy shortcuts built into it right away, it can adapt to the situation at hand (like the type of object it's operating on). That explains why it's faster than the generic builtin, and also why it provides consistent performance in both of your test cases. That said, if your scenario were more complicated, you could probably pollute this function's type feedback to the point where it becomes slower than the generic builtin.

With the [i, item] of source.entries() approach you're coming close to the spec behavior of .slice() very concisely at the cost of some overhead; a plain old for (let i = 0; i < source.length; i++) {...} loop would be about twice as fast, even if you add an if (i in source) check to reflect .slice()'s "HasElement" check on every iteration.


More generally: you'll probably see the same general pattern for many other JS builtins -- it's a natural consequence of running on an optimizing engine for a dynamic language. As much as we'd love to just make everything fast, there are two reasons why that won't happen:

(1) Implementing fast paths comes at a cost: it takes more engineering time to develop (and debug) them; it takes more time to update them when the JS spec changes; it creates an amount of code complexity that quickly becomes unmanageable leading to further development slowdown and/or functionality bugs and/or security bugs; it takes more binary size to ship them to our users and more memory to load such binaries; it takes more CPU time to decide which path to take before any of the actual work can start; etc. Since none of those resources are infinite, we'll always have to choose where to spend them, and where not.

(2) Speed is fundamentally at odds with flexibility. Fast paths are fast because they get to make restrictive assumptions. Extending fast paths as much as possible so that they apply to as many cases as possible is part of what we do, but it'll always be easy for user code to construct a situation that makes it impossible to take the shortcuts that make a fast path fast.

like image 131
jmrk Avatar answered Oct 02 '22 19:10

jmrk