Idea: Improving the performance of $tw.utils.each

Note that this is also a issue in GitHub.

I noticed that the $tw.utils.each function (a very important core function used in most filters) in $:/boot/boot.js first determines the type of the object to be traversed, and uses a for loop to traverse it if it is an array, or a for + Object.keys loop to traverse it if it is an object. I recently conducted performance tests on various ways of traversing arrays and objects on several major browsers and came to some conclusions.

  • For array traversal, using the sequential Duff’s Device algorithm can improve performance somewhat.
  • For object traversal, the for + Object.keys approach is very efficient in most cases, but for...in... should be preferred in Safari (by detecting Safari browser first using /version.*safari/i.test(navigator.userAgent)).

The following is used to prove my conclusion.


Benchmark test part

My test environment:

  • Apple MacBook Pro 2017 (15-inch)
  • macOS Big Sur (v11.6 (20G165)), Darwin Kernel Version 20.6.0
  • CPU: 3.1 GHz Quad-Core Intel Core i7-7920HQ
  • Memory: LPDDR3 2133 MHz 8G x 2

I have tested it in four browsers:

  • Chrome 94.0.4606.81 (Official Build) (x86_64)
  • FireFox 93.0 (x86_64)
  • Safari 15.0 (16612.1.29.41.4, 16612)
  • Edge 94.0.992.37 (Official build) (x86_64) (Note that Edge also uses Chrome kernel)

Test Method:

I encapsulate the various traversal methods into a function each(obj, callback), which takes an argument obj to be traversed (possibly an array or object) and traverses the elements of it and calls the function callback one by one. For the array traversal test and the object traversal test, the following callback functions are used, respectively.

// For Array
let arr_ = [];
each(EXAMPLE_ARRAY, (x) => {
  arr_.push(x);
});

// For Object
let obj_ = {};
each(EXAMPLE_OBJECT, (key, value) => {
  obj_[key] = value + 1;
});

The sizes of EXAMPLE_ARRAY and EXAMPLE_OBJECT are both 10000000. I timed each traversal method and counted the average of five timings as the test result.


Array

Benchmark script for array

Thanks to nullqube;

!(function () {
  const TEST_TIMES = 5;
  const ARRAY_SIZE = 10000000;
  const EXAMPLE_ARRAY = new Array(ARRAY_SIZE).fill(255);
  function time(name, each) {
    var totalDuration = 0;
    console.log(`[${name}]`);
    for (var i = 0; i < TEST_TIMES; i++) {
      let arr_ = [];
      const start = new Date();
      each(EXAMPLE_ARRAY, (x) => {
        arr_.push(x);
      });
      const end = new Date();
      var duration = end - start;
      console.log(`    * ${duration}ms`);
      totalDuration += duration;
      arr_ = undefined;
    }
    console.log(`    average: ${totalDuration / 5}ms`);
  }

  time("for i<len", (arr, func) => {
    var i, len;
    for (i = 0, len = arr.length; i < len; i++) {
      func(arr[i]);
    }
  });

  time("for i<arr.length", (arr, func) => {
    var i;
    for (i = 0; i < arr.length; i++) {
      func(arr[i]);
    }
  });

  time("while i<len", (arr, func) => {
    var i = 0,
      len = arr.length;
    while (i < len) {
      func(arr[i++]);
    }
  });

  time("forEach", (arr, func) => {
    arr.forEach(func);
  });

  time("for var..of..", (arr, func) => {
    for (var x of arr) {
      func(x);
    }
  });

  time("for let..of..", (arr, func) => {
    for (let x of arr) {
      func(x);
    }
  });

  time("for const..of..", (arr, func) => {
    for (const x of arr) {
      func(x);
    }
  });

  time("for var..in..", (arr, func) => {
    for (var i in arr) {
      func(arr[i]);
    }
  });

  time("for let..in..", (arr, func) => {
    for (let i in arr) {
      func(arr[i]);
    }
  });

  time("for const..in..", (arr, func) => {
    for (const i in arr) {
      func(arr[i]);
    }
  });

  time("Duff's device", (arr, func) => {
    var len = arr.length;
    var n = len % 8;
    var i;
    if (n > 0) {
      do {
        func(arr[len - n]);
      } while (--n); // n must be greater than 0 here
    }
    n = (len * 0.125) ^ 0;
    if (n > 0) {
      do {
        i = --n << 3;
        func(arr[i]);
        func(arr[i + 1]);
        func(arr[i + 2]);
        func(arr[i + 3]);
        func(arr[i + 4]);
        func(arr[i + 5]);
        func(arr[i + 6]);
        func(arr[i + 7]);
      } while (n); // n must be greater than 0 here also
    }
  });

  time("Duff's device (Ordered)", (arr, func) => {
    var len = arr.length;
    var block = (len * 0.125) ^ 0;
    var i, n;
    if (block > 0) {
      n = 0;
      do {
        i = n++ << 3;
        func(arr[i]);
        func(arr[i + 1]);
        func(arr[i + 2]);
        func(arr[i + 3]);
        func(arr[i + 4]);
        func(arr[i + 5]);
        func(arr[i + 6]);
        func(arr[i + 7]);
      } while (n < block); // n must be greater than 0 here also
    }
    n = len % 8;
    if (n > 0) {
      do {
        func(arr[len - n]);
      } while (--n); // n must be greater than 0 here
    }
  });
})();

Please note: I didn’t do a deep verification of my Duff’s Device algorithm implementation, I just tested it with some general arrays and verified it is correct. So there is no guarantee that this way of implementing the algorithm is completely correct. If someone finds a problem with it, please just point it out!

Result (details are here):

Chrome FireFox Safari Edge
for i<len 269.8ms 211.4ms 295.4ms 252.8ms
for i<arr.length 201.4ms 204ms 222.4ms 240.2ms
while i<len 258ms 201.2ms 363ms 184.2ms
forEach 332.8ms 235.6ms 260.2ms 272.2ms
for var…of… 310.6ms 254ms 360ms 249.6ms
for let…of… 272ms 241.8ms 339.2ms 236.6ms
for const…of… 289.2ms 261.8ms 342.2ms 271.6ms
for var…in… 3680.2ms 15953.6ms 6477.2ms 3151ms
for let…in… 2862.8ms 15977.8ms 6657.6ms 3001.6ms
for const…in… 3087.2ms 15790ms 8089ms 2947.2ms
Duff’s device 162.2ms 184.4ms 248.2ms 133.4ms
Duff’s device (Ordered) 176ms 184.6ms 241ms 175.8ms

As a result, Duff’s Device algorithm is even faster than for loop. Considering that it is more suitable for optimizing complex traversal operations, I think it should be used as the traversal method for arrays.


Object

Benchmark script for object

Thanks to pod4g/tool.

!(function () {
  const TEST_TIMES = 5;
  const OBJECT_SIZE = 10000000;
  const EXAMPLE_OBJECT = {};
  for (var i = 0, j = OBJECT_SIZE; i < j; i++) EXAMPLE_OBJECT[i] = i;
  function duffOrdered(arr, func) {
    var len = arr.length;
    var block = (len * 0.125) ^ 0;
    var i, n;
    if (block > 0) {
      n = 0;
      do {
        i = n++ << 3;
        func(arr[i]);
        func(arr[i + 1]);
        func(arr[i + 2]);
        func(arr[i + 3]);
        func(arr[i + 4]);
        func(arr[i + 5]);
        func(arr[i + 6]);
        func(arr[i + 7]);
      } while (n < block); // n must be greater than 0 here also
    }
    n = len % 8;
    if (n > 0) {
      do {
        func(arr[len - n]);
      } while (--n); // n must be greater than 0 here
    }
  }
  function time(name, each) {
    var totalDuration = 0;
    console.log(`[${name}]`);
    for (var i = 0; i < TEST_TIMES; i++) {
      let obj_ = {};
      const start = new Date();
      each(EXAMPLE_OBJECT, (key, value) => {
        obj_[key] = value + 1;
      });
      const end = new Date();
      var duration = end - start;
      console.log(`    * ${duration}ms`);
      totalDuration += duration;
      obj_ = undefined;
    }
    console.log(`    average: ${totalDuration / 5}ms`);
  }

  time("for..in..", (obj, func) => {
    for (var key in obj) {
      func(key, obj[key]);
    }
  });

  time("duff+Object.getOwnPropertyNames", (obj, func) => {
    duffOrdered(Object.getOwnPropertyNames(obj), (key) => {
      func(key, obj[key]);
    });
  });

  time("duff+Reflect.ownKeys", (obj, func) => {
    duffOrdered(Reflect.ownKeys(obj), (key) => {
      func(key, obj[key]);
    });
  });

  time("duff+Object.entries", (obj, func) => {
    duffOrdered(Object.entries(obj), (entry) => {
      func(entry[0], entry[1]);
    });
  });

  time("duff+Object.keys", (obj, func) => {
    duffOrdered(Object.keys(obj), (key) => {
      func(key, obj[key]);
    });
  });
  time("for+Object.keys", (obj, func) => {
    var keys = Object.keys(obj);
    var i, length;
    for (i = 0, length = keys.length; i < length; i++) {
      func(keys[i], obj[keys[i]]);
    }
  });
})();

Result (details are here):

Chrome FireFox Safari Edge
for…in… 3256.2ms 16665ms 6635.6ms 3199.2ms
duff+Object.getOwnPropertyNames 7922.6ms 1483ms 8904.8ms 9849.4ms
duff+Reflect.ownKeys 8602.4ms 1410ms 7912.8ms 8044.2ms
duff+Object.entries 11984.8ms 5405.8ms 8453.8ms 11502ms
duff+Object.keys 3120.4ms 1458.8ms 7920.6ms 3004.6ms
for+Object.keys 3178.8ms 1329.2ms 8613.2ms 3020ms

Surprisingly, the Duff’s Device algorithm is not the fastest way to iterate over objects, so a for loop should be used. But we noticed a detail that for + Object.keys is less efficient on Safari browser, so for... should be used. .in... loop for traversal.

3 Likes

@Sttot thank you for what looks like a very thorough examination. My recommendation is to post this as a Github Issue, you can even copy and paste this word for word:

We probably want to confirm the results on other OS and I suspect a PR would be welcome, though it would be good to hear from @jeremyruston before you invest time in a PR.

Thanks, I will copy this to a new issue. BTW, I have implemented the new each function and I have placed the code below.

var isSafari = $tw.browser && navigator && /version.*safari/i.test(navigator.userAgent);
$tw.utils.each = function(object, callback) {
    var next, f, n, length;
    if (object) {
        if (Object.prototype.toString.call(object) == "[object Array]") {
            length = object.length;
            var blocks = (length * 0.125) ^ 0;
            if (blocks > 0) {
                n = 0;
                do {
                    f = n++ << 3;
                    next = callback(object[f], f, object) === false ||
                        callback(object[f + 1], f, object) === false ||
                        callback(object[f + 2], f, object) === false ||
                        callback(object[f + 3], f, object) === false ||
                        callback(object[f + 4], f, object) === false ||
                        callback(object[f + 5], f, object) === false ||
                        callback(object[f + 6], f, object) === false ||
                        callback(object[f + 7], f, object) === false;
                    if (next) {
                        return;
                    }
                } while (n < blocks); // n must be greater than 0 here also
            }
            f = length % 8;
            if (f > 0) {
                do {
                    next = callback(object[length - f], length - f, object);
                } while (--f && next !== false); // n must be greater than 0 here
            }
        } else {
            if (isSafari) {
                for (var key in object) {
                    next = callback(object[key], key, object);
                    if (next === false) {
                        break;
                    }
                }
            } else {
                var keys = Object.keys(object);
                for (f = 0, length = keys.length; f < length; f++) {
                    var key = keys[f];
                    next = callback(object[key], key, object);
                    if (next === false) {
                        break;
                    }
                }
            }
        }
    }
};

In addition, I wrote a method to test the performance of both old and new each functions. I only have my macOS environment, so I need some assistance with testing. Anyone can run the following code in their browser console and we can work together to see if this algorithm really improves performance.

!(function() {
    // Initalize
    const TEST_TIMES = 50;
    const ARRAY_SIZE = 10000000;
    const OBJECT_SIZE = 10000000;
    const EXAMPLE_ARRAY = new Array(ARRAY_SIZE).fill(255);
    const EXAMPLE_OBJECT = {};
    for (var i = 0, j = OBJECT_SIZE; i < j; i++) EXAMPLE_OBJECT[i] = i;

    // Two each functions
    const isSafari = navigator && /version.*safari/i.test(navigator.userAgent);
    const each1 = function(object, callback) {
        var next, f, length;
        if (object) {
            if (Object.prototype.toString.call(object) == "[object Array]") {
                for (f = 0, length = object.length; f < length; f++) {
                    next = callback(object[f], f, object);
                    if (next === false) {
                        break;
                    }
                }
            } else {
                var keys = Object.keys(object);
                for (f = 0, length = keys.length; f < length; f++) {
                    var key = keys[f];
                    next = callback(object[key], key, object);
                    if (next === false) {
                        break;
                    }
                }
            }
        }
    };
    const each2 = function(object, callback) {
        var next, f, n, length;
        if (object) {
            if (Object.prototype.toString.call(object) == "[object Array]") {
                length = object.length;
                var blocks = (length * 0.125) ^ 0;
                if (blocks > 0) {
                    n = 0;
                    do {
                        f = n++ << 3;
                        next = callback(object[f], f, object) === false ||
                            callback(object[f + 1], f, object) === false ||
                            callback(object[f + 2], f, object) === false ||
                            callback(object[f + 3], f, object) === false ||
                            callback(object[f + 4], f, object) === false ||
                            callback(object[f + 5], f, object) === false ||
                            callback(object[f + 6], f, object) === false ||
                            callback(object[f + 7], f, object) === false;
                        if (next) {
                            return;
                        }
                    } while (n < blocks); // n must be greater than 0 here also
                }
                f = length % 8;
                if (f > 0) {
                    do {
                        next = callback(object[length - f], length - f, object);
                    } while (--f && next !== false); // n must be greater than 0 here
                }
            } else {
                if (isSafari) {
                    for (var key in object) {
                        next = callback(object[key], key, object);
                        if (next === false) {
                            break;
                        }
                    }
                } else {
                    var keys = Object.keys(object);
                    for (f = 0, length = keys.length; f < length; f++) {
                        var key = keys[f];
                        next = callback(object[key], key, object);
                        if (next === false) {
                            break;
                        }
                    }
                }
            }
        }
    };

    // Tests
    function timeArray(name, each) {
        var totalDuration = 0;
        console.log(`[${name} on Array]`);
        for (var i = 0; i < TEST_TIMES; i++) {
            let arr_ = [];
            const start = new Date();
            each(EXAMPLE_ARRAY, (x) => {
                arr_.push(x + 1);
            });
            const end = new Date();
            var duration = end - start;
            console.log(`    * ${duration}ms`);
            totalDuration += duration;
            arr_ = undefined;
        }
        console.log(`    average: ${totalDuration / TEST_TIMES}ms`);
    }

    function timeObject(name, each) {
        var totalDuration = 0;
        console.log(`[${name} on Object]`);
        for (var i = 0; i < TEST_TIMES; i++) {
            let obj_ = {};
            const start = new Date();
            each(EXAMPLE_OBJECT, (value, key) => {
                obj_[key] = value + 1;
            });
            const end = new Date();
            var duration = end - start;
            console.log(`    * ${duration}ms`);
            totalDuration += duration;
            obj_ = undefined;
        }
        console.log(`    average: ${totalDuration / TEST_TIMES}ms`);
    }

    timeArray('Original each', each1);
    timeArray('New each', each2);
    timeObject('Original each', each1);
    timeObject('New each', each2);
})();
2 Likes