Skip to content

Commit 1d99f98

Browse files
author
Ralph Küpper
committed
perf(runtime): class dispatch and instanceof stop consulting locked hash maps (#7769)
The class parent chain becomes a dense atomic mirror instead of a process-global RwLock<HashMap>; the hasInstance / toStringTag / extends-Error / fetch-parent / generic-origin / class-static-symbol / timer-id registries get monotone latches; the dispatch tower caches its own per-(class, method name) resolution behind a re-checked receiver-shape guard; and vtable argument marshalling drops two Vec allocations per dynamic call. shapes.ts 0.28s -> 0.23s on the pinned quiet mini, no protected floor crossed.
1 parent 293e72f commit 1d99f98

15 files changed

Lines changed: 1367 additions & 71 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
### Class dispatch and `instanceof` stop consulting locked hash maps
2+
3+
A scene-graph benchmark (`gc-handoff/apps/shapes.ts` — deep `extends` chains,
4+
virtual dispatch through a base-typed array, `super()` chains, `instanceof`,
5+
getters, statics, a fieldless subclass and a two-level indirect subclass) was
6+
the widest margin a competing compiler held anywhere in the corpus. A
7+
symbolicated profile showed why, and it was not codegen: **the runtime answered
8+
"who is this class's parent?" and "which method is this?" with a process-global
9+
lock plus a SipHash probe, per hop, per call.**
10+
11+
Measured on the pinned quiet mini, `std::hash::random::RandomState` was 1.3% of
12+
runtime and `pthread_mutex_{lock,unlock}` another 2.8% — for what is
13+
semantically an indexed load in a single-threaded program.
14+
15+
#### The parent chain is now a dense mirror, not a `RwLock<HashMap>`
16+
17+
`get_parent_class_id` is the single hottest class-registry read in the runtime:
18+
`instanceof`, vtable dispatch, static-member lookup, `super()` construction,
19+
symbol lookup and the typed-feedback guards all walk the parent chain one hop at
20+
a time, and every hop took `CLASS_REGISTRY.read()` plus a hash probe. Codegen
21+
assigns user class ids from a small sequential counter, so every edge whose
22+
child id fits a 64 K window is mirrored into a flat array of atomics
23+
(`.bss`, zero-fill, only the indexed pages are ever touched). In-window ids
24+
answer from one atomic load; the reserved builtin bands and the high-bit
25+
synthetic ids keep using the map.
26+
27+
The dense slot stores `parent + 1`, which is what lets one word distinguish
28+
"absent" from "registered with parent id 0" — every caller that treats `Some(0)`
29+
as a chain terminator does so explicitly, and a test pins that.
30+
31+
#### Five metadata registries got monotone latches
32+
33+
`Symbol.hasInstance` hooks, `Symbol.toStringTag` hooks, `extends Error`, the
34+
fetch-builtin parent kind, the generic-origin table, the class static-symbol
35+
table, and the timer-id registry are all empty in a program that does not use
36+
those features — but `js_instanceof` probed three of them on every evaluation,
37+
and `class_chain_reaches` probed one on every hop. They now use
38+
`registry_latch::RegistryLatch` (#7755), so an unused feature answers from one
39+
atomic load. The `Symbol.hasInstance` latch also keeps the string-keyed
40+
`well_known_symbol("hasInstance")` interning probe off the path entirely.
41+
42+
#### The dispatch tower caches its own answer
43+
44+
`js_native_call_method` is the virtual-call path for every receiver whose static
45+
type does not pin the callee — which is *every* call through a base-typed
46+
collection, the shape a class hierarchy is written in. Reaching a resolution
47+
cost a `String` allocation for the method name, a `RuntimeHandleScope`, ~900
48+
lines of probes for exotic receiver kinds, a GC-heap `StringHeader` allocation
49+
for the prototype-chain probe, a lock and two SipHash lookups. For
50+
`shape.area()` that is four heap allocations and a lock around a single
51+
multiply.
52+
53+
A per-thread, content-keyed cache now records the tower's OUTCOME for a
54+
`(class_id, method name)` pair, and a fast path at the top of the tower serves
55+
it. Three things about it are load-bearing:
56+
57+
* **Both resolution points populate it.** The first attempt cached only the
58+
tower's tail vtable arm, which checks the receiver's OWN class vtable — so
59+
every INHERITED method (`class Square extends Rect` calling `Rect`'s `area`)
60+
missed forever, and inherited methods are the common case in any real
61+
hierarchy. The parent-chain walk in `handle_methods` is the other site, and it
62+
is the one that mattered.
63+
* **It is keyed on the name BYTES, not its address.** The sibling `VTABLE_IC`
64+
keys on the rodata pointer codegen passes, but `js_native_call_method_str_key`
65+
reaches the same tower with a name materialised into a *caller-stack* scratch
66+
buffer, where two different short names genuinely land at the same address in
67+
successive calls. A sabotage test plants exactly that and asserts a miss.
68+
* **A hit never substitutes for an object-specific check.** Everything the tower
69+
decides per RECEIVER is re-verified on every hit: pointer classification
70+
through `gc_pointer_and_type_from_value` (buffers, typed arrays, Sets, Maps,
71+
RegExps and Symbols are raw allocations with no `GcHeader`, so screening them
72+
before the header read is a memory-safety requirement, not an optimisation —
73+
see #5625), `OBJECT_TYPE_REGULAR`, a null `meta` (which rules out both a
74+
per-instance `[[Prototype]]` override and any own accessor descriptor), the
75+
own-key scan an own field would win on, and the recorded-prototype probe. The
76+
`using`/`await using` disposal hooks and the iterator helpers are excluded by
77+
name, because both branch on per-object state the guard cannot see (a
78+
Symbol-keyed own property, and "is the receiver an iterator").
79+
80+
Prototype surgery now bumps `VTABLE_GEN`. `invalidate_class_prototype_fast_guards`
81+
is the single latch all three prototype-write entry points funnel through, but
82+
the method-dispatch caches were only retired by class *registration*, so a
83+
`Class.prototype.m = fn` after first dispatch left them serving the pre-surgery
84+
answer.
85+
86+
#### One argument vector instead of two `Vec`s
87+
88+
`call_vtable_method` built a `Vec<f64>` of positional args and then
89+
`call_fn_with_f64_args` built a second `Vec` with `this` prepended — two
90+
`malloc`/`free` round-trips for a zero-argument virtual call. It is now one
91+
buffer, on the stack for every arity that occurs in practice.
92+
93+
#### Thread safety
94+
95+
`perry/thread` spawns real OS threads with independent arenas, so both new
96+
structures had to stay correct off the main thread. The parent mirror is
97+
process-global atomics published (`Release`) *before* the map insert, so no
98+
reader can observe an edge through the map without it also being visible
99+
densely; the latches follow `RegistryLatch`'s arm-before-publish rule, whose
100+
only possible wrong observation ("idle while non-empty") that rule excludes. The
101+
dispatch cache is per-thread and starts empty on every worker, so a worker
102+
populates it from its own tower run rather than inheriting one — pinned by
103+
`test_issue_7769_thread_class_dispatch.ts`, which runs the same hierarchy on the
104+
main thread, through `parallelMap`, and through `spawn`, and compares.
105+
106+
#### Measured (quiet M1 mini, best of 5, absolute seconds)
107+
108+
| | before | after | | | before | after |
109+
|---|---|---|---|---|---|---|
110+
| **shapes** | **0.28** | **0.23** | | interp | 2.28 | 2.28 |
111+
| iso_miss | 2.85 | 2.72 | | churn | 0.42 | 0.41 |
112+
| retain_wide | 1.14 | 1.09 | | churn_alloc | 0.37 | 0.37 |
113+
| tree_wide | 2.17 | 2.10 | | churn_read | 0.02 | 0.02 |
114+
| retain | 0.55 | 0.53 | | push_cls | 0.35 | 0.35 |
115+
| asyncpipe | 0.93 | 0.90 | | push_num | 0.13 | 0.13 |
116+
| fib40 | 0.40 | 0.39 | | cycles | 0.19 | 0.19 |
117+
| tree | 1.63 | 1.64 | | deeplist | 0.24 | 0.24 |
118+
119+
Every output byte-identical to `node --experimental-strip-types`, verified
120+
before timing; no protected floor crossed. `shapes` prints
121+
`1431180 1463160 1176000 320000040000 48000 24000 144000` and `iso_miss` reports
122+
`misses 0`.
123+
124+
On the `shapes_big` profile (two agreeing 7 s runs, ~5 250 samples each), the
125+
dispatch cluster (`class_registry` + `instanceof::class_*` +
126+
`js_native_call_method` + `native_call_meth*`) falls from **12.3% to 7.5%**, and
127+
`RandomState` + `pthread_mutex_*` from **5.6% to 3.6%**. Four leaders leave the
128+
profile's top ranks: `get_parent_class_id` (3.3% → 0.5%), `class_chain_reaches`
129+
(2.1% → 0.3%), `js_instanceof` (1.0%), and — because the fast path needs no
130+
handle scope — `RuntimeHandle::get_nanbox_u64`, which was the single hottest
131+
symbol in the program at 4.9% and is now off the board.
132+
133+
#### What the remaining lock traffic is, and why it is not this change's
134+
135+
Attributed by walking the profile's call graph: of the `pthread_mutex_lock`
136+
frames, 7 in 8 come from `is_registered_symbol_slow` and the rest from
137+
`is_registered_map`, reached from `js_array_get_f64` (so, every `arr[i]`) and
138+
from the dispatch guard's pointer classification. Those registries are already
139+
latched (#7474, #7755) — the latches are simply **armed**, because something in
140+
startup materialises a well-known Symbol, which turns a free atomic load into a
141+
process-global mutex for every array element read in every program. That is
142+
worth chasing, but it is Map/Set/Symbol registry work, not class dispatch, and
143+
it is adjacent to work in flight elsewhere.
144+
145+
The rest of the gap on `shapes.ts` is likewise not dispatch: array element reads
146+
(`js_array_get_f64` + `array_object_flags` + `js_array_length`, ~10%) and the GC
147+
layout tables (~14%) now dominate it.
148+
149+
#### Two pre-existing divergences this change does NOT fix
150+
151+
Verified against a binary built from the merge-base, which produces the same
152+
wrong answers: `Class.prototype.m = fn` after `m`'s first dispatch still
153+
resolves to the vtable method, and `Object.setPrototypeOf(instance, donor)`
154+
does not redirect an already-dispatched method on that instance. Both are
155+
produced by the tower itself — the fast path's guard rejects a receiver with a
156+
`meta` record, and prototype surgery now bumps `VTABLE_GEN` — so neither is
157+
reached from the cache. They are called out in the gap test rather than
158+
asserted, so the file stays byte-identical to Node.

0 commit comments

Comments
 (0)