Skip to content

perf: one Object.setPrototypeOf/util.inherits on a plain object makes every class method call in the process 5× slower, 155–189× Node (sticky global latch retires all direct-call guards) #10504

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. A single Object.setPrototypeOf(obj, …) on any ordinary
object, including util.inherits(...) and o.__proto__ = …, sets a process-global "all direct-method guards
invalidated" byte that is never cleared. From then on every compiler-emitted direct method call in the program, on
every class, falls back to the runtime dispatch tower. In this microbenchmark that is 5× more instructions for the
same code (155–189× Node, against 23–31× without the mutation). The mutated object has no relation to the class
being called.

Reproduction

bench.mjs (23 lines; the class is a parse5-Tokenizer-shaped this._method() loop):

// One prototype mutation ANYWHERE in the program, then an unrelated class's `this.m()` hot loop.
import util from "node:util";
const variant = process.argv[2]; const N = Number(process.argv[3]);
function Base() {} function Sub() {}
if (variant === "setproto_null") { const rec = { a: 1 }; Object.setPrototypeOf(rec, null); } // null-prototype dictionary
else if (variant === "setproto_chain") Object.setPrototypeOf(Sub.prototype, Base.prototype);
else if (variant === "util_inherits") util.inherits(Sub, Base);                                 // pre-ES2015 inheritance
else if (variant === "proto_assign") { const o = { a: 1 }; o.__proto__ = { b: 2 }; }
else if (variant === "create_null") { const o = Object.create(null); o.a = 1; }                 // control
else if (variant === "setproto_fn") Object.setPrototypeOf(Sub, Base);                           // control (function target)
class Tok {
  constructor(text) { this.text = text; this.pos = -1; this.state = 0; this.active = true; this.count = 0; }
  _consume() { this.pos++; return this.pos < this.text.length ? this.text.charCodeAt(this.pos) : -1; }
  _ensureHibernation() { return false; }
  _stateData(cp) { if (cp === 60) this.state = 1; else this.count++; }
  _stateTag(cp) { if (cp === 62) this.state = 0; else this.count += 2; }
  _callState(cp) { switch (this.state) { case 0: this._stateData(cp); break; case 1: this._stateTag(cp); break; } }
  run() { while (this.active) { const cp = this._consume(); if (cp < 0) { this.active = false; break; } if (!this._ensureHibernation()) this._callState(cp); } return this.count; }
}
const text = "<tr class=r1><td>123</td><td><a href=/u/1>user1</a></td></tr>".repeat(50);
function run(n) { let s = 0; for (let i = 0; i < n; i++) s = (s + new Tok(text).run()) % 1000003; 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 bench.mjs -o bench
for v in none create_null setproto_fn setproto_null setproto_chain util_inherits proto_assign; do
  node bench.mjs $v 200; ./bench $v 200; done

Measurements

Median of 3 on a shared, loaded host; instruction counts are the load-independent figure. N = 200 tokenizer runs
over a 3,050-character string, about 12,200 method calls per run. Perry instructions per run are (whole-process
instructions:u − 38 M startup) / 240, which counts the warm-up.

variant Node loop ms Perry loop ms ratio Perry instructions (per run) Node wall Perry wall
none (control) 3.9 122.2 31× 1.49 G (6.05 M) 105 ms 195 ms
create_null (control: Object.create(null)) 4.6 118.9 26× 1.49 G (6.05 M) 88 ms 189 ms
setproto_fn (control: target is a function) 5.0 115.9 23× 1.49 G (6.05 M) 80 ms 180 ms
setproto_null: Object.setPrototypeOf({a:1}, null) 4.5 700.9 155× 7.48 G (31.0 M) 100 ms 873 ms
setproto_chain: setPrototypeOf(Sub.prototype, Base.prototype) 4.3 713.3 166× 7.48 G (31.0 M) 103 ms 898 ms
util_inherits: util.inherits(Sub, Base) 4.1 721.1 174× 7.48 G (31.0 M) 77 ms 917 ms
proto_assign: o.__proto__ = {…} 3.8 722.4 189× 7.48 G (31.0 M) 85 ms 905 ms
  • Checksums are identical in every variant.
  • The class code and the loop are the same in every variant. The one-time mutation adds about 25 M instructions per
    run, roughly 2,050 per method call.
  • The extra cost grows with the receiver's own-key count. With 15 constructor fields instead of 5, the same mutation
    added about 46 M instructions per run (6.0 M → 52.2 M).
  • perf record of setproto_null (verified): 99 % inclusive under js_native_call_method_by_id →
    try_class_vtable_fast_dispatch. class_vtable_fast_guard is 45.7 % inclusive, and js_array_get_f64 is 23 %
    self (the own-key scan in perf: obj.method() through the runtime dispatcher is ~3,000× slower than Node (two O(own-keys) string-compare scans per call before any cache) #10502). Without the mutation, the profile is the compiled methods only.

Impact

Mechanism

All verified at 7661bc0:

  • crates/perry-runtime/src/object/object_ops/define_properties.rs:489: js_object_set_prototype_of handles an
    ordinary, non-closure heap object as "keyless prototype surgery". It calls
    crate::object::invalidate_class_prototype_fast_guards() before recording the prototype, whatever the object is.
    A function target returns earlier (:469-471), which is why setproto_fn stays fast.
  • crates/perry-runtime/src/util_inherits.rs:178: util.inherits calls js_object_set_prototype_of(ctor.prototype, super.prototype).
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs:199-207: the function stores 1 into the
    #[no_mangle] process-global PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED (declared at :113). Nothing ever
    resets it. The function also calls retire_prototype_dependent_caches() (:179), which invalidates every array
    element-shape proof and bumps VTABLE_GEN.
  • crates/perry-codegen/src/lower_call/method_override.rs:254 and :354 (inline direct-method guards) and
    crates/perry-codegen/src/stmt/versioned_indexed_loop.rs:397 (loop-clone guard) load that byte first. A non-zero
    value branches to the fallback, js_native_call_method_by_id, at every direct call site in the program.
  • fix(runtime): observe prototype replacement in method calls #9169 (merged 2026-08-31, fixing Prototype method replacement not observed through a typed-parameter receiver #9131) introduced this invalidation so that a prototype replacement becomes
    observable. perf(class): stop disarming every dispatch guard when a class prototype is materialized #7800 (merged) fixed an earlier case of the same latch being tripped by class-prototype
    materialization.

What fast looks like

Retire direct-method guards only when the mutation can change what a guarded (class_id, ShapeId) call site
resolves to:

  • The target is a class prototype object, or an object on a class prototype chain.
  • The target is itself a class instance. Its own shape already transitions when its prototype diverges, via
    transition_object_shape_semantics in prototype_chain.rs (inferred sufficient).

Re-prototyping a dictionary literal ({…} → null) or a function-constructor's .prototype object cannot affect a
class-id-keyed guard. Other ways to get there: a per-object "is used as a prototype" bit consulted before
invalidating, or scoping the latch by class id.

Targets:

Notes

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress in #11793: setproto_chain / proto_assign 27.57M → 7.47M instructions/run. The cliff is gone (≈ control); base dispatch is still well above node. Leaving open.

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