Search code examples
javascriptperformanceoptimizationv8

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

Solution

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