Repository navigation
perf: function-constructor objects (new F(), x instanceof F) are 78–566× slower than Node (F.prototype and builtin identity re-derived from side tables per operation) #10507
Description
Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. The pre-ES6 object model (function F(){},
F.prototype.m = …, new F(), x instanceof F, new this.constructor(v)) never gets a class id or a cached
prototype: every new F() runs js_new_function_construct (10,464 instructions for an empty constructor vs 625
for new KE() of an empty class) and every x instanceof F runs the dynamic ladder (3,770 vs 185).
Reproduction
fnctor.ts:
// Pre-ES6 object model (decimal.js, dayjs, lodash): `function F(){}` constructors, `x instanceof F`, `new x.constructor(v)`.
const variant = process.argv[2]; const N = Number(process.argv[3]);
function E(this: any) { }
class KE { }
function clone() { // decimal.js: the constructor is a closure made by clone()
function Dec(this: any, v: number): any { const x = this; if (!(x instanceof Dec)) return new (Dec as any)(v); x.constructor = Dec; x.d = v; }
Dec.prototype.plus = function (this: any, y: number) { const Ctor = this.constructor; return new Ctor((this.d + y) % 1000003); };
return Dec;
}
const D: any = clone();
class C { d: number; constructor(v: number) { this.d = v; } plus(y: number) { return new C((this.d + y) % 1000003); } }
const fe: any = new (E as any)(); const ke: any = new KE(); const keep: any[] = new Array(1024);
function run(n: number): number {
let s = 0;
if (variant === "new_fn_empty") for (let i = 0; i < n; i++) { keep[i & 1023] = new (E as any)(); s += 1; }
else if (variant === "new_class_empty") for (let i = 0; i < n; i++) { keep[i & 1023] = new KE(); s += 1; }
else if (variant === "instanceof_fn") for (let i = 0; i < n; i++) s += fe instanceof E ? 1 : 0;
else if (variant === "instanceof_class") for (let i = 0; i < n; i++) s += ke instanceof KE ? 1 : 0;
else if (variant === "decimal_fn") { let x = new D(1); for (let i = 0; i < n; i++) x = x.plus(i); s = x.d; }
else if (variant === "decimal_class") { let x = new C(1); for (let i = 0; i < n; i++) x = x.plus(i); s = x.d; }
return s;
}
run(N / 5 | 0); const t0 = performance.now(); const cs = run(N);
console.log(`variant=${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(2)}`);PERRY_NO_AUTO_OPTIMIZE=1 perry compile fnctor.ts -o fnctor
for v in new_fn_empty new_class_empty instanceof_fn instanceof_class decimal_fn decimal_class; do node fnctor.ts $v 1000000; ./fnctor $v 1000000; doneMeasurements
Median of 3, N = 1,000,000, shared host (loaded; instruction counts are the load-independent figure).
| variant | Node loop ms | Perry loop ms | ratio | Perry instructions / iter | Node wall | Perry wall |
|---|---|---|---|---|---|---|
new E() — empty function E(){} |
12.6 | 1,292 | 102× | 10,464 | 177 ms | 1,607 ms |
new KE() — empty class KE {} (control) |
12.9 | 54.8 | 4.3× | 625 | 162 ms | 102 ms |
fe instanceof E (hit) |
4.3 | 340.8 | 78× | 3,770 | 137 ms | 443 ms |
ke instanceof KE (control) |
4.0 | 12.6 | 3.2× | 185 | 155 ms | 50 ms |
decimal.js shape: x.plus(i) → instanceof + new this.constructor(v) + 2 property adds |
7.2 | 4,094 | 566× | 30,585 | 129 ms | 4,957 ms |
| same as an ES class (control) | 7.0 | 173.0 | 25× | 2,067 | 122 ms | 238 ms |
Checksums identical. The empty-constructor and instanceof rows contain no property adds, so they isolate this
mechanism from #10496 (adds never cached), which accounts for ~35 % of the decimal_fn row.
perf record (inclusive):
new_fn_empty:js_new_function_construct91 % →js_get_global_this_builtin_value36 %,
closure_get_dynamic_prop17 %,get_accessor_descriptor12 %,identify_global_builtin_constructor12 %,
extends_target_must_throw7 %,object_set_static_prototype_impl7 %,synthetic_class_id_for_function5 %,
SipHash 4 %.instanceof_fn:js_instanceof_dynamic68 % →builtin_ctor_class_id_from_value28 %,closure_get_dynamic_prop
27 %,js_instanceof_dynamic_tail20 %,js_object_has_own_symbol(Symbol.hasInstance) 18 %,
synthetic_class_id_for_function17 %,identify_global_builtin_constructor15 %,well_known_symbol14 %.decimal_fn:js_new_function_construct79 %,js_put_value_set_ic_miss35 % (the adds),js_instanceof_dynamic9 %.
Impact
From the audit profiles (v0.5.1587):
- decimal.js 10.6 (26.7× Node overall, objects-group report):
js_new_function_construct8.8 %,instanceof
dynamic tail 15.6 %, plus the property adds and prototype-method calls of the same object model — ≈ 46 % of Perry
time is this model. Every operation runsnew Ctor(…)withCtor = x.constructorand
isDecimalInstance(y)(y instanceof Decimal);Decimalis a closure created insideclone(). - dayjs (85× Node, strings-group report): the
Dayjsconstructor chain — the audit's function-constructor
microbenchmark was 141× vs 52× for the same code as a class. - typescript 5.8 (
transpileModule, large-group report):js_new_function_construct→
replay_class_object_constructor15.2 % inclusive (NodeObject/TokenObject/SymbolObjectconstructors — class
expressions constructed through a variable, which enter the same function; overlaps perf:newon a class expression is 159× slower than Node vs 7× for the identical declaration (constructorthis.x =fields inferred only for declarations) #10499). - lodash:
Stack/MapCache/ListCache/Hashare function constructors (≈ 1 %).
Mechanism
crates/perry-runtime/src/object/class_registry/construct.rs:242js_new_function_construct(verified), per call:
bound-function / boxed-primitive / RegExp / Proxy / non-constructable-builtin checks;is_function_prototype_object_value
(:321, resolvesglobalThis.Function.prototypeby name — see perf: missing-property reads on functions are ~2,600× andObject.hasOwn/getPrototypeOf40–90× slower than Node (Function.prototype re-resolved by name per call) #10497);bound_native_callable_module_and_method
(:324);identify_global_builtin_constructor(:470); arrow andextends_target_must_throwchecks (:1046);
synthetic_class_id_for_function(:1049, a hash lookup keyed by the function); allocation; then
closure_get_dynamic_prop(fp, "prototype")(:1083) and a static-prototype side-table insert for the instance.crates/perry-runtime/src/closure/dynamic_props.rs:900closure_get_dynamic_prop(verified): first
get_accessor_descriptor(ptr, prop)(:919; when the per-object summary admits a possible entry it looks up
(ptr, key.to_string())— aStringallocation + SipHash,crates/perry-runtime/src/object/descriptor_state.rs), then locks the process-global
Mutex<PtrHashMap<usize, ClosureProps>>(:94,:936) and hashes the&strname.F.prototypetherefore costs up to a
string allocation, two hash lookups and a mutex on everynewand everyinstanceof(inferred from source + profile).crates/perry-runtime/src/object/instanceof.rs:166js_instanceof_dynamic(verified): Proxy unwrap, Temporal
kind, ownSymbol.hasInstanceprobe (:225),builtin_ctor_class_id_from_value(:127, reads the closure's
namedynamic prop and string-matches it,:292),identify_global_builtin_constructor(:481),
intl_instanceof(:541), and only thenjs_instanceof_dynamic_tail(:626), which readsF.prototypefrom the
closure side table again and walks the chain.- None of these answers is cached per closure, although for an ordinary user function none of them can change
exceptF.prototype(a write that can bump an epoch).
What fast looks like
- Give a user
functionused withnewa per-closure constructor record (synthetic class id, "is ordinary user
function" bit, cachedprototypeobject pointer + epoch) established on firstnew/instanceof; later calls do a
header check + epoch compare and allocate with the cached prototype. Skip the builtin-identity ladder for closures
whosefunc_ptris not one of the builtin thunks (a pointer compare, done once). - Targets on this benchmark:
new_fn_empty≤ 2×new_class_empty(≤ 1,300 instructions),instanceof_fn≤ 2×
instanceof_class,decimal_fn≤ 3×decimal_classonce perf: adding a property witho.k = vis ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496 and perf: method calls on untyped receivers (prototype methods,fn.call,pushonany) are 44–520× slower than Node (no call-site cache; name re-resolved per call) #10505 are fixed.
Notes
- The ES-class rows show the gap is specific to the function-constructor path (class construction was addressed in
closed perf:new Klass(v,w)is 63% slower than the equivalent object literal (28.5x vs 17.4x Node) #7512). - Open correctness issues on the same path:
x instanceof Fis always false whenFis an imported non-class constructor (ES5 function, factory-made function, CJSmodule.exports = F); namespace access and local aliases work #10477 (x instanceof Ffalse for imported non-class constructors),
Object.create(proto).constructoris a bogus class reference instead ofproto.constructor;ctor instanceof ctorthen segfaults (lodashisEqual(cloneDeep(x), x)) #10478 (Object.create(proto).constructorbogus class reference),argumentsin a class constructor is empty when the class is constructed through a value, and padded to the declared parameter count on a staticnew#10484 (argumentsin a class constructor
constructed through a value). A fix that caches the constructor record should keep those in view. - Sibling drafts: perf: adding a property with
o.k = vis ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496 (property adds in the constructor body), perf: method calls on untyped receivers (prototype methods,fn.call,pushonany) are 44–520× slower than Node (no call-site cache; name re-resolved per call) #10505 (prototype-method calls on the
instances), perf: missing-property reads on functions are ~2,600× andObject.hasOwn/getPrototypeOf40–90× slower than Node (Function.prototype re-resolved by name per call) #10497 (Function.prototyperesolved by name).
Activity
proggeramlug commentedon Oct 1, 2026
Package matrix re-run (#11723, at 9e29f59): node-forge/hmac is 37× Node, and about 55% is method calls on objects built by a constructor function (see also #10502). A SipHash (
hash_one::<&char>underclass_prototype_object) runs on every such call and is 3.8% of hmac's own time. Profiles:benchmarks/packages/profile/rerun-2026-09-30/.perf: construct plain function objects from the prototype's birth rec…
perf: construct plain function objects from the prototype's birth rec…
perf: serve function-valued stores from the emitted store cache
proggeramlug commentedon Oct 3, 2026
Reopening: #11787 + #11798 are partial progress, not the target (new E() 5,048 → ~1,900; decimal 15,848 → 2,387 instructions/op). Leaving open for the rest.
perf(runtime): make an ordinary object's prototype a fact of its shape
proggeramlug commentedon Oct 3, 2026
More progress in #11815: an ordinary object's prototype is now a fact of its shape, and
new F()allocates no ObjectMeta (also fixesObject.create(null) instanceof Object). Leaving open.perf(runtime): drop the prototype-divergence flag; the shape pins the…
perf(runtime): read prototype-link facts from the shape; delete the l…
perf(runtime): the ShapeId names its prototype identity kind; read th…
proggeramlug commentedon Oct 3, 2026
More progress in #11834 (prototype phase 2): prototype-link flags and latch deleted; the ShapeId names its prototype identity kind. instanceof 3× faster; tsc −0.7%, Zod −1.9%. Leaving open.
perf(runtime): by-name class method calls are answered by prototype s…
perf(runtime): by-name class method calls are answered by prototype s…