Repository navigation
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
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; doneMeasurements
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 recordofsetproto_null(verified): 99 % inclusive underjs_native_call_method_by_id→
try_class_vtable_fast_dispatch.class_vtable_fast_guardis 45.7 % inclusive, andjs_array_get_f64is 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
- Audited packages: not observed. gdb read
PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATEDat process exit on
7661bc0 in two builds that compile module-initsetPrototypeOf/util.inheritscallers from source, and it was
0 in both:- cheerio 1.2.0 with undici 7.29.1 compiled (
lib/core/constants.js:108Object.setPrototypeOf(headerNameLowerCasedRecord, null)); - jsonwebtoken 9.0.3 (jws
lib/data-stream.js:39util.inherits(DataStream, Stream)).
Either those module inits take one of the earlier returns injs_object_set_prototype_of(class-ref, native-handle
or native-base targets), or they do not run in these programs (not investigated). So this does not explain
cheerio's parse5 dispatch cost or the audit's "compiling undici makes cheerio 1.78× slower" result
(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, perf:this.m()on a class whose constructor adds fields inside anifis ~490× slower than Node (exact birth-ShapeId guard misses every call) #10503). Those causes remain unisolated.
- cheerio 1.2.0 with undici 7.29.1 compiled (
- User code and other packages: a latent whole-program cliff. The shapes that do arm it in a plain program are
ordinary pre-ES2015 idioms:util.inherits(Sub, Base)with two plain function constructors;Object.setPrototypeOf(Sub.prototype, Base.prototype);Object.setPrototypeOf(dict, null)on an object literal;obj.__proto__ = ….
After any one of them, every classthis.m()call in the program, including classes in unrelated modules, runs
through the runtime tower for the rest of the process. The cost grows with each receiver's own-key count, because
the tower scans those keys (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). Nothing reports it; the program just gets several times slower.
Mechanism
All verified at 7661bc0:
crates/perry-runtime/src/object/object_ops/define_properties.rs:489:js_object_set_prototype_ofhandles 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 whysetproto_fnstays fast.crates/perry-runtime/src/util_inherits.rs:178:util.inheritscallsjs_object_set_prototype_of(ctor.prototype, super.prototype).crates/perry-runtime/src/object/class_registry/prototype_methods.rs:199-207: the function stores1into the
#[no_mangle]process-globalPERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED(declared at:113). Nothing ever
resets it. The function also callsretire_prototype_dependent_caches()(:179), which invalidates every array
element-shape proof and bumpsVTABLE_GEN.crates/perry-codegen/src/lower_call/method_override.rs:254and: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_semanticsinprototype_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:
setproto_null,setproto_chain,util_inheritsandproto_assignwithin 1.05× of thenonecontrol:
≈1.49 G instructions, ≈6.1 M per run.- Prototype method replacement not observed through a typed-parameter receiver #9131's prototype-replacement tests still pass.
Notes
- This is a separate trigger from perf:
this.m()on a class whose constructor adds fields inside anifis ~490× slower than Node (exact birth-ShapeId guard misses every call) #10503, where conditional constructor fields change the ShapeId. The fallback
cost itself, the O(own keys) scans, is 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. Fixing PERF-8 lowers this penalty, but the guard stays off. - The same latch also retires every array element-shape proof, via
invalidate_all_element_shapes. This benchmark
does not measure that effect onarr[i]loops. - Ratios and instruction counts measured on 7661bc0. The latch has been sticky since fix(runtime): observe prototype replacement in method calls #9169 (merged 2026-08-31).
Activity
perf: learned receiver words for class-method sites; setPrototypeOf o…
proggeramlug commentedon Oct 3, 2026
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.