javascriptprofilingv8microbenchmark

V8 performance comparison of using PlainObject Vs es6 Class Vs oldSchool Class


I'm trying to understand how v8 optimizer works in order to get the most performance of my javascript code which makes some pretty intensive computation.

For this purpose I made some benchmarks on Matrix composition using 3 different options for holding the 6 matrix values called Xx, Xy, Yx, Yy, Tx, Ty:

1 - using Plain Object :

    XfObj.new = (Xx, Xy, Yx, Yy, Tx, Ty) => {
        return { Xx, Xy, Yx, Yy, Tx, Ty };
    }

2 - using class

    class XfCls_ {
        Xx;
        Xy;
        Yx;
        Yy;
        Tx;
        Ty;
        constructor(Xx, Xy, Yx, Yy, Tx, Ty) {
            this.Xx = Xx;
            this.Xy = Xy;
            this.Yx = Yx;
            this.Yy = Yy;
            this.Tx = Tx;
            this.Ty = Ty;
        }
    }
    XfCls.new = (Xx, Xy, Yx, Yy, Tx, Ty) => {
        return new XfCls_(Xx, Xy, Yx, Yy, Tx, Ty);
    }

3 - using old school class construction

    const XfCls2_ = function XfCls2_(Xx, Xy, Yx, Yy, Tx, Ty) {
         this.Xx = Xx;
         this.Xy = Xy;
         this.Yx = Yx;
         this.Yy = Yy;
         this.Tx = Tx;
         this.Ty = Ty;
         return this;
    };
    XfCls2.new = (Xx, Xy, Yx, Yy, Tx, Ty) => {
        return new XfCls2_(Xx, Xy, Yx, Yy, Tx, Ty);
    }

And for some reasons the performances are really not the same

1 - plain object : 3569 ms

2 - es6 class : 13577 ms

3 - old school : 2519 ms.

Note that for case 2, if I removed the field declaration of the class so only the constructor remains in the class body, I get same performances as old school class, so the difference probably comes for that, but then why having field declarations would make class instances that slow ?

The detail of the computation is not important here. I have some 3 big array of references to matrix, and I browse then iteratively to perform matrix composition. And for you to know the initilisation code and warm up is not measured, and each run is independant from each other, so everything remains monomorphic (I checked that with v8-deopt-viewer).

Other thing that was surprising, if I check with v8-natives, none of these 3 have the same Map, whereas their debugPrint is pretty similar.

Any idea of what is going on here ?

I've gone through V8 doc, blogPosts and some videos, but did not find anything relevant. also v8-natives does not really help as the only useful functions are debugPrint and haveSameMap.


Solution

  • That's a surprising difference indeed, and off the top of my head I don't know what might be causing it. I'd have to investigate a case where it can be observed, but toying around with the snippets you provided, I cannot reproduce any significant performance differences.

    So, if you want more help with this, please post a complete reproducible example. (Update: done, see addendum below.)

    Or you could just move on: there are no benefits to "declaring" properties outside the constructor (quotes because "declaring properties" is not a concept that exists in JavaScript), but apparently in your case there's a significant drawback to it, so just save yourself the extra typing work and don't "declare" properties. Simply write:

    class XfCls_ {
      constructor(Xx, Xy, Yx, Yy, Tx, Ty) {
        this.Xx = Xx;
        this.Xy = Xy;
        this.Yx = Yx;
        this.Yy = Yy;
        this.Tx = Tx;
        this.Ty = Ty;
      }
    }
    

    and call it a day.

    (By the way, return this at the end of the old-school constructor also serves no purpose and could just be omitted.)

    if I check with v8-natives, none of these 3 have the same Map

    That's expected. Different constructors always produce objects with different maps, even if the object layouts are exactly the same.


    Addendum after providing a repro case:
    Thanks for the repro, that makes it pretty clear what's happening. Turns out the declared fields aren't just ignored; instead they're effectively treated roughly like:

      this.Xx = undefined;  // From the declaration.
      this.Xx = Xx;         // From the constructor code.
    

    (Which is because, as I learned today, they can actually have semantic meaning in certain somewhat exotic cases: they cause any setters for the same property on superclasses to be overridden with a plain property on the subclass.)
    And that, in turn, defeats V8's "field type tracking" mechanism: without the declaration, V8 correctly observes that the field has always held a number so far, and optimizes based on the assumption that this will continue to hold by using a mutable/reusable number box for the property's value, which makes it cheap to store a new value in there. With the field declaration (and implicit undefined-initialization), V8 thinks (somewhat correctly...) that the field has held both undefined and numbers in the past, so it doesn't apply the same optimization, so assignments to that property cause fresh (immutable) HeapNumbers to be allocated every time, which has several direct and indirect negative effects on performance.

    We'll see if we can improve something about this in V8.

    In the meantime, there are two workarounds you can do on your end: (1) As I suggested before, don't declare class fields. In TypeScript, there's the useDefineForClassFields setting which you can turn off to not get them. (2) Initialize the properties to a number, e.g.:

    class XfCls {
      Xx: Number = 0;  // Note the '= 0' part.
      ...
    }
    

    which will ensure the invariant that Xx always holds a number value. (The extra assignment will be optimized out.)