Skip to content

perf: 09_method_calls is not a dispatch problem — dispatch costs 0 ms and the whole 7.9x is un-hoistable per-access field guards #7287

Closed

Description

@proggeramlug

Summary

09_method_calls is 7.9× behind node (78 ms vs 10 ms for 10M iterations of counter.increment()). It is filed and discussed as a dispatch benchmark ("Measures virtual dispatch performance"). It is not one.

Measured decomposition (in-repo compile, auto-optimize on, release, min of 5):

variant perry ms node ms
09_method_calls — counter.increment() in a loop 78 11
identical loop, no method: counter.value = counter.value + 1 78 10
identical loop, free function bump(c) taking the receiver 78 10
identical loop, plain local: v = v + 1 10 7
  • Method dispatch costs 0 ms. increment() is fully inlined into main; there is no call in the loop.
  • Routing the receiver through a function parameter costs 0 ms.
  • The loop itself is at parity with node (10 ms vs 7 ms) — Perry's loop lowering is fine.
  • All 68 ms — the entire gap — is this.value read + write. 6.8 ns/iteration ≈ 22 cycles for what should be ldr / fadd / str.

What the hot loop actually contains (--trace llvm, for.body.8)

There are zero js_* calls on the fast path. The cost is ~60 IR instructions guarding 3:

block what it does instrs
for.body.8 2 global loads, load volatile @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED, receiver reload + tag test ~12
class_field_inline.deref.14 7 loads from the object header (-8, -7, -6, +0, +4, +12, +16) + 13 compares/ands ~20
class_field_get_number.fast.11 the field load 3
class_field_get_number.merge.13 fadd + re-load receiver + 8 tag compares on the stored value ~12
put.dynic.guard.16 8 more header loads, re-reading the same -8/-7/-6/+0/+4/+16 bytes + 15 compares/selects ~25
put.dynic.ways/way1/way2 3-way inline-cache way probe over @perry_ic_7 ~3–9
put.dynic.bounds.20 slot-count reload + bounds test ~6
put.dynic.store.21 the field store 3

Useful work: load double + fadd double + store double. Everything else is guard.

Why LLVM cannot hoist any of it

Three independent barriers, all in the emitted IR:

  1. load volatile i8 @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED (expr/class_field_inline_guard.rs:122,266,348) and its array sibling @PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED. A volatile load is an optimisation barrier by definition — LICM will never hoist it out of a loop, and it re-reads memory every iteration. collectors/proven_this.rs says this out loud in its own module doc: "the per-access guarded diamond whose cost is unhoistable by construction (volatile gate + the fallback arm's opaque call block LICM)".
  2. The receiver alloca escapes. call void @js_shadow_slot_bind(i32 0, ptr %r8) hands the address of counter's slot to the runtime, so mem2reg cannot promote it and every iteration re-executes load double, ptr %r8 + bitcast + and POINTER_MASK before it can even start the guard.
  3. The get-guard and the put-guard do not share work. They load the same header bytes twice per iteration and test overlapping predicates, because they are emitted by two independent lowerings (class_field_inline_guard.rs and property_set.rs).

The lever

Not representation selection, and not more inlining — both already happened here. What is missing is loop-invariant guard hoisting for a shape-proven receiver:

  • (a) Hoist the shape check to the loop preheader. The receiver is loop-invariant and nothing in the loop can change its class. Check the header once, branch to a guard-free loop body, and keep today's diamond as the cold arm. This is the classic "guard version the loop" transform and is worth essentially the whole 68 ms.
  • (b) Stop making the invalidation latch volatile per access. A sticky global latch does not need volatile on the read side; it needs a compiler barrier at the flip site, or better, a version counter checked once in the preheader with the loop bailing out on mismatch. As emitted today the latch alone guarantees a memory access per field op forever.
  • (c) Merge the get-guard and put-guard for a read-modify-write on the same receiver+field. this.x = this.x + 1 currently validates the same shape twice.

collectors/proven_this.rs (repsel Phase 5a) already emits a __pshape clone with a guard-free this — and this module has perry_method__..__Counter__increment__pshape in it. It is not reached here because increment() was inlined into main first, so the inlined body re-enters the guarded diamond. Phase 5a's proof does not survive inlining. That is probably the cheapest concrete fix: make the inliner carry the proven-this claim into the inlined body, or run the guard-hoist after inlining.

Why this matters beyond one benchmark

bench_object_property is 126 ms vs node 16 ms and 05_object_create-class workloads dominate real applications far more than array math does. The RFC itself says so (§4: "objects-with-static-shape is the class that moves real applications"). But the RFC's remedy for that row is listed as "direct field offsets (no hash lookup), static method dispatch" — and Perry already emits a direct field offset here. The remaining cost is purely the guard that protects it. Unboxing buys nothing; hoisting buys ~9×.

This contradicts the standing "opaque js_* calls" model

The repo's working hypothesis for the integer-heavy class is "natively-expressible primitives become runtime calls", and #7128's conclusion was that the scoreboard should be opaque js_* calls removed from hot paths. That model is exactly right for 16_matrix_multiply / 11_prime_sieve (see the companion issue — 67.1M calls removed buys 10.7×). It is wrong for 09_method_calls: the hot loop already has zero js_* calls, and the score on that metric is already perfect while the benchmark is 7.9× behind. Any future repsel scoreboard needs a second axis — guard instructions executed per unit of useful work — or it will keep reporting success on this workload class.

Relation to existing issues

Repro

target/release/perry benchmarks/suite/09_method_calls.ts -o mc && ./mc
# same loop, no method — identical time, proving dispatch costs nothing
# same loop, plain local — 10 ms, the floor

Measured on Apple M1 Max, macOS 26.5, perry 0.5.1279 @ defa4d601, auto-optimize on, node v22.23.1. Host was not quiet; baseline reproduces public-node-bun-v1.json (78 vs 79 published).

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 3, 2026
  2. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Companion issues from the same 2026-08-03 diagnosis of the three worst suite gaps: #7286 (array index range proof — matrix_multiply/prime_sieve), #7287 (method_calls is guard-bound, not dispatch-bound), #7288 (build determinism — method_calls' published 79 ms only reproduces inside the checkout).

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

    performanceRuntime, 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