Skip to content

Commit 9d6a18e

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 9d6a18e

14 files changed

Lines changed: 1332 additions & 49 deletions
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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 |
109+
|---|---|---|
110+
| **shapes** | **0.28** | **0.23** |
111+
| iso_miss | 2.85 | 2.71 |
112+
| retain_wide | 1.14 | 1.08 |
113+
| tree_wide | 2.17 | 2.11 |
114+
| retain | 0.55 | 0.53 |
115+
| asyncpipe | 0.93 | 0.91 |
116+
| churn_alloc | 0.37 | 0.36 |
117+
| fib40 | 0.40 | 0.39 |
118+
| interp | 2.28 | 2.28 |
119+
| churn | 0.42 | 0.42 |
120+
| cycles | 0.19 | 0.19 |
121+
| churn_read | 0.02 | 0.02 |
122+
| push_cls | 0.35 | 0.36 |
123+
| push_num | 0.13 | 0.14 |
124+
| deeplist | 0.24 | 0.25 |
125+
| tree | 1.63 | 1.65 |
126+
127+
Every benchmark's output is byte-identical to `node --experimental-strip-types`,
128+
verified before timing; no protected floor is crossed.
129+
130+
On the `shapes_big` profile (two agreeing 7 s runs, ~5 250 samples each), the
131+
whole dispatch cluster (`class_registry` + `instanceof::class_*` +
132+
`js_native_call_method` + `native_call_meth*`) falls from **16.0% to 11.1%**,
133+
and `RandomState` + `pthread_mutex_*` from **5.6% to 4.0%**. Four leaders
134+
disappear from the profile entirely: `get_parent_class_id` (3.3%),
135+
`class_chain_reaches` (2.1%), `js_instanceof` (1.0%), and — because the fast
136+
path needs no handle scope — `RuntimeHandle::get_nanbox_u64`, which was the
137+
single hottest symbol in the program at 4.9%.
138+
139+
#### What the remaining lock traffic is, and why it is not this change's
140+
141+
The residual `pthread_mutex_*` + `RandomState` is `is_registered_symbol` and
142+
`is_registered_map`/`is_registered_set`, reached from two places: `js_array_get_f64`
143+
(so, every `arr[i]`), and the pointer classification the dispatch guard runs.
144+
Those registries are already latched (#7474, #7755) — the latches are simply
145+
**armed**, because something in startup materialises a well-known Symbol, which
146+
turns a free atomic load into a process-global mutex for every array element
147+
read in every program. That is worth chasing, but it is Map/Set/Symbol registry
148+
work, not class dispatch, and it is adjacent to work in flight elsewhere.
149+
150+
The rest of the gap on `shapes.ts` is likewise not dispatch: array element
151+
reads (`js_array_get_f64` + `array_object_flags` + `js_array_length`, ~10%) and
152+
the GC layout tables (~14%) now dominate it, and both belong to campaigns
153+
already running.
154+
155+
#### Two pre-existing divergences this change does NOT fix
156+
157+
Verified against a binary built from the merge-base, which produces the same
158+
wrong answers: `Class.prototype.m = fn` after `m`'s first dispatch still
159+
resolves to the vtable method, and `Object.setPrototypeOf(instance, donor)`
160+
does not redirect an already-dispatched method on that instance. Both are
161+
produced by the tower itself — the fast path's guard rejects a receiver with a
162+
`meta` record, and prototype surgery now bumps `VTABLE_GEN` — so neither is
163+
reached from the cache. They are called out in the gap test rather than
164+
asserted, so the file stays byte-identical to Node.

0 commit comments

Comments
 (0)