Skip to content

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

Draft
proggeramlug wants to merge 1 commit into
mainfrom
perf/7794-class-dispatch-prototype-latch
Draft

perf(class): stop disarming every dispatch guard when a class prototype is materialized#7800
proggeramlug wants to merge 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.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 795d4cfa-7a12-483f-be32-c8ea5cb346f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

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