ponyfoo.com

Investigating Performance of Object#toString in ES2015

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

In this article, we’ll discuss how Object.prototype.toString() performs in the V8 engine, why it’s important, how it changed with the introduction of ES2015 symbols, and how the baseline performance can be improved by up to 6x (based on findings from Mozilla engineers).

We’re thrilled to welcome Benedikt — the tech lead for JavaScript execution optimization at Google — to Pony Foo. He’s been sharing a great deal of insight into V8 performance internals and caveats elsewhere on the web, and we’re happy to host him on Pony Foo as well! ⏱

Introduction

The ECMAScript 2015 Language Standard introduced the concept of so-called well-known symbols to the JavaScript language. These are special built-in symbols which represent internal language behaviors that were not exposed to developers in ECMAScript 5 and earlier. Examples of these are:

Most of these newly introduced symbols affect several parts of the JavaScript language in non-trivial and cross-cutting ways, and lead to significant changes in the performance profile due to the additional monkey-patchability. Operations that were not observable by JavaScript code are all of a sudden observable and the behavior of these operations can be changed by user code.

One particularly interesting example of this is the new Symbol.toStringTag symbol, which is used to control the behavior of the Object.prototype.toString() built-in method. For example, a developer can now put this special property on any instance, and it is then used instead of the default built-in tag when the toString method is invoked:

class A {
  get [Symbol.toStringTag]() { return 'A'; }
}
Object.prototype.toString.call(‘’);     // "[object String]"
Object.prototype.toString.call({});     // "[object Object]"
Object.prototype.toString.call(new A);  // "[object A]"

This requires that the implementation of Object.prototype.toString() for ES2015 and later now converts its this value into an object first via the abstract operation ToObject and then looks for Symbol.toStringTag on the resulting object and in its prototype chain. The relevant part of the language specification looks like this:

Object.prototype.toString ()
Object.prototype.toString ()

Here you can see the ToObject conversion as well as the Get for @@toStringTag (this is special internal syntax for the language specification for the well-known symbol with the name toStringTag). The addition of Symbol.toStringTag in ES2015 adds a lot of flexibility for developers, but at the same time comes at a cost.

Motivation

The performance of the Object.prototype.toString() method in Chrome and Node.js has been under investigation in the past already, because it is used heavily by certain frameworks and libraries to perform type tests. For example, the AngularJS framework uses it to implement various helper functions like angular.isDate, angular.isArrayBuffer and angular.isRegExp (among others):

/**
 * @ngdoc function
 * @name angular.isDate
 * @module ng
 * @kind function
 *
 * @description
 * Determines if a value is a date.
 *
 * @param {*} value Reference to check.
 * @returns {boolean} True if `value` is a `Date`.
 */
function isDate(value) {
  return toString.call(value) === '[object Date]';
}

Also popular libraries like lodash and underscore.js use Object.prototype.toString() to implement checks on values, like the _.isPlainObject or _.isDate predicates provided by lodash:

/**
 * Checks if `value` is classified as a `Date` object.
 *
 * @since 0.1.0
 * @category Lang
 * @param {*} value The value to check.
 * @returns {boolean} Returns `true` if `value` is a date object, else `false`.
 * @example
 *
 * isDate(new Date)
 * // => true
 *
 * isDate('Mon April 23 2012')
 * // => false
 */
function isDate(value) {
  return isObjectLike(value) && baseGetTag(value) == '[object Date]'
}

The Mozilla engineers working on the SpiderMonkey JavaScript engine also identified the Symbol.toStringTag lookup in Object.prototype.toString() as bottleneck for real-world performance, as part of their Speedometer investigation. Running just the AngularJS subtest from the Speedometer benchmark suite using the internal V8 profiler (enabled by passing --no-sandbox --js-flags=--prof as command line flags to Chrome) we can see that a significant portion of the overall time is spent performing the @@toStringTag lookup (inside the GetPropertyStub) and the ObjectProtoToString code, which implements the Object.prototype.toString() built-in method:

Speedometer AngularJS performance profile
Speedometer AngularJS performance profile

Jan de Mooij from the SpiderMonkey team crafted a simple micro-benchmark to specifically test the performance of Object.prototype.toString() on Arrays:

function f() {
    var res = "";
    var a = [1, 2, 3];
    var toString = Object.prototype.toString;
    var t = new Date;
    for (var i = 0; i < 5000000; i++) res = toString.call(a);
    print(new Date - t);
    return res;
}
f();

In fact, running this simple micro-benchmark using the internal profiler built into V8 (enabled in the d8 shell via the --prof command line flag) already demonstrates the underlying problem: It is completely dominated by the Symbol.toStringTag lookup on the [1,2,3] array instance. Roughly 73% of the overall execution time is consumed by the negative property lookup (in the GetPropertyStub that implements the generic property lookup), and another 3% are wasted in the ToObject built-in, which is a no-op in case of arrays (since an Array is already an Object in the JavaScript sense).

Mozilla micro-benchmark performance profile (before)
Mozilla micro-benchmark performance profile (before)

Interesting symbols

The proposed solution for SpiderMonkey was to add the notion of an interesting symbol, which is a bit on every hidden class that says whether instances with this hidden class may have a property whose name is @@toStringTag or @@toPrimitive. This way the expensive search for Symbol.toStringTag can be avoided in the common case, where the lookup is negative anyways, which resulted in a 2x improvement on the simple micro-benchmark for SpiderMonkey.

Since I was looking specifically into some AngularJS use cases, I was happy to find this idea and see that it works out well. So I started thinking about the design and eventually ported it to V8, although limited to just Symbol.toStringTag and Object.prototype.toString() for now, as I haven’t found evidence (yet) that Symbol.toPrimitive is a major pain point in Chrome or Node.js. The fundamental idea is that by default we assume that instances don’t have interesting symbols, and every time we add a new property to an instance, we check whether that property’s name is an interesting symbol, and if so we set the bit on the instances hidden classes.

const obj = {};
Object.prototype.toString.call(obj);  // fast-path
obj[Symbol.toStringTag] = 'a';
Object.prototype.toString.call(obj);  // slow-path

Check this simple example: Here obj starts life as an instance with definitely no interesting symbols on it. So the first call to Object.prototype.toString() takes the new fast-path, where the Symbol.toStringTag lookup can be skipped (also because the Object.prototype doesn’t have any interesting symbols on it), whereas the second call takes the generic slow-path because obj now has an interesting symbol.

Performance

Implementing this mechanism in V8 improves the performance on the above mentioned micro-benchmark by roughly 5.8x on a Z620 Linux workstation. And checking the performance profile again, we can see that we no longer spend time in the GetPropertyStub, but the micro-benchmark is now dominated by the Object.prototype.toString() built-in as expected:

Mozilla micro-benchmark performance profile (after)
Mozilla micro-benchmark performance profile (after)

Running this on a slightly more realistic benchmark, which passes different values to Object.prototype.toString(), including primitives and objects which have a custom Symbol.toStringTag property, shows up to 6.5x improvements in the latest V8 compared to V8 6.1.

Micro-benchmark results
Micro-benchmark results

Measuring the impact on the Speedometer browser benchmark, specifically the AngularJS subtest in the benchmark suite, it seems to yield a 1% overall improvement on the full suite and a solid 3% on the AngularJS subtest.

Speedometer results
Speedometer results

Conclusion

Even a highly optimized built-in like Object.prototype.toString() still provides some potential for further optimization - leading up to 6.5x improvements in throughput - if you dig deep enough into appropriate performance tests (like the Speedometer AngularJS benchmark in this case). Kudos to Jan de Mooij and Tom Schuster from Mozilla for doing the investigation in this case, and coming up with the cool idea of interesting symbols!

It’s worth noting that JavaScriptCore, the JavaScript engine used by WebKit, caches the result of subsequent Object.prototype.toString() calls on the hidden class of the receiver instance (that cache was introduced in early 2012, so it predates ES2015). It’s a very interesting strategy, but it has limited applicability (i.e. it doesn’t help with other well-known symbols like Symbol.toPrimitive or Symbol.hasInstance) and requires pretty complex invalidation logic to react to changes in the prototype chain, which is why I decided against a caching based solution in V8 (for now).

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments