Repository navigation
Performance overhead of --classTypes #2159
Description
Description
As noted in a few other issues, the --classTypes compiler option results in code that performs anywhere up to 30% worse than without that option.
Related discussions:
- Improve TypeScript typing for unions #2096 (comment)
- Added support for classes #2056
- No base class for types that don't inherit from other types #2153
Background
The class constructor for the Union base class is defined in TypeScript, and is transpiled to:
constructor(tag, name, ...fields) {
super();
this.tag = tag | 0;
this.name = name;
this.fields = fields;
}For Fable 2.x, union type declarations are generated as follows (sample from fsharp/src/TypedTree):
// no class types
export const ValInline = declare(function FSharp_Compiler_TypedTree_ValInline(tag, name, ...fields) {
this.tag = tag | 0;
this.name = name;
this.fields = fields;
}, Union);
// class types
export class ValInline extends Union {}declare defers to inherits:
function inherits(subClass, superClass) {
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true,
},
});
}Observations
The default compiler output defines an ES5-style constructor function which duplicates Union's constructor body. declare then splices Union.prototype onto the compiler-generated function's prototype. This does not actually invoke Union. As far as I can tell, these factory calls are effectively inlined by v8.
The classTypes version does not define a constructor, but v8 still creates a stub constructor from v8's builtins causing the effective class declaration to be something like:
export class ValInline extends Union {
constructor(...args) {
super(...args);
}
}Comparing bottom-up traces from perf shows a significant increase in time spent in builtin StoreIC, which I believe is v8 caching generated code for future inlining. Union is the most significant contributor to that time, but is never directly invoked by derived constructors - there is always either one or two layers of indirection, the builtin JSConstructStubGeneric created to stand in as the derived constructor, and builtin ArgumentsAdaptorTrampoline.
Analysis
ES5 union factories don't even show up in perf, as far as I can tell. I suspect this means v8 is able to effectively inline them.
I don't have a complete understanding of what's causing the additional overhead, but my reading of some of the v8 design docs around ES classes lead me to believe there are two confounding factors causing v8 to struggle with Union, specifically:
- Union's constructor is massively polymorphic over
this, causing deoptimisation and I believe inline cache misses causing the additional time spent in StoreIC [1] - Union's constructor is under-applied for valueless cases, which I think is why it's being wrapped in
ArgumentsAdaptorTrampoline[2]
I suspect the indirection is also preventing v8 from inlining derived constructors.
Fix
By declaring Union as abstract in its TypeScript definition, deleting its constructor and having Fable emit constructor implementations at each derived Union implementation, v8 appears to be able to effectively optimise the class constructors. The emitted code is similar in size to the ES5 factories. The bottom-up traces from perf are extremely similar and running time is almost identical.
Sample output:
export class ValInline extends Union {
constructor(tag, name, ...fields) {
super();
this.tag = tag | 0;
this.name = name;
this.fields = fields;
}
}Results
$ node --version
v14.10.0
$ uname -sr
Linux 5.4.0-47-generic
$ time node out-node/app.js bench-compiler.fsproj out-node2
# No class types
________________________________________________________
Executed in 161.88 secs fish external
usr time 204.79 secs 413.00 micros 204.78 secs
sys time 2.58 secs 74.00 micros 2.58 secs
$ time node out-node/app.js bench-compiler.fsproj out-node2
# Class types
________________________________________________________
Executed in 167.87 secs fish external
usr time 224.88 secs 0.00 micros 224.88 secs
sys time 2.70 secs 1215.00 micros 2.70 secs
References
- Making ES2015 classes fast (see Megamorphicity of super class methods)
- Rest parameters and arguments object optimization plan
- Octane/Typescript performance investigation
Activity
chrisvanderpennen commentedon Sep 11, 2020
Let me tidy a couple of things up and I'll get a PR in.
ncave commentedon Sep 11, 2020
@chrisvanderpennen Thanks for the awesome analysis, this is great!
Unfortunately, if it's just union constructors that are missing, #2153 already creates constructors for every union. That helps, but not much. But your point about the polymorphic constructor makes sense.
chrisvanderpennen commentedon Sep 11, 2020
I haven't started looking into the v3 branch yet, I'll have a look over the weekend and see if I can work out why you aren't seeing the same results from removing the inheritance completely.
chrisvanderpennen commentedon Sep 11, 2020
Ran some first pass timings. #2150 + #2153 clocks in at 157 seconds, faster than both of the above tests!
I'll experiment with pushing down constructor implementations on top of #2150 and see if that shows the same improvements.
ncave commentedon Sep 11, 2020
I've rebased #2153 to latest, no change in performance (see #2096 (comment)).
alfonsogarciacaro commentedon Oct 31, 2020
This was fixed by #2162, thanks again for all your help!