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:
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)".
- 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.
- 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).
Summary
09_method_callsis 7.9× behind node (78 ms vs 10 ms for 10M iterations ofcounter.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):
09_method_calls—counter.increment()in a loopcounter.value = counter.value + 1bump(c)taking the receiverv = v + 1increment()is fully inlined intomain; there is no call in the loop.this.valueread + write. 6.8 ns/iteration ≈ 22 cycles for what should beldr/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:for.body.8load volatile @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED, receiver reload + tag testclass_field_inline.deref.14-8,-7,-6,+0,+4,+12,+16) + 13 compares/andsclass_field_get_number.fast.11class_field_get_number.merge.13fadd+ re-load receiver + 8 tag compares on the stored valueput.dynic.guard.16-8/-7/-6/+0/+4/+16bytes + 15 compares/selectsput.dynic.ways/way1/way2@perry_ic_7put.dynic.bounds.20put.dynic.store.21Useful 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:
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.rssays 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)".call void @js_shadow_slot_bind(i32 0, ptr %r8)hands the address ofcounter's slot to the runtime, so mem2reg cannot promote it and every iteration re-executesload double, ptr %r8+bitcast+and POINTER_MASKbefore it can even start the guard.class_field_inline_guard.rsandproperty_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:
volatileon 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.this.x = this.x + 1currently validates the same shape twice.collectors/proven_this.rs(repsel Phase 5a) already emits a__pshapeclone with a guard-freethis— and this module hasperry_method__..__Counter__increment__pshapein it. It is not reached here becauseincrement()was inlined intomainfirst, 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-thisclaim into the inlined body, or run the guard-hoist after inlining.Why this matters beyond one benchmark
bench_object_propertyis 126 ms vs node 16 ms and05_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" modelThe 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 for16_matrix_multiply/11_prime_sieve(see the companion issue — 67.1M calls removed buys 10.7×). It is wrong for09_method_calls: the hot loop already has zerojs_*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
js_typed_feedback_class_field_{get,set}_guard→ TLSTYPED_LAYOUTShashmap, quoted there as 3300 ms). That path still exists and is still reachable — see the companion build-determinism issue. The 78 ms arm measured here is the newer inline path, and it is guard-bound, not hashmap-bound. perf(GC): make per-object layout O(1)-loadable — kill per-operation thread-local layout tracking (umbrella: method_calls/array-downgrade/object-property) #5094's header-bit proposal is a precondition for (a): the hoisted preheader check needs an O(1) header-loadable layout token to check.Repro
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 reproducespublic-node-bun-v1.json(78 vs 79 published).