16

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 B 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 A below)? 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
Rafał Cieślak
  • 972
  • 1
  • 8
  • 25
mogelbrod
  • 2,246
  • 19
  • 20
  • 1
    A quick test gives me 3300ms for 10 million iterations, for both options, so I guess JS engines do avoid recreating a constant at each function call. Hard to be sure, though. – Jeremy Thille Oct 17 '19 at 12:43
  • Although [this answer](https://stackoverflow.com/a/40070682/6624953) focuses on V8, I believe it will answer your question. – frobinsonj Oct 17 '19 at 12:57
  • @JeremyThille Yeah that would imply the engine powering your browser does optimize for this. I've been unable to find any write ups on this though - it would be interesting to know to what degree the engines optimize this. – mogelbrod Oct 17 '19 at 13:33
  • @frobinsonj it's not about `const` vs `let` or `var` which is what the linked answer mostly seems to cover, but rather about if the engines extracts `inlinedLookupTable` out of `inlinedAccess()` or not. – mogelbrod Oct 17 '19 at 13:35
  • I think you mixed up "Option A" and "Option B" in the text compared to the code. – jmrk Oct 17 '19 at 15:31
  • Indeed I did, updated the post! – mogelbrod Oct 18 '19 at 07:32

1 Answers1

18

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.)

jmrk
  • 34,271
  • 7
  • 59
  • 74
  • shouldn't that be `mapAccess("a")`... instead of looking up an integer which doesn't exist? performance is ~10x as good for me in that case – Sam Mason Oct 17 '19 at 17:41
  • I was hoping for an answer from an engine developer, and what I great one I got - thank you so much! I've created a new jsperf (link in updated question) after your feedback, and the results are definitely different this time! With that said I agree that in most cases the penalty is negligible. – mogelbrod Oct 18 '19 at 10:50
  • 1
    When interpreting the updated jsperf test's results, please keep in mind that (1) the time spent in `randomKey` is probably quite significant compared to the actual functions under test (just guessing; I haven't profiled it), making the relative differences appear smaller than they really are; and (2) by actually looking up `"b"` sometimes, the time to execute `new Array(100)` is included in the `switch` results, which probably explains why that option appears much slower than in my results quoted above. – jmrk Oct 18 '19 at 13:54
  • Absolutely! I assumed that some randomness was needed to give fair (uncached? unoptimized?) results (except for the switch approach in this case). Since `randomKey` is called once for every sample it should in theory only incur a constant cost. – mogelbrod Oct 18 '19 at 17:49
  • Constant cost, yes, but about as large as the thing you're trying to measure. A lower-overhead alternative would be to simply hardcode several accesses, e.g.: `globalVar = mapAccess("a"); globalVar = mapAccess("d");` – jmrk Oct 21 '19 at 13:49