JS: Benchmarking Lazy Getters
Lazily evaluating properties is not just handy, but mandatory in some case. This post explains strategies and provides answers regarding this topic.
Some Background
Where I work, we deal daily with hundred thousand things to eventually parse when needed, and having lazy resolutions, in terms of parsing time, to provide meaningful results on demand is not an option!
Without boring you with too many details, I wanted to understand if the way we are lazy-parsing things is efficient, and here there’s some result.
The Pattern
JS provides many ways to do the same thing, and that’s both awesome and cumbersome, as we don’t have an opinionated, “always best for the job”, way of doing such things … so we need to explore, which is arguably another great side-effect of using such flexible programming language 🥳
And when it comes to lazy property evaluation, we have these choices:
Lazy Override
class LazyOverride {
get value() {
let value = Math.random();
Object.defineProperty(this, 'value', {value});
return value;
}
}
Aka shadowed, this pattern is based on classes, and it uses a shared accessor to override once the getter, whenever it’s accessed (inheritance shadow).
The idea behind, is to flag the accessor as direct property only when it’s been accessed for the very first time.
This pattern shares the class prototype for all instances, so it’s super cheap, when it comes to create instances, but it needs to do some work whenever a property is accessed for the first time.
Lazy Known
class LazyKnown {
constructor() {
this._value = null;
}
get value() {
return this._value || (this._value = fn());
}
}
Aka shaped, this pattern needs a constructor to attach a pseudo-private property to the instance, so that such property can be populated the very first time it gets accessed, but the instance “shape” is known in advance.
The difference with the Lazy Override pattern, is that:
- there is a constructor that needs to set a default value to the pseudo-hidden property.
- the getter will be invoked every single time the property is accessed, as opposite of becoming the property itself, without prototype round-trip.
Expectations here, in terms of performance, is that the constructor does more, hence it’ll be slower, and the getter is executed every single time, which is also, theoretically, slower.
What else could we do?
Object Literal
const getRandom = () => Math.random();function Literal() {
let value = null;
return {
get value() {
return value || (value = getRandom());
}
};
}
There is this long-tail-story about object literals being the fastest thing in JS:
- functional programming oriented developer don’t even understand the need for classes.
- old school Web developers know that classes were never a thing, and ES2015 ruined the Web introducing these, because good old prototypal inheritance was enough.
While this post intent is not to argue against any of these points, there is a third consideration some dev might have done:
- v8 and Chrome optimize the hell out of object literals, so object literals is the only thing we should use!
About that … I have good and bad news … good: object literals are super fast in JS, but bad: if you use accessors, each literal needs its own scope per each property accessor!
On the other side though, using functions as factory to create these literals, enables what today is known as private properties, as the scope can be abused to handle these cases with ease.
Round #1
The benchmark constantly reveal the heap memory, plus it uses the console.time
API to measure the following:
- how long did it take to create all instances?
- how long did it take to access that
value
property for the first time, per each instance? - how long did it take, once the property was accessed already, to retrieve back the same data?
- how long did it take to repeatedly get such data every next execution time?
And finally it shows the total heap used, and the time it took to execute the benchmark.
A first look
The literal idea is the worst ever, as the creation time is 8x classes based, the heap is 5x the one needed for classes/prototypal based instances, and while accessing the value
property for the first time wins, repeated access through the scope is slower than instances based inheritance via accessors.
When does this matter
A few consistent results that differ few ms might not feel like a great achievement, if your laptop is “beefy”, so that to have a better idea of what was going on, and what was definitively the best approach, in general, and not only on my hardware, or OS, I decided to run the very same benchmark on a Raspberry Pi 4 with 4GB of RAM: enough to exclude heap issues for this test!
Well, I believe the image speaks for itself, but here the summary:
- Lazy Known, aka shaped, won pretty much everywhere, meaning that describing what the object properties will be wins, optimized at the engine level almost more than anything else
- Lazy Override, aka shadowed, won in terms of repeatedly property access, and 12ms vs 16ms means concretely nothing in a 200K array of instances
- Object literals lost almost any advantage whatsoever: heavier on the heap, much slower on creation, slightly faster than shaped instances on first access, but also irrelevantly faster in there for a 200K array
And the winner is …
Among these 3 patterns, the clear winner is the shaped approach: irrelevantly faster, or slower, on instance creation, 2/3rd or 1/4th+ heap needed, and more than twice as fast at finalizing the benchmark … but is this pattern the absolute best one?
About Sub Patterns
The shaped pattern is based on the fact an instance shape is known at construction time, and such property addressed via an _prop
underscore prefix convention, that is meaningless for the JS engine, but meaningful for the developer.
And here the catch:
- JS offers private properties these days, so that using an underscore prefix convention to mean such property is private, and it shouldn’t be addressed directly outside its class definition, feels too much like 90s’
- JS previously offered the
Symbol
primitive, which is enumerable like any other property, but it grants no name clashing, plus it’s harder to have in our way out there, yet it’s a direct property like_prop
would be.
So I went ahead and benchmarked these alternatives too!
Private Properties
const getRandom = () => Math.random();class LazyGetterPrivate {
#value = null;
get value() {
return this.#value || (this.#value = getRandom());
}
}
This pattern ensures no code out there could access #value
, and it’s backed on the language, so we expect this to be natively both the right tool for the job, but also the fastest!
Symbol Properties
const VALUE = Symbol('value');const setSymbol = self => {
let value = Math.random();
self[VALUE] = value;
return value;
};class LazyGetterSymbol {
constructor() {
this[VALUE] = null;
}
get value() {
return this[VALUE] || setSymbol(this);
}
}
This patterns is basically the same as the shaped one, or Lazy Known, we’ve seen before, except it grants the property won’t clash with any other property, and it’s well defined as constant, for what it’s supposed to represent.
Symbols are not private though, but if none of your code explicitly tries to access symbols too, these are a very safe bet in terms of intent => behavior.
Round #2
Narrowing down to direct pattern competitors here revealed a few things:
- between shaped and symbol strategy, nobody really wins: repeated runs of the same benchmark sees one, or the other, win for a very irrelevant margin, in terms of creation, first access, or repeated one.
- private properties add nothing on the heap side, which is some awesome news, but it offers nothing more in terms of performance, and if the goal is to improve lazy properties access, the extra cost on creation becomes something not-acceptable, as the whole goal here is to be as fast as possible in creation, and eventually slightly slower on property access.
The Verdict
Using symbols shouldn’t scare developers, but I haven’t noticed wide adoption out there. However, LinkeDOM is based on this “name-clashing-less” pattern, and its performance pulverizes any known competitor, so I start thinking we really overlooked at this pattern.
However, since 200K instances passed around might have some code trying to set, or get, random properties, feeling safer on the “not so easy to guess” side, is definitively something I’m down with, conscious that patterns able to reveal, or pass around, symbols, are very rare, except for the Object.assign
operation, which is able to move around all known properties, and symbols, by default, are like that.
Anyway, the summary of this post is:
- never trust your laptop is meaningful for a specific benchmark, try to benchmark on slower devices to have a better, realistic, view, of your conclusions.
- don’t overdo lazy accessors, and use any extra pseudo-private property strategy to obtain best performance.
- don’t believe classes-less JS is faster or better at anything, in general.
- don’t over-do lazy accessors, and fallback to what you’ve always known, to day, was very fast, and easy to deal with, that being
_prefixed
properties, or, maybe,Symbol('name')
one!
What about other engines?
Both different versions and different engines see the shaped approach win.
If nothing, it’s worth mentioning node v6 literal benchmark is twice as slow and with an even bigger heap memory consumption.