Skip to content

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

@proggeramlug

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; done

Measurements

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_construct 91 % → js_get_global_this_builtin_value 36 %,
    closure_get_dynamic_prop 17 %, get_accessor_descriptor 12 %, identify_global_builtin_constructor 12 %,
    extends_target_must_throw 7 %, object_set_static_prototype_impl 7 %, synthetic_class_id_for_function 5 %,
    SipHash 4 %.
  • instanceof_fn: js_instanceof_dynamic 68 % → builtin_ctor_class_id_from_value 28 %, closure_get_dynamic_prop
    27 %, js_instanceof_dynamic_tail 20 %, js_object_has_own_symbol (Symbol.hasInstance) 18 %,
    synthetic_class_id_for_function 17 %, identify_global_builtin_constructor 15 %, well_known_symbol 14 %.
  • decimal_fn: js_new_function_construct 79 %, js_put_value_set_ic_miss 35 % (the adds), js_instanceof_dynamic 9 %.

Impact

From the audit profiles (v0.5.1587):

  • decimal.js 10.6 (26.7× Node overall, objects-group report): js_new_function_construct 8.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 runs new Ctor(…) with Ctor = x.constructor and
    isDecimalInstance(y) (y instanceof Decimal); Decimal is a closure created inside clone().
  • dayjs (85× Node, strings-group report): the Dayjs constructor 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_constructor 15.2 % inclusive (NodeObject/TokenObject/SymbolObject constructors — class
    expressions constructed through a variable, which enter the same function; overlaps perf: new on a class expression is 159× slower than Node vs 7× for the identical declaration (constructor this.x = fields inferred only for declarations) #10499).
  • lodash: Stack/MapCache/ListCache/Hash are function constructors (≈ 1 %).

Mechanism

  • crates/perry-runtime/src/object/class_registry/construct.rs:242 js_new_function_construct (verified), per call:
    bound-function / boxed-primitive / RegExp / Proxy / non-constructable-builtin checks; is_function_prototype_object_value
    (:321, resolves globalThis.Function.prototype by name — see perf: missing-property reads on functions are ~2,600× and Object.hasOwn/getPrototypeOf 40–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 and extends_target_must_throw checks (: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:900 closure_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()) — a String allocation + SipHash, crates/perry-runtime/src/object/descriptor_state.rs), then locks the process-global
    Mutex<PtrHashMap<usize, ClosureProps>> (:94, :936) and hashes the &str name. F.prototype therefore costs up to a
    string allocation, two hash lookups and a mutex on every new and every instanceof (inferred from source + profile).
  • crates/perry-runtime/src/object/instanceof.rs:166 js_instanceof_dynamic (verified): Proxy unwrap, Temporal
    kind, own Symbol.hasInstance probe (:225), builtin_ctor_class_id_from_value (:127, reads the closure's
    name dynamic prop and string-matches it, :292), identify_global_builtin_constructor (:481),
    intl_instanceof (:541), and only then js_instanceof_dynamic_tail (:626), which reads F.prototype from 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
    except F.prototype (a write that can bump an epoch).

What fast looks like

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions