Why is array.push sometimes faster than array[n] = value?
Asked Answered
S

5

78

As a side result of testing some code I wrote a small function to compare the speed of using the array.push(value) method vs direct addressing array[n] = value. To my surprise the push method often showed to be faster especially in Firefox and sometimes in Chrome. Just out of curiosity: anyone has an explanation for it?

Here's the test (note: rewritten 2023/02/10)

const arrLen = 10_000;
const x = [...Array(10)].map( (_, i) => testArr(arrLen, i));
console.log(`Array length: ${arrLen}\n--------\n${x.join(`\n`)}`);

function testArr(n, action) {
  let arr = [];
  const perfStart = performance.now();
  const methods = 
    ` for (; n; n--) arr.push(n)
      for (; i < n; i += 1) { arr[i] = i; }
      for (; i < n; i += 1) arr.push(i)
      while (--n) arr.push(n)
      while (i++ < n) arr.push(n)
      while (--n) arr.splice(0, 0, n)
      while (--n) arr.unshift(n)
      while (++i < n) arr.unshift(i)
      while (--n) arr.splice(n - 1, 0, n)
      while (n--) arr[n] = n`.split(`\n`).map(v => v.trim());
  const report =  i => `${methods[i]}: ${
    (performance.now() - perfStart).toFixed(2)} milliseconds`;
  let i = 0;
  
  switch (action) {
    case 0: for (; n; n--) arr.push(n)
    case 1: for (; i < n; i += 1) { arr[i] = i; } break;
    case 2: for (let i = 0; i < n; i += 1) arr.push(i); break;
    case 3: while (--n) arr.push(n); break;
    case 4: while (i++ < n) arr.push(n); break;
    case 5: while (--n) arr.splice(0, 0, n); break;
    case 6: while (--n) arr.unshift(n)
    case 7: while (++i < n) arr.unshift(i); break;
    case 8: while (--n) arr.splice(n - 1, 0, n); break;
    default: while (n--) arr[n] = n;
  }
  
  return report(action);
}
.as-console-wrapper {
    max-height: 100% !important;
}
Steak answered 5/3, 2009 at 9:41 Comment(3)
Should be supported if IE6 is sufficiently updated. As far as I recall somewhere around IE version 5.5 a new jscript engine emerged that supported push (before then I used home brew Array augmentations).Steak
Of course you could add push to the ie6 array -- but that would probably be implemented as function push(value) { this[this.length] = value } so you'd be testing the same thingMargo
IE6 will always have at least JScript 5.6. It's only IE 5.0 whose base JScript implementation didn't support Array.push(); everyone else got it back in ancestral JavaScript 1.2.Abbotson
M
90

All sorts of factors come into play, most JS implementations use a flat array that converts to sparse storage if it becomes necessary later on.

Basically the decision to become sparse is a heuristic based on what elements are being set, and how much space would be wasted in order to remain flat.

In your case you are setting the last element first, which means the JS engine will see an array that needs to have a length of n but only a single element. If n is large enough this will immediately make the array a sparse array -- in most engines this means that all subsequent insertions will take the slow sparse array case.

You should add an additional test in which you fill the array from index 0 to index n-1 -- it should be much, much faster.

In response to @Christoph and out of a desire to procrastinate, here's a description of how arrays are (generally) implemented in JS -- specifics vary from JS engine to JS engine but the general principle is the same.

All JS Objects (so not strings, numbers, true, false, undefined, or null) inherit from a base object type -- the exact implementation varies, it could be C++ inheritance, or manually in C (there are benefits to doing it in either way) -- the base Object type defines the default property access methods, eg.

interface Object {
    put(propertyName, value)
    get(propertyName)
private:
    map properties; // a map (tree, hash table, whatever) from propertyName to value
}

This Object type handles all the standard property access logic, the prototype chain, etc. Then the Array implementation becomes

interface Array : Object {
    override put(propertyName, value)
    override get(propertyName)
private:
    map sparseStorage; // a map between integer indices and values
    value[] flatStorage; // basically a native array of values with a 1:1
                         // correspondance between JS index and storage index
    value length; // The `length` of the js array
}

Now when you create an Array in JS the engine creates something akin to the above data structure. When you insert an object into the Array instance the Array's put method checks to see if the property name is an integer (or can be converted into an integer, e.g. "121", "2341", etc.) between 0 and 2^32-1 (or possibly 2^31-1, i forget exactly). If it is not, then the put method is forwarded to the base Object implementation, and the standard [[Put]] logic is done. Otherwise the value is placed into the Array's own storage, if the data is sufficiently compact then the engine will use the flat array storage, in which case insertion (and retrieval) is just a standard array indexing operation, otherwise the engine will convert the array to sparse storage, and put/get use a map to get from propertyName to value location.

I'm honestly not sure if any JS engine currently converts from sparse to flat storage after that conversion occurs.

Anyhoo, that's a fairly high level overview of what happens and leaves out a number of the more icky details, but that's the general implementation pattern. The specifics of how the additional storage, and how put/get are dispatched differs from engine to engine -- but this is the clearest i can really describe the design/implementation.

A minor addition point, while the ES spec refers to propertyName as a string JS engines tend to specialise on integer lookups as well, so someObject[someInteger] will not convert the integer to a string if you're looking at an object that has integer properties eg. Array, String, and DOM types (NodeLists, etc).

Margo answered 5/3, 2009 at 10:28 Comment(6)
@olliej: "most JS implementations use a flat array that converts to sparse storage if it becomes necessary later on" - interesting. So array objects have two kinds of storage: one for regular properties, one for array entries?Arnulfo
@Christoph: Yup -- I could go into great detail if you like, but it will be biased towards the JavaScriptCore/Nitro implementation -- the general model is the same in SpiderMonkey, V8 and KJS, but i'm don't know their exact implementation detailsMargo
@olliej: just checked the SpiderMonkey source: the JSObject struct contains has a dslot member (d for dynamic) which will hold an actual array as long as the JS array is dense; I didn't check what will happen for sparse arrays or when using non-array-index property namesArnulfo
@olliej: thanks, it's making sense. I added a [0..n] test to the page, it is faster and I understand why. Compared to push [0..n] is faster in all browsers.Steak
@Christoph: yeah, they're the C-styled implementation i was mentioning in my (excessively long) annotated answer; JSC, V8 & KJS are all C++ impls, JSC and V8 store the property hashtables separately from objects, iirc SM uses tree's rather than hashtables - everyone doing the same thing differentlyMargo
addendum: adding values directly and in an ascending [0..n] way actually chokes (my) IE7 here.Steak
C
11

These are the result I get with your test

on Safari:

  • Array.push(n) 1,000,000 values: 0.124 sec
  • Array[n .. 0] = value (descending) 1,000,000 values: 3.697 sec
  • Array[0 .. n] = value (ascending) 1,000,000 values: 0.073 sec

on FireFox:

  • Array.push(n) 1,000,000 values: 0.075 sec
  • Array[n .. 0] = value (descending) 1,000,000 values: 1.193 sec
  • Array[0 .. n] = value (ascending) 1,000,000 values: 0.055 sec

on IE7:

  • Array.push(n) 1,000,000 values: 2.828 sec
  • Array[n .. 0] = value (descending) 1,000,000 values: 1.141 sec
  • Array[0 .. n] = value (ascending) 1,000,000 values: 7.984 sec

According to your test the push method seems to be better on IE7 (huge difference), and since on the other browsers the difference is small, it seems to be the push method really the best way to add element to an array.

But I created another simple test script to check what method is fast to append values to an array, the results really surprised me, using Array.length seems to be much faster compared to using Array.push, so I really don't know what to say or think anymore, I'm clueless.

BTW: on my IE7 your script stops and browsers asks me if I want to let it go on (you know the typical IE message that says: "Stop runnign this script? ...") I would recoomend to reduce a little the loops.

Camilacamile answered 2/3, 2010 at 19:35 Comment(0)
A
7

push() is a special case of the more general [[Put]] and therefore can be further optimized:

When calling [[Put]] on an array object, the argument has to be converted to an unsigned integer first because all property names - including array indices - are strings. Then it has to be compared to the length property of the array in order to determine whether or not the length has to be increased. When pushing, no such conversion or comparison has to take place: Just use the current length as array index and increase it.

Of course there are other things which will affect the runtime, eg calling push() should be slower than calling [[Put]] via [] because the prototype chain has to be checked for the former.


As olliej pointed out: actual ECMAScript implementations will optimize the conversion away, ie for numeric property names, no conversion from string to uint is done but just a simple type check. The basic assumption should still hold, though its impact will be less than I originally assumed.

Arnulfo answered 5/3, 2009 at 10:28 Comment(2)
All JS engines actually optimise [[Put]] for integers on the assumption that if you're using an integer it's probably a type that has a special handler for Integer property names -- eg. Arrays, Strings, as well as DOM types (NodeLists, CanvasPixelArray, etc)Margo
Err, completing the last comment - they assume Integer first, then generic object fallback will convert the Integer to a string and try again with the string representation.Margo
P
1

array[n] = value, when previously initialised with a length (like new Array(n)), is faster than array.push, when ascending when n >= 90.

From inspecting the javascript source code of your page, your Array[0 .. n] = value (ascending) test does not initialize the array with a length in advance.

So Array.push(n) sometimes comes ahead on the first run, but on subsequent runs of your test then Array[0 .. n] = value (ascending) actually consistently performs best (in both Safari and Chrome).

If the code is modified so it initialises an array with a length in advance like var array = new Array(n) then Array[0 .. n] = value (ascending) shows that array[n] = value performs 4.5x to 9x faster than Array.push(n) in my rudimentary running of this specific test code.

This is consistent with other tests, like @Timo Kähkönen reported. See specifically this revision of the test he mentioned: https://jsperf.com/push-method-vs-setting-via-key/10

The modified code, so you may see how I edited it and initialised the array in a fair manner (not unnecessarily initialising it with a length for the array.push test case):

function testArr(n, doPush){

  var now = new Date().getTime(),
                  duration,
                  report =  ['<b>.push(n)</b>',
                             '<b>.splice(0,0,n)</b>',
                             '<b>.splice(n-1,0,n)</b>',
                             '<b>[0 .. n] = value</b> (ascending)',
                             '<b>[n .. 0] = value</b> (descending)'];
  doPush = doPush || 5;

  if (doPush === 1) {
   var arr = [];
   while (--n) {
     arr.push(n);
   }
  } else if (doPush === 2) {
   var arr = [];
   while (--n) {
    arr.splice(0,0,n);
   }
  } else if (doPush === 3) {
   var arr = [];
   while (--n) {
    arr.splice(n-1,0,n);
   }
  } else if (doPush === 4) {
   var arr = new Array(n);
   for (var i = 0;i<n;i++) {
    arr[i] = i;
   }
  } else {
    while (--n) {
    var arr = [];
      arr[n] = n;
    }
  }
  /*console.log(report[doPush-1] + '...'+ arr.length || 'nopes');*/
  duration = ((new Date().getTime() - now)/1000);
  $('zebradinges').innerHTML +=  '<br>Array'+report[doPush-1]+' 1.000.000 values: '+duration+' sec' ;
  arr = null;
}
Petty answered 24/4, 2020 at 14:50 Comment(1)
I've tested this on jsbench.me and it appears to be true if you are appending >= 90 items. Less than 90, and the cost of setting the array length outweighs the cost of pushing n times.Daves
B
-6

Push adds it to the end, while array[n] has to go through the array to find the right spot. Probably depends on browser and its way to handle arrays.

Bibb answered 5/3, 2009 at 9:47 Comment(3)
In case of the test n is known (it's equivalent to [array].length-1), so there's no searching going on.Steak
If you're looking for n-th element it needs to find a pointer to that spot in array to fill in the value.Bibb
In the case of the test, n is known. However, the Javascript libraries were written completely ignorant of your test and might still have [] search the array for the right spot even though you know perfectly well where it is. Think of a linked list with a tail pointer.Divinity

© 2022 - 2024 — McMap. All rights reserved.