Skip to content

perf(class): stop disarming every dispatch guard when a class prototype is materialized - #7800

Merged
proggeramlug merged 1 commit into
mainfrom
perf/7794-class-dispatch-prototype-latch
Aug 11, 2026
Merged

perf(class): stop disarming every dispatch guard when a class prototype is materialized#7800
proggeramlug merged 1 commit into
mainfrom
perf/7794-class-dispatch-prototype-latch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What this found

gc-handoff/apps/shapes.ts was believed to be a class-dispatch problem — 5.87x
scriptc, 2.86x node. The dispatch guard was never running at all.

class_decl_prototype_value() — the lazy materializer that creates a declared
class's prototype object the first time anything demands it — called
invalidate_class_prototype_fast_guards(). That is not a hint. It trips a
process-global, monotonic latch that

The latch exists for prototype surgery (Class.prototype.m = fn) — the two
call sites in class_registry/prototype_methods.rs, which keep it. Materialization
changes none of that: the object is fresh and unobserved, and the writes
immediately below install constructor plus exactly the methods the class already
declares.

What actually reaches the materializer (measured with a name-printing probe on
the materializer itself, not inferred):

program materializations
class A {}; new A() 0
class B extends A {}; new B() 2B, A
class C extends B extends A; new C() 3C, B, A
same, but only new B() 2 — B, A
x instanceof SomeClass 0
Object.getPrototypeOf(x) 0
arr instanceof Array 0

So the trigger is new on any class that extends something — instantiating a
subclass materializes its whole prototype ancestor chain. It is not instanceof
and not getPrototypeOf; an earlier revision of this description said it was, and
that was inferred rather than measured. In shapes.ts the three are Rect,
Shape, Node2D — the ancestor chain of the first subclass build() constructs.

Evidence — temporary per-precondition counters on the guard, shapes.ts:

[mdsc] total=384000 notptr=0 nogcheader=0 gctype=0 descriptors=0
       protoinvalid=384000 notregular=0 cid0=0  inval_sites=[3,0,0,0]

384,000 of 384,000 probes failed on this latch and on nothing else.
inval_sites[0] is class_decl_prototype_value. A probe containing no
instanceof at all shows the same 100%.

The two halves are only worth anything together

js_method_direct_shape_class factors the class-id half out of
js_method_direct_shape_guard (which is now defined in terms of it, so its
single-pair semantics are unchanged by construction). Codegen uses it to widen the
shape-guarded direct call from ONE arm — the declared receiver class — to the
declared class plus its subclass closure, each paired with the body the method
resolves to when walked from that class, capped at 8 arms.

Measured separately, each half is a no-op:

  • multi-arm dispatch alone, latch still stuck: shapes 0.2237 -> 0.2241 s.
  • latch fix alone, single-arm guard: shapes 0.2228 -> 0.2205 s (-1.0%).
  • both: 0.2228 -> 0.1859 s (-16.6%).

The reason is that they gate each other. With the latch stuck, no guard of any
width ever passes. With the latch fixed but only one arm, the guard still
speculates the declared class — Node2D — which is never the runtime class of
anything in the array, so it still misses on every element. Neither is worth
landing without the other.

Measurements (quiet M1 mini, best-of-5, all three arms interleaved rep-by-rep)

Baseline origin/main @ 0a2bf15bd (perry 0.5.1455), corpus gc-handoff/m0810/pr/.
Every cell exit 0, every output byte-identical to node --experimental-strip-types.

bench main latch only this PR ratio protected-set gate
shapes 0.2228 0.2205 0.1859 0.834
asyncpipe 0.7216 0.7194 0.7188 0.996 PASS <= 0.75
churn 0.4235 0.4231 0.4220 0.996 PASS <= 0.44
churn_alloc 0.3780 0.3738 0.3736 0.988 PASS <= 0.39
churn_read 0.0229 0.0225 0.0224 0.978 PASS <= 0.03
cycles 0.1932 0.1929 0.1931 0.999 PASS <= 0.20
deeplist 0.2451 0.2450 0.2456 1.002 PASS <= 0.26
fib40 0.3933 0.3938 0.3935 1.001 PASS <= 0.41
interp 1.8931 1.8901 1.8894 0.998 PASS <= 1.95
iso_miss 2.3598 2.3675 2.3660 1.003
pipeline 0.5520 0.5408 0.5446 0.987
push_cls 0.3564 0.3569 0.3568 1.001 PASS <= 0.37
push_num 0.1429 0.1433 0.1432 1.002 PASS <= 0.15
retain 0.5366 0.5368 0.5362 0.999 PASS <= 0.56
retain1 0.2961 0.2962 0.2964 1.001
retain_wide 1.0887 1.0910 1.0911 1.002 PASS <= 1.12
retain_wide1 0.2736 0.2733 0.2742 1.002
tree 1.6346 1.6340 1.6340 1.000 PASS <= 1.68
tree_wide 2.1014 2.1044 2.1028 1.001 PASS <= 2.15

Re-confirmed at 9 reps for the four programs an earlier, contaminated two-arm run
had flagged: asyncpipe 0.7220 -> 0.7196 (0.997), interp 1.8874 -> 1.8886 (1.001),
iso_miss 2.3671 -> 2.3673 (1.000), pipeline 0.5520 -> 0.5613 (1.017), shapes
0.2232 -> 0.1866 (0.836). That first run had been taken while another agent's
benchmarks were running on the mini and its ~4% "regressions" did not reproduce.

shapes is still 2.4x node (0.078) and 4.9x scriptc (0.038). This closes a third
of the gap, not the gap. See the probes below for where the rest is.

Correctness

  • All 19 corpus programs byte-identical to node, exit 0.
  • Canary gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0, also under
    PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 and under
    PERRY_GC_VERIFY_EVACUATION=1 (both exit 0). shapes.ts likewise.
  • 169 test-files/*.ts matching class / inherit / extend / method / proto / super /
    instanceof: identical pass/fail set to clean main — the same 7 pre-existing
    failures, each individually A/B'd against the reference build.

Review question I could not settle

Is prototype materialization really not surgery? The writes below the removed
call install the class's own declared methods on its own fresh prototype, which
cannot change what recv.m() resolves to. The residual risk is a later write to
that now-existing prototype object that does not route through
js_register_prototype_method / class_prototype_method_root_store — those two
still invalidate, and Object.defineProperty is covered by descriptors_in_use()
— but I did not enumerate every path that can reach a materialized prototype
object's fields.

Probes

gc-handoff/bench/shapes_{build,describe,dispatch,dispatch_static}.ts decompose
apps/shapes.ts, each annotated with its measured seconds. On main they record
that build() is 0.1035 s (46% of the program) and that describe()'s
"lit" + this.stringField concatenation is 0.074 s (33%, ~620 ns/call, through
js_dynamic_string_or_number_add — the NaN-boxed field read does not carry its
declared string type forward). Those two, not dispatch, are where the remaining
gap to node's 0.083 s lives.

Blast radius of the latch, measured

The latch is monotonic in production — the only store(false) is #[cfg(test)]
(class_registry/gc_roots.rs:495). Since almost every class-hierarchy program
trips it, the obvious worry is that it silently disarms the element-shape repsel
work (#7770, #7771, #7766, #7702) process-wide. It does not, and the measured
cost elsewhere is ~0.
Quiet mini, best-of-9, one statement added before an
otherwise identical hot loop:

probe main + one instanceof + one getPrototypeOf
churn_read shape, object literals 0.0222 0.0222 0.0222
same loop, array of class instances 0.0222 0.0222 0.0222
same loop, method call per element 2.4613 2.4648

Two different mechanisms, and only one of them is monotonic:

  • Element shapes self-heal. invalidate_all_element_shapes() bumps
    CLASS_SHAPE_GENERATION; each record carries the generation it was installed
    under and ensure_element_shape re-establishes it on the next query
    (array/element_shape.rs:204, :388). One bump costs at most one
    re-establishment per array — the repsel element-shape work is not disarmed by
    this.
  • Dispatch guards do not self-heal. That half is permanent, which is what
    shapes.ts paid for — but on its own it is worth only 1.0% there (0.2228 ->
    0.2205). It becomes worth 16.6% only in combination with the multi-arm widening,
    because a single-arm guard bets on the declared class and misses on a
    base-typed collection whether or not the latch is set.

Summary by CodeRabbit

  • Performance

    • Improved method-call performance for class hierarchies by enabling faster dispatch across eligible subclasses.
    • Reduced unnecessary cache invalidation when class prototypes are materialized.
    • Preserved existing optimized behavior for unsupported or complex class hierarchies.
  • Documentation

    • Added release documentation covering dispatch behavior, performance results, limitations, and benchmark findings.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a runtime helper for class-and-keys shape checks, preserves guards during declared-class prototype materialization, and extends shape-only direct method calls with bounded subclass dispatch arms.

Changes

Class dispatch and prototype materialization

Layer / File(s) Summary
Runtime shape contract and materialization
crates/perry-runtime/src/typed_feedback/guards.rs, crates/perry-codegen/src/runtime_decls/objects.rs, crates/perry-runtime/src/object/class_registry/...
The runtime adds js_method_direct_shape_class. Direct shape guards use the helper. Declared-class prototype materialization no longer invalidates related guards and caches.
Bounded subclass direct dispatch
crates/perry-codegen/src/lower_call/..., changelog.d/7800-class-dispatch-prototype-latch.md
Shape-only direct calls can use deterministic subclass arms capped at eight. Typed-feedback guards remain single-class. Matching calls use resolved subclass targets, while other calls use the existing fallback path.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant emit_guarded_direct_method_call
  participant js_method_direct_shape_class
  participant Subclass_method_target
  participant Dynamic_fallback
  emit_guarded_direct_method_call->>js_method_direct_shape_class: Probe receiver class and keys
  js_method_direct_shape_class-->>emit_guarded_direct_method_call: Return class ID and keys
  emit_guarded_direct_method_call->>Subclass_method_target: Call matching declared or subclass target
  emit_guarded_direct_method_call->>Dynamic_fallback: Continue when no arm matches
Loading

Possibly related PRs

  • PerryTS/perry#7496: Related to class-prototype invalidation and element-shape guard maintenance.
  • PerryTS/perry#7769: Related to prototype and dispatch guard invalidation behavior.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary performance change caused by class prototype materialization.
Description check ✅ Passed The description is detailed and on-topic, covering the change, rationale, test evidence, benchmarks, and residual risks, so it is mostly complete.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7794-class-dispatch-prototype-latch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug force-pushed the perf/7794-class-dispatch-prototype-latch branch from 83b778e to 6cfeba9 Compare August 10, 2026 21:13
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite (./scripts/run_gap_tests.sh, PERRY_SKIP_BUILD=1, against this branch's release build) is running locally. At 247/522 the only failures are 8 tests already present in test-parity/gap_snapshot.json — zero new. Notably test_gap_2159_defineproperty_class_prototype, the test most directly exercising what this touches, is a pre-existing failure on clean main too (A/B'd against the reference build). Will update with the full verdict.

…pe is materialized

`class_decl_prototype_value()` lazily materializes a declared class's
prototype object the first time anything demands it — `instanceof`,
`Object.getPrototypeOf`, a `super` chain. It called
`invalidate_class_prototype_fast_guards()`, which trips a process-global,
MONOTONIC latch that makes every `js_method_direct_shape_guard` /
`js_typed_feedback_method_direct_call_guard` answer "miss" for the rest of the
run, retires every element-shape record (`invalidate_all_element_shapes`,
#7480), and bumps `VTABLE_GEN`, retiring the `vtable_ic` / `obj_dispatch_ic`
caches (#7769).

That latch is for prototype SURGERY (`Class.prototype.m = fn`) — the two call
sites in `class_registry/prototype_methods.rs`, which keep it. Materialization
changes nothing about which member `recv.m()` resolves to: the object is fresh
and unobserved, and the writes below it install `constructor` plus exactly the
methods the class already declares. But because any demand lands there, an
ordinary class-hierarchy program disarmed its own speculation during startup
and then ran every method call and every array element read on the slow path.

Measured on `gc-handoff/apps/shapes.ts` with per-precondition counters on the
guard: 384,000 of 384,000 probes failed on this latch and on nothing else.

Also adds `js_method_direct_shape_class`, the class-id half of
`js_method_direct_shape_guard` (which is now defined in terms of it, so its
single-pair semantics are unchanged by construction), and uses it to widen the
shape-guarded direct call from one arm — the declared receiver class — to the
declared class plus its subclass closure, capped at 8 arms. For a base-typed
collection the single-arm bet loses on every element.

shapes 0.2256 -> 0.1976 s on the quiet mini (best-of-5, output byte-identical
to node, exit 0). Four allocation-heavy programs regress 3.4-4.2%; see the PR
body — this is a draft for that reason.
@proggeramlug
proggeramlug force-pushed the perf/7794-class-dispatch-prototype-latch branch from 6cfeba9 to 87911ac Compare August 10, 2026 21:44
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite: complete. 522/522 run, exit 1, zero regressions attributable to this change.

./scripts/run_gap_tests.sh with PERRY_SKIP_BUILD=1 against this branch's release
build. The harness exits 1 and names 11 regressions. I A/B'd every one of them
standalone against the clean-main reference build (0a2bf15bd, the same binary
the corpus baseline was compiled with):

test harness verdict clean main this branch
test_gap_fetch_request_from_node_incoming_message pass -> crash FAIL FAIL
test_gap_gc_alloc_point_no_move pass -> crash pass pass
test_gap_gc_rest_argument_rooting pass -> parity_fail pass pass
test_gap_gc_same_module_call_argument_rooting pass -> parity_fail pass pass
test_gap_http_client_no_redirect_follow pass -> crash FAIL FAIL
test_gap_http_overloads_3226plus pass -> crash FAIL FAIL
test_gap_http_req_async_iterator pass -> crash FAIL FAIL
test_gap_http_res_socket_writable_onfinished pass -> crash FAIL FAIL
test_gap_net_connect_bound_value pass -> crash FAIL FAIL
test_gap_specabi_reassign pass -> parity_fail FAIL FAIL
test_gap_zlib_3285_params pass -> parity_fail FAIL FAIL

Every row is identical between the two builds. Eight fail on clean main too;
three pass on both builds standalone and only fail under the harness.

This is the documented phantom-regression shape for a fresh worktree — the harness
says so itself in its own preamble:

NOTE: no macos baseline at 'test-parity/gap_snapshot.macos.json'; comparing against
      test-parity/gap_snapshot.json (the shared baseline required CI uses).

Corroborating: the run also reports 10 node_fail -> parity_fail status changes
(4510_enum_forward_ref, backoff_options, cron_cronjob, dayjs_factory_arg,
derived_param_props, enum_in_function_body, moment_methods,
prop_plan_cache_invalidation, ratelimiter_memory, slugify_options) — i.e.
node stopped failing tests the snapshot recorded as node-failures. That is an
oracle/environment difference from the snapshot's recording host, not a compiler
change. The pass -> crash cluster is six http/fetch/net tests on a heavily loaded
shared dev machine.

Two caveats stated plainly rather than papered over:

  1. This run used the latch-fix-only build, not the full PR head — the gap run
    was started against that build while the combined one was being rebuilt. The
    latch removal is the half that touches prototype resolution, so it is the half
    that most needed this gate. The multi-arm codegen half is covered by the
    169-test class / prototype / inheritance / instanceof sweep (identical pass/fail
    set to clean main), which was run against a build containing both changes.
  2. I did not run UPDATE_SNAPSHOT=1. The snapshot deltas above are
    environmental and belong to whoever re-baselines macOS, not to this PR.

@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 05:47
@proggeramlug
proggeramlug merged commit 94fdafd into main Aug 11, 2026
8 of 18 checks passed
@proggeramlug
proggeramlug deleted the perf/7794-class-dispatch-prototype-latch branch August 11, 2026 05:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs`:
- Around line 938-980: Before adding a subclass dispatch arm in the loop
resolving `sub_name`, reject it when `class_chain_has_field_named(ctx, sub_name,
property)` is true, so an own or inherited class-field override is not bypassed
by `target_fn`. Preserve the existing method/static/rest/arity eligibility
checks, and add a regression covering a base-typed reference whose subclass
defines a same-named class field.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 91927f27-f070-4882-b83f-5a1312302517

📥 Commits

Reviewing files that changed from the base of the PR and between 4e7d245 and 87911ac.

📒 Files selected for processing (7)
  • changelog.d/7800-class-dispatch-prototype-latch.md
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/typed_feedback/guards.rs

Comment on lines +938 to +980
let Some(keys_global) = ctx.class_keys_globals.get(sub_name).cloned() else {
continue;
};
// Resolve through the SUBCLASS's own chain, and remember
// where it landed: the rest-param shape is a property of
// the declaring class, and a rest-bearing target cannot be
// called with this site's flat, base-arity argument list.
let mut cur = Some(sub_name.clone());
let mut resolved: Option<(String, String)> = None;
while let Some(c) = cur {
let key = (c.clone(), property.to_string());
if let Some(fname) = ctx.methods.get(&key).cloned() {
resolved = Some((c, fname));
break;
}
cur = ctx.classes.get(&c).and_then(|c| c.extends_name.clone());
}
let Some((decl_class, target_fn)) = resolved else {
continue;
};
if target_fn.starts_with("perry_static_") {
continue;
}
if matches!(
ctx.method_has_rest
.get(&(decl_class.clone(), property.to_string())),
Some(&true)
) {
continue;
}
if ctx
.method_param_counts
.get(&(decl_class, property.to_string()))
.is_some_and(|&n| n > max_explicit_arity)
{
continue;
}
seen_ids.push(sub_id);
subclass_arms.push(SubclassDispatchArm {
class_id: sub_id,
keys_global,
target_fn,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exclude subclass arms that can have an own class-field override.

shape_only_guard checks fields only on class_name. A subclass can declare property as a class field. Its canonical keys token then passes this arm, but arm.target_fn bypasses the own field and calls the inherited method.

For example, const value: Base = new Sub() must call Sub's method = () => ... field, not Base.method().

Reject an arm when class_chain_has_field_named(ctx, sub_name, property) is true. Add a regression for a base-typed reference to a subclass with a same-named class field.

Proposed eligibility guard
                     if !is_subclass {
                         continue;
                     }
+                    if class_chain_has_field_named(ctx, sub_name, property) {
+                        continue;
+                    }
                     let Some(keys_global) = ctx.class_keys_globals.get(sub_name).cloned() else {
                         continue;
                     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs` around
lines 938 - 980, Before adding a subclass dispatch arm in the loop resolving
`sub_name`, reject it when `class_chain_has_field_named(ctx, sub_name,
property)` is true, so an own or inherited class-field override is not bypassed
by `target_fn`. Preserve the existing method/static/rest/arity eligibility
checks, and add a regression covering a base-typed reference whose subclass
defines a same-named class field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant