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

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_callscounter.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).

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