Repository navigation
perf(method dispatch): method_calls ~290× Node — remaining cost is per-field-access shape-guard calls (plan + standby) #5093
Description
Summary
benchmarks/suite/09_method_calls.ts (10M calls to a trivial monomorphic counter.increment() where increment() is this.value = this.value + 1) runs in ~3300 ms vs Node's ~11 ms — still ~290× slower after the two improvements already landed/open. The remaining cost is per-field-access shape-guard calls, and closing it cleanly requires a GC typed-shape-layout change (high risk). This issue records the full analysis so the work can resume from a known state. Putting it in standby for now.
What already landed
- perf(codegen): make typed-feedback site registration opt-in (3.6x on dynamic property access) #5084 — typed-feedback site registration made opt-in. Removed a 14-arg no-op
js_typed_feedback_register_sitecall per property access. Big win for dynamic property access (bench_object_property1127 ms → ~310 ms, 3.6×), but only ~2% onmethod_calls. - perf(transform): inline small this-using methods on exact receivers (1.6x on method_calls) #5092 — inline small
this.fieldmethods on exact receivers. Two inliner gaps fixed (loop bodies seeded with empty exact-receiver facts;is_inlinablerejecting every method that touchesthis).increment()is now inlined into the loop.method_calls: 5229 ms → ~3300 ms (1.6×).
Root cause of the remaining ~290×
Per counter.increment() iteration, after #5092 the body is inlined but each this.value read/write still goes through a typed-feedback class-field shape guard — a non-inlined cross-crate call:
- read →
js_typed_feedback_class_field_get_guard(...)(crates/perry-runtime/src/typed_feedback/guards.rs) - write →
js_typed_feedback_class_field_set_guard(...)
When typed feedback is disabled (the default), each guard reduces to class_field_fast_contract (guards.rs:284): it validates class_id + keys_array + field_count, and for a number-typed (raw-f64) field additionally calls layout_typed_raw_f64_slot_for_user (crates/perry-runtime/src/gc/layout.rs:743), a thread-local PtrHashMap (TYPED_LAYOUTS) lookup. So per iteration: 2 non-inlined guard calls (+ the slot load/store the fast path already emits). ×10M ≈ the 290× gap.
Codegen emits this at crates/perry-codegen/src/expr/property_get.rs:1551 (class-field GET, known slot index) and the property_set.rs counterpart — both wrap a direct getelementptr+load/store (object header is 24 bytes; ObjectHeader #[repr(C)] at crates/perry-runtime/src/object/mod.rs:2293) behind the guard call.
Why the cheap/safe shortcuts do NOT work (measured)
- Removing
register_site(perf(codegen): make typed-feedback site registration opt-in (3.6x on dynamic property access) #5084): ~2% onmethod_calls— register_site was not the cost here. - MRU-caching the raw-f64 layout lookup (1-entry thread-local cache in
layout_typed_raw_f64_slot_for_user): measured ~3300 ms → ~7050 ms (2× SLOWER). ThePtrHashMapis already fast; adding a second thread-local access costs more TLS overhead than it saves. The dominant cost is the guard CALL itself, not what's inside it.
Conclusion: there is no intermediate win. Either the guard call stays, or the guard is inlined.
The plan (A2) — inline the class-field shape guard
Replace the js_typed_feedback_class_field_{get,set}_guard call with inline LLVM at the emission sites (property_get.rs:1551, property_set.rs):
- Inline the cheap part of the contract: load
class_id(i32 @ obj+4) andkeys_array(ptr @ obj+16), compare to the expected compile-time constants; plus a plain (hoistable) load of the process-global descriptors flag. Keep the by-name fallback for the guard-fail edge. - Once both the method (already, perf(transform): inline small this-using methods on exact receivers (1.6x on method_calls) #5092) and the guard are inlined into the caller loop, all guard operands are loop-invariant → LLVM LICM can hoist the shape check out of the 10M-iteration loop, collapsing the body to a tight
load/fadd/store.
Correctness trap — require_raw_f64
Counter.value: number is a raw-f64 candidate, so the guard passes require_raw_f64 = 1 and the contract additionally calls layout_typed_raw_f64_slot_for_user, which is a thread-local PtrHashMap lookup, not an O(1) header field — so it is NOT cheaply inline-able. A correct inline guard for raw-f64 fields requires the per-object raw-f64 slot mask to live somewhere O(1)-loadable (object header or GC header). Getting this wrong = reading a NaN-boxed value as a raw double → silent memory/value corruption. This is the crux and the reason this is high-risk:
- The per-object raw-f64 layout can downgrade (a non-number written to a
number-typed field via ananyalias makes the slot non-raw); the guard's layout check is what catches that. An inline guard must preserve this, or skip the raw-f64 fast path for fields that can downgrade. descriptors_in_use()(accessor descriptors) must also gate the fast path.
Suggested approach
- Move the per-object raw-f64 slot mask into an O(1)-loadable location (GC header / object header), updated wherever
TYPED_LAYOUTSis mutated today (~20 sites ingc/layout.rs), so the inline guard can bit-test it cheaply. - Validate against the full local parity suite +
cargo testworkspace; add a targeted test that stores a non-number into anumber-typed field via ananyalias and reads it back (the downgrade case). - Target:
method_calls≤ ~50 ms (≤ ~3× Node).
Risk: HIGH (GC typed-shape layout change; memory-corruption class). Effort: L. Recommend a maintainer-driven change, not autonomous.
Files
- Benchmark:
benchmarks/suite/09_method_calls.ts - Guard emission:
crates/perry-codegen/src/expr/property_get.rs:1551,crates/perry-codegen/src/expr/property_set.rs - Method dispatch:
crates/perry-codegen/src/lower_call/property_get.rs:1314 - Runtime guard + contract:
crates/perry-runtime/src/typed_feedback/guards.rs:284,318 - raw-f64 layout (the thread-local hashmap):
crates/perry-runtime/src/gc/layout.rs:743 - ObjectHeader layout:
crates/perry-runtime/src/object/mod.rs:2293
This continues the method_calls line of work from #5084 and #5092.
Activity
proggeramlug commentedon Jun 15, 2026
Update — inline guard landed; full collapse needs a loop-versioning pass
PR #5198 inlines the class-field shape guard so a monomorphic
this.fieldread/write branches straight to the raw slot on a hit, skipping the cross-crate
js_typed_feedback_class_field_{get,set}_guardcall. It's correct and a modestwin (~10% on
09_method_calls), but it deliberately does not claim the ~60×target. Here's the root-cause finding for why, so the follow-up is well-specified.
Why LICM does not collapse the loop (deeper than the original analysis)
The original issue blamed the cost of the guard call. Inlining the guard removes
that cost on the fast path, but the loop still does the full per-iteration shape
check and does not collapse to
load/fadd/store. Disassembling the optimized09_method_callshot loop shows why:The receiver
counterlives in a precise-GC shadow-stack root slot whoseaddress is registered for scanning. The cold guard-miss fallback calls remain in
the loop CFG, and LLVM treats them as GC safepoints — after any of them the
object could have moved — so
counteris reloaded from its shadow slot on everyiteration. That makes the object pointer non-loop-invariant, which defeats
hoisting of every shape load, including ones tagged
!invariant.load(
class_id,object_type). Confirmed empirically: adding!invariant.loadto theimmutable header fields changed nothing, because the pointer itself reloads.
It's circular: receiver reload (caused by in-loop calls) → guard operands not
loop-invariant → LLVM won't unswitch/hoist the guard → the cold fallback call stays
in the loop → reload stays. No codegen-local tweak breaks this (attributes,
invariant.load, branch weights all leave the cold call in the loop).What the collapse actually requires
Make the hot loop call-free, so the receiver stays in a register and the
(now loop-invariant) shape check hoists / unswitches. Two viable routes:
perry-transform) — recognize a loop whose bodydoes monomorphic field access on a loop-invariant receiver; emit the shape
guard once in the preheader, then a fast loop with raw slot access and no
guard/fallback call, plus the existing guarded loop as the slow version.
Safety conditions that MUST be proven for the fast loop (any violation → fall to
the slow loop): the receiver cannot move (no GC safepoint in the fast body, or
pin it), the field cannot downgrade (the body never stores a non-number / never
aliases the object to something that could), and the shape cannot change
(no dynamic prop add, no freeze). Getting these wrong = the memory-corruption
class this issue flags.
across non-allocating calls (mark guard/fallbacks non-allocating and teach the
root machinery it needn't reload). Broader blast radius.
Route 1 is the more contained option but is a substantial new pass with subtle
safety analysis — a maintainer-driven change, as the issue notes. The landed PR is
the additive foundation (per-object intact bit + inline fast path); the collapse is
the remaining work this issue should track.
Landed pieces (foundation for the follow-up)
GC_OBJ_TYPED_LAYOUT_INTACTbit (all-or-nothing raw-f64 downgrade ⇒one bit replaces the thread-local
TYPED_LAYOUTSprobe).PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED(auto-off under descriptors/typed-feedback;PERRY_DISABLE_CLASS_FIELD_INLINE=1escape hatch) +PERRY_VERIFY_TYPED_INTACT=1.property_get/property_set; downgrade regression test.perf(codegen): inline class-field shape guard fast path (#5093)
perf(codegen): inline class-field shape guard fast path (#5093)
perf(codegen): inline class-field shape guard fast path (#5093)
perf(codegen): inline class-field shape guard fast path (#5093) (#5198)
perf(runtime,codegen): stop unrelated descriptor installs disabling t…
perf(codegen,runtime): #5093 — class-field loop versioning: monomorph…
fix(codegen): versioned-loop preheader vets OBJ_FLAG_HAS_DESCRIPTORS …
perf(codegen,runtime): #5093 — class-field loop versioning: monomorph…
fix(codegen): versioned-loop preheader vets OBJ_FLAG_HAS_DESCRIPTORS …
perf(codegen,runtime): #5093 — class-field loop versioning: monomorph…
fix(codegen): sloppy-mode class-field number stores keep the inline r…
perf(codegen): revive the class-field versioned loop for canonical-i3…
perf(repsel): element-shape versioned loop clone (#7480 / #5093)
proggeramlug commentedon Aug 8, 2026
#7612 lands the narrow sound slice of the versioned-loop clone (route B in
#7480's sequencing: invariant bit → B → A). Recording what it deliberately left
out, so the next slice starts from a known state rather than a re-derivation.
Shipped:
for (let j = k0; j < B; j++) acc = <pure numeric over arr[j].field>where
arrhas a declared class element type, behind a preheader guard onjs_array_ensure_element_shapeplus an explicitGC_TYPE_ARRAYbrand. Measured41 ms → 13 ms (3.15×) on the #7480 access shape, now at parity with node, at
+0 bytes on programs with no qualifying loop.
Revocation is handled by restrict-the-body: the matcher admits no stores or
calls, and the lowering then verifies the emitted fast clone is call-free and
branches unconditionally to the slow clone if it is not. Every revocation funnel
is a runtime call, so call-free ⇒ nothing can revoke and nothing can move the
array.
Extension 1 — fold the residual per-element facts into the invariant
The clone still pays a per-element check, because the array-level invariant
covers
POINTER_TAG/GcHeaderreadability /GC_TYPE_OBJECT/OBJECT_TYPE_REGULAR/class_idbut not the per-object facts a raw-f64slot load needs:
keys_arrayidentity —delete elem.fcompacts the packed inline slotswhile preserving
class_id, so a class-id match alone does not prove thelayout;
field_count;OBJ_FLAG_HAS_DESCRIPTORS(instance-level installs deliberately do not flipthe process-global latch, perf: class-field inline fast path (#5093) is disabled process-wide in normal runs #5654);
GC_OBJ_TYPED_LAYOUT_INTACT— dropping this one is the miscompile: adowngraded element holds a NaN-boxed value where the clone reads a raw
double.Today they cost one 4-byte load of the three contiguous
GcHeaderbytes, plus afield_countload, akeys_arrayload, and one side-exit branch per access.Folding them into
element_class_of_bitswould make the reads bare — but eachone needs an invalidation surface that does not exist:
delete elem.f,Object.defineProperty(elem, …), and typed-layout downgrade do not funnelthrough
note_element_store, and a record that goes stale on any of them is amiscompile rather than a slow path. #7496 kept the maintenance matrix
enumerable precisely by not opening that surface, so this should land the way
#7496 did: invariant first, revocation matrix second (#7608's shape), consumer
third.
Extension 2 — object-literal element types (#7480's own kernel)
keep: {v,w}[]is untouched: 86 ms, 8.6× node, while the named-class kernel isnow at 13 ms. The class genuinely exists (an object literal lowers to
New { __AnonShape_<hash> }with a synthesized constructor) and matching thedeclared object type's
property_orderagainst this module's__AnonShape_*classes finds it — that was implemented and then removed, because the class
name alone buys nothing:
receiver_class_namereturningNonefor anObject-typed element is also what makeslower_raw_f64_class_field_get_for_number_contextdecline, so the read stilllowers through the generic diamond, the clone fails its call-free check, and the
only product is a block of dead fast-clone IR.
The real prerequisite is teaching
static_type_of/receiver_class_nametotype an
Object-typed property read. That is squarely the #6377 hazard ("moretype visibility un-gates latent fast paths its own microbench never exercises")
and needs its own PR with a full gap-suite A/B against a same-session
main.Extension 3 — widen the admitted loop shape
Currently: one array, index is exactly the counter, single store-free statement,
element class has no base class. Natural widenings, roughly in order of
value/risk:
arr[j+1]) — needs the preheader'slength >= boundcheck widened to the actual window, the way simple EMA (Exponential Moving Average) loop over a 100k-element array runs **7x slower** in the compiled Perry binary (2300ms) compared to tsx (328ms) or node (343ms). #6011's range loop does it.
partial effect), same argument
packed_f64_range_loop_dense_body_collectalready makes.
through the inheritance chain; a native base (
extends Array) must staydeclined, it is the fix(runtime): a Map/Set subclass in a base-typed binding was read as a raw header (#7570) #7573/fix(runtime): an Array subclass in a base-typed binding was read as a raw header (#7574) #7603 hazard.
Extension 4 — make the clone survive moving loop polls
Under
PERRY_GC_MOVING_LOOP_POLLS=1(compile-time, off by default since #7161)the back-edge safepoint is itself a call, so the call-free scan fails and the
clone stands down — verified in the corpus IR, where the deref block emits an
unconditional
brto the slow preheader. That is the correct behaviour today(a collection at the poll could move the array out from under the cached
elements base), but it means the optimization silently disappears if that
default ever flips. Making it poll-compatible means re-deriving the elements
base after each safepoint and re-validating the proof identity
(
js_array_element_shape_check(arr, class_id, epoch)already exists for exactlythis), which is the per-back-edge re-check option #7612 considered and declined
for the first slice.
Route A is still the durable fix
None of the above replaces #7480's Route A — element reads yielding a proven
Ptr<Shape>so field reads emit baregep + loadcomposing with 3b'sproven-local machinery, and helping non-loop access too. Extension 1 is its
prerequisite as much as it is this clone's.
perf(repsel): element-shape versioned loop clone (#7480 / #5093)
perf(repsel): element-shape versioned loop clone (#7480 / #5093) (#7612)
perf(typed-feedback): stop emitting recording calls into default buil…
perf(typed-feedback): stop emitting recording calls into default buil…
perf(codegen,runtime): inline precheck for boxed class-field stores, …
perf(codegen,runtime): inline precheck for boxed class-field stores, …
fix(gc): parse PERRY_GC_DIAG by value — `=0` no longer ENABLES diagno…
fix(codegen): PropertySet rejections honour sloppy mode (#9459); a wr…
codegen: collapse the class-field GET tower to one exit
perf(codegen): drop the dead raw-f64 canonicalize call on `: number` …
perf(codegen): drop the dead raw-f64 canonicalize call on `: number` …