2023-06-02

1685678400
notes
 02 Jun 2023  notes

Testing an Imperative Loop versus Higher Order Functions in JavaScript

Modern JavaScript offers a lovely suite of useful array methods that can loop over arrays in elegant and functional ways. Of course, they usually come with overhead since they’re either an abstraction on the built-in iterator methods, or they create an in-memory copy of the array to do operations.

I decided to make a little micro benchmark testing common higher-order array methods against a basic iterative for loop.

TL;DR the for loop wins, but the results illustrate just how much faster it is.

Setup

Since this test is using Node, making use of argv allowed for arguments to be passed via the command line as an array:

import { argv } from "node:process"

let [_, __, _size, _fill] = argv

From the Node.js documentation, the process.argv property returns an array containing the command-line arguments passed when the Node.js process was launched.

The first two arguments passed in by argv are the path of the Node.js executable, and the path of the current file being run, hence I used _ and __ to deconstruct them into variables that won’t be used.

The size (_size) of the array and the contents of each element (_fill) are what will be used to run the micro benchmarks.

I measured the performance using a simple function that sums all the array elements, and used the following methods for testing:

  • Array.reduce method
  • Array.map method
  • Array.forEach method
  • for..of loop
  • for loop

Instrumenting

I chose to use performance.now rather than console.time to measure the execution time of each function. In my view performance.now is more modern and the output can be stored in a variable which is preferable for doing calculations.

Whenever performance.now is called, it returns a timestamp as a float. I stored two timestamps (start and end), and then subtracted the difference to measure how long the operation took to execute:

let start = performance.now()
/* Some Operation */
let end = performance.now()

Since start and end are within the function scope, the measurement structure can be used across all the methods being tested. To achieve this in a DRY manner, I created an elapsedTime function that does the calculation and also truncates the output to a readable integer value in milliseconds:

function elapsedTime(method, start, end) {
  console.log(`${method} --> ${Math.trunc(end - start)} ms`)
}

Creating Tests

The first test function is Array.reduce and including the instrumentation code above, it looks like:

function sumReduce(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = array.reduce((acc, cur) => (cur += acc))
  let end = performance.now()
  elapsedTime(reduce, start, end)
  // console.log(result)
}

The code for the Array.map method:

function sumMap(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  array.map((element) => (result += element))
  let end = performance.now()
  elapsedTime(reduce, start, end)
  // console.log(result)
}

The code for the Array.forEach method:

function sumForEach(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  array.forEach((element) => (result += element))
  let end = performance.now()
  elapsedTime(forEach, start, end)
  // console.log(result)
}

The code for a slightly lower-level for..of loop:

function sumForOf(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  for (let element of array) {
    result += element
  }
  let end = performance.now()
  elapsedTime(forOf, start, end)
  // console.log(result)
}

And finally the good old for loop:

function sumForLoop(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  for (let i = 0; i < size; i++) {
    result += array[i]
  }
  let end = performance.now()
  elapsedTime(forLoop, start, end)
  // console.log(result)
}

The console.log in each was to check if all the tests actually produce the same output.

The last thing I did was to create a function to automatically run the tests. Here’s the code:

function runTests(_size, _fill) {
  console.log(`${Number(_size).toLocaleString()} Elements of Value ${_fill}`)
  sumReduce(Number(_size), Number(_fill))
  sumMap(Number(_size), Number(_fill))
  sumForEach(Number(_size), Number(_fill))
  sumForOf(Number(_size), Number(_fill))
  sumForLoop(Number(_size), Number(_fill))
  console.log("\n----------\n")
}

The function runTests will accept the _size and _fill from the command line (via argv) and pass them to each testing function.

Since Node’s command line arguments are technically strings, they need to be converted to a number via Number(_size) and Number(_fill) respectively.

You’ll notice Number(_size).toLocaleString() is interpolated. The toLocaleString method ensures the array’s size is more readable. If the number is 25000000 then it’ll be shown as 25,000,000 as an example.

The final code for the entire file looks like:

import { argv } from "node:process"

let [_, __, _size, _fill] = argv

let reduce = "Array.reduce"
let map = "Array.map"
let forEach = "Array.forEach"
let forOf = "for..of"
let forLoop = "for"

function elapsedTime(method, start, end) {
  console.log(`${method} --> ${Math.trunc(end - start)} ms`)
}

function sumReduce(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = array.reduce((acc, cur) => (cur += acc))
  let end = performance.now()
  elapsedTime(reduce, start, end)
  // console.log(result)
}

function sumMap(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  array.map((element) => (result += element))
  let end = performance.now()
  elapsedTime(map, start, end)
  // console.log(result)
}

function sumForEach(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  array.forEach((element) => (result += element))
  let end = performance.now()
  elapsedTime(forEach, start, end)
  // console.log(result)
}

function sumForOf(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  for (let element of array) {
    result += element
  }
  let end = performance.now()
  elapsedTime(forOf, start, end)
  // console.log(result)
}

function sumForLoop(size, fill) {
  let array = Array(size).fill(fill)
  let start = performance.now()
  let result = 0
  for (let i = 0; i < size; i++) {
    result += array[i]
  }
  let end = performance.now()
  elapsedTime(forLoop, start, end)
  // console.log(result)
}

function runTests(_size, _fill) {
  console.log(`${Number(_size).toLocaleString()} Elements of Value ${_fill}`)
  sumReduce(Number(_size), Number(_fill))
  sumMap(Number(_size), Number(_fill))
  sumForEach(Number(_size), Number(_fill))
  sumForOf(Number(_size), Number(_fill))
  sumForLoop(Number(_size), Number(_fill))
  console.log("\n----------\n")
}

runTests(_size, _fill)

Where runTests(_size, _fill) accepts the CLI arguments and passes them to the tests.

Running Tests

To run against custom data, the command is node index.js 5000 50, where 5000 is the size of the array and 50 is the integer value of each element.

However, to make the tests more automated, I created a test command in package.json that includes a warmup run for the garbage collector and then runs the tests against a few different array sizes. The command is:

echo \"Warmup\" && node index.js 50000 50 && clear && node index.js 1000000 100 && node index.js 10000000 250 && node index.js 12345678 1337 && node index.js 25000000 500

Which runs the tests against following data:

  • 1,000,000 Elements of Value 100
  • 10,000,000 Elements of Value 250
  • 12,345,678 Elements of Value 1337
  • 25,000,000 Elements of Value 500

Trying to test array sizes above 25 million elements sends the CPU usage above 90%, causing the CodeSandbox MicroVM to hang.

Here’s an example of test output from within CodeSandbox:

1,000,000 Elements of Value 100
Array.reduce --> 9 ms
Array.map --> 13 ms
Array.forEach --> 8 ms
For..of --> 20 ms
For --> 2 ms

----------

10,000,000 Elements of Value 250
Array.reduce --> 98 ms
Array.map --> 277 ms
Array.forEach --> 107 ms
For..of --> 151 ms
For --> 16 ms

----------

12,345,678 Elements of Value 1337
Array.reduce --> 142 ms
Array.map --> 1177 ms
Array.forEach --> 215 ms
For..of --> 222 ms
For --> 25 ms

----------

25,000,000 Elements of Value 500
Array.reduce --> 280 ms
Array.map --> 2613 ms
Array.forEach --> 453 ms
For..of --> 359 ms
For --> 41 ms

Charting Tests

Below are the comparisons in charts of the different methods and array sizes tested:

1,000,000 Elements of Value 100
10,000,000 Elements of Value 250
12,345,678 Elements of Value 1337
25,000,000 Elements of Value 500

As expected, the for loop is more performant than the competitors by a large margin (usually 8x-10x across all sample sizes). It’s of course a micro benchmark and running an operation on an array of 25 million elements isn’t something found in the real world.

While it’s easier (and more intuitive in my opinion) to use modern array methods like reduce and map, it’s also a good idea if you have the time to go back and refactor your code to be a bit more imperative and lower-level where possible, and then test real-world performance using tools such as Chrome’s V8 Profiler, etc.

Copyright © Paramdeo Singh · All Rights Reserved · Built with Jekyll

This node last updated October 9, 2024 and is permanently morphing...

Paramdeo Singh Guyana

Generalist. Edgerunner. Riding the wave of consciousness in this treacherous mortal sea.

Technology Design Strategy Literature Personal Blogs
Search Site

Results are from Blog, Link Dumps, and #99Problems