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