Skip to content

perf(method dispatch): method_calls ~290× Node — remaining cost is per-field-access shape-guard calls (plan + standby) #5093

Closed

Description

@TheHypnoo

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

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% on method_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). The PtrHashMap is 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):

  1. Inline the cheap part of the contract: load class_id (i32 @ obj+4) and keys_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.
  2. 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 an any alias 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_LAYOUTS is mutated today (~20 sites in gc/layout.rs), so the inline guard can bit-test it cheaply.
  • Validate against the full local parity suite + cargo test workspace; add a targeted test that stores a non-number into a number-typed field via an any alias 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

  1. proggeramlug commented on Jun 15, 2026

    @proggeramlug
    Contributor

    Update — inline guard landed; full collapse needs a loop-versioning pass

    PR #5198 inlines the class-field shape guard so a monomorphic this.field
    read/write branches straight to the raw slot on a hit, skipping the cross-crate
    js_typed_feedback_class_field_{get,set}_guard call
    . It's correct and a modest
    win (~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 optimized
    09_method_calls hot loop shows why:

    0x1120  ldr  d9, [sp, #0x18]      ; counter RELOADED from its shadow-root slot every iteration
    0x1128  and  x19, x22, #0xff...   ; re-derive object pointer
    ...     (full shape check: gc-type, class_id, keys, field_count, intact bit) ...
    0x11a0  bl   <get guard>          ; cold miss path still present
    ...
    0x10e8  bl   <set guard>          ; cold miss path still present
    

    The receiver counter lives in a precise-GC shadow-stack root slot whose
    address 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 counter is reloaded from its shadow slot on every
    iteration. 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.load to the
    immutable 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:

    1. Loop-versioning pass (perry-transform) — recognize a loop whose body
      does 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.
    2. GC-root model change — let a loop-invariant object local stay in a register
      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)

    • Per-object GC_OBJ_TYPED_LAYOUT_INTACT bit (all-or-nothing raw-f64 downgrade ⇒
      one bit replaces the thread-local TYPED_LAYOUTS probe).
    • PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED (auto-off under descriptors/typed-feedback;
      PERRY_DISABLE_CLASS_FIELD_INLINE=1 escape hatch) + PERRY_VERIFY_TYPED_INTACT=1.
    • Inline pre-check in property_get/property_set; downgrade regression test.
  2. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    Contributor

    #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 arr has a declared class element type, behind a preheader guard on
    js_array_ensure_element_shape plus an explicit GC_TYPE_ARRAY brand. Measured
    41 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 / GcHeader readability / GC_TYPE_OBJECT /
    OBJECT_TYPE_REGULAR / class_id but not the per-object facts a raw-f64
    slot load needs:

    • keys_array identity — delete elem.f compacts the packed inline slots
      while preserving class_id, so a class-id match alone does not prove the
      layout;
    • field_count;
    • OBJ_FLAG_HAS_DESCRIPTORS (instance-level installs deliberately do not flip
      the 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: a
      downgraded element holds a NaN-boxed value where the clone reads a raw
      double.

    Today they cost one 4-byte load of the three contiguous GcHeader bytes, plus a
    field_count load, a keys_array load, and one side-exit branch per access.
    Folding them into element_class_of_bits would make the reads bare — but each
    one needs an invalidation surface that does not exist: delete elem.f,
    Object.defineProperty(elem, …), and typed-layout downgrade do not funnel
    through note_element_store, and a record that goes stale on any of them is a
    miscompile 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 is
    now at 13 ms. The class genuinely exists (an object literal lowers to
    New { __AnonShape_<hash> } with a synthesized constructor) and matching the
    declared object type's property_order against this module's __AnonShape_*
    classes finds it — that was implemented and then removed, because the class
    name alone buys nothing: receiver_class_name returning None for an
    Object-typed element is also what makes
    lower_raw_f64_class_field_get_for_number_context decline, so the read still
    lowers 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_name to
    type an Object-typed property read. That is squarely the #6377 hazard ("more
    type 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:

    1. Offset indices (arr[j+1]) — needs the preheader's length >= bound
      check 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.
    2. Multiple statements — safe for reads only (no side exit can leave a
      partial effect), same argument packed_f64_range_loop_dense_body_collect
      already makes.
    3. More than one tracked array.
    4. Element classes with a base — needs the packed slot index resolved
      through the inheritance chain; a native base (extends Array) must stay
      declined, 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 br to 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 exactly
    this), 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 bare gep + load composing with 3b's
    proven-local machinery, and helping non-loop access too. Extension 1 is its
    prerequisite as much as it is this clone's.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions