Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Do javascript engines optimize constants defined within closures?

Imagine I have a function which accesses a constant (never mutated) variable (lookup table or array, for example). The constant is not referenced anywhere outside the function scope. My intuition tells me that I should define this constant outside the function scope (Option A below) to avoid (re-)creating it on every function invocation, but is this really the way modern Javascript engines work? I'd like to think that modern engines can see that the constant is never modified, and thus only has to create and cache it once (is there a term for this?). Do browsers cache functions defined within closures in the same way?

Are there any non-negligible performance penalties to simply defining the constant inside the function, right next to where it's accessed (Option B)? Is the situation different for more complex objects?

// Option A:
function inlinedAccess(key) {
  const inlinedLookupTable = {
    a: 1,
    b: 2,
    c: 3,
    d: 4,
  }

  return 'result: ' + inlinedLookupTable[key]
}

// Option B:
const CONSTANT_TABLE = {
  a: 1,
  b: 2,
  c: 3,
  d: 4,
}
function constantAccess(key) {
  return 'result: ' + CONSTANT_TABLE[key]
}

Testing in practice

I created a jsperf test which compares different approaches:

  1. Object - inlined (option A)
  2. Object - constant (option B)

Additional variants suggested by @jmrk:

  1. Map - inlined
  2. Map - constant
  3. switch - inlined values

Initial findings (on my machine, feel free to try it out for yourself):

  • Chrome v77: (4) is by far the fastest, followed by (2)
  • Safari v12.1: (4) is slightly faster than (2), lowest performance across browsers
  • Firefox v69: (5) is the fastest, with (3) slightly behind
like image 555
mogelbrod Avatar asked Oct 17 '19 12:10

mogelbrod


People also ask

How do constants work in JavaScript?

Constants are block-scoped, much like variables declared using the let keyword. The value of a constant can't be changed through reassignment (i.e. by using the assignment operator), and it can't be redeclared (i.e. through a variable declaration).

Does const improve performance JavaScript?

Just like we saw for variables, there was no difference in performance for functions declared as const variables using the arrow syntax compared to those declared using the traditional function keyword syntax.

Is const or let faster?

Clearly, performance-wise on Chrome, let on the global scope is slowest, while let inside a block is fastest, and so is const.

Why is const better than let?

`const` is a signal that the identifier won't be reassigned. `let` is a signal that the variable may be reassigned, such as a counter in a loop, or a value swap in an algorithm. It also signals that the variable will be used only in the block it's defined in, which is not always the entire containing function.


1 Answers

V8 developer here. Your intuition is correct.

TL;DR: inlinedAccess creates a new object every time. constantAccess is more efficient, because it avoids recreating the object on every invocation. For even better performance, use a Map.

The fact that a "quick test" yields the same timings for both functions illustrates how easily microbenchmarks can be misleading ;-)

  • Creating objects like the object in your example is quite fast, so the impact is hard to measure. You can amplify the impact of repeated object creation by making it more expensive, e.g. replacing one property with b: new Array(100),.
  • The number-to-string conversion and subsequent string concatenation in 'result: ' + ... contribute quite a bit to the overall time; you can drop that to get a clearer signal.
  • For a small benchmark, you have to be careful not to let the compiler optimize away everything. Assigning the result to a global variable does the trick.
  • It also makes a huge difference whether you always look up the same property, or different properties. Object lookup in JavaScript is not exactly a simple (== fast) operation; V8 has a very fast optimization/caching strategy when it's always the same property (and the same object shape) at a given site, but for varying properties (or object shapes) it has to do much costlier lookups.
  • Map lookups for varying keys are faster than object property lookups. Using objects as maps is so 2010, modern JavaScript has proper Maps, so use them! :-)
  • Array element lookups are even faster, but of course you can only use them when your keys are integers.
  • When the number of possible keys being looked up is small, switch statements are hard to beat. They don't scale well to large numbers of keys though.

Let's put all of those thoughts into code:

function inlinedAccess(key) {
  const inlinedLookupTable = {
    a: 1,
    b: new Array(100),
    c: 3,
    d: 4,
  }
  return inlinedLookupTable[key];
}

const CONSTANT_TABLE = {
  a: 1,
  b: new Array(100),
  c: 3,
  d: 4,
}
function constantAccess(key) {
  return CONSTANT_TABLE[key];
}

const LOOKUP_MAP = new Map([
  ["a", 1],
  ["b", new Array(100)],
  ["c", 3],
  ["d", 4]
]);
function mapAccess(key) {
  return LOOKUP_MAP.get(key);
}

const ARRAY_TABLE = ["a", "b", "c", "d"]
function integerAccess(key) {
  return ARRAY_TABLE[key];
}

function switchAccess(key) {
  switch (key) {
    case "a": return 1;
    case "b": return new Array(100);
    case "c": return 3;
    case "d": return 4;
  }
}

const kCount = 10000000;
let result = null;
let t1 = Date.now();
for (let i = 0; i < kCount; i++) {
  result = inlinedAccess("a");
  result = inlinedAccess("d");
}
let t2 = Date.now();
for (let i = 0; i < kCount; i++) {
  result = constantAccess("a");
  result = constantAccess("d");
}
let t3 = Date.now();
for (let i = 0; i < kCount; i++) {
  result = mapAccess("a");
  result = mapAccess("d");
}
let t4 = Date.now();
for (let i = 0; i < kCount; i++) {
  result = integerAccess(0);
  result = integerAccess(3);
}
let t5 = Date.now();
for (let i = 0; i < kCount; i++) {
  result = switchAccess("a");
  result = switchAccess("d");
}
let t6 = Date.now();
console.log("inlinedAccess: " + (t2 - t1));
console.log("constantAccess: " + (t3 - t2));
console.log("mapAccess: " + (t4 - t3));
console.log("integerAccess: " + (t5 - t4));
console.log("switchAccess: " + (t6 - t5));

I'm getting the following results:

inlinedAccess: 1613
constantAccess: 194
mapAccess: 95
integerAccess: 15
switchAccess: 9

All that said: these numbers are "milliseconds for 10M lookups". In a real-world application, the differences are probably too small to matter, so you can write whatever code is most readable/maintainable/etc. For example, if you only do 100K lookups, the results are:

inlinedAccess: 31
constantAccess: 6
mapAccess: 6
integerAccess: 5
switchAccess: 4

By the way, a common variant of this situation is creating/calling functions. This:

function singleton_callback(...) { ... }
function efficient(...) {
  return singleton_callback(...);
}

is much more efficient than this:

function wasteful(...) {
  function new_callback_every_time(...) { ... }
  return new_callback_every_time(...);
}

And similarly, this:

function singleton_method(args) { ... }
function EfficientObjectConstructor(param) {
  this.___ = param;
  this.method = singleton_method;
}

is much more efficient than this:

function WastefulObjectConstructor(param) {
  this.___ = param;
  this.method = function(...) { 
    // Allocates a new function every time.
  };
}

(Of course the usual way of doing it is Constructor.prototype.method = function(...) {...}, which also avoids repeated function creations. Or, nowadays, you can just use classes.)

like image 120
jmrk Avatar answered Oct 19 '22 14:10

jmrk