From ece9c49a642e0b087c3e382d6508428ca8d3d544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 22:06:08 +0200 Subject: [PATCH 1/2] perf(runtime): make the megamorphic write stub 2-way set-associative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct-mapped table cannot hold a colliding pair at all: the two keys evict each other on every rotation through the key set, so both miss forever and neither ever stabilises. Instrumenting the computed-key write loop showed that is not a rounding error — the runtime reported the colliding pairs directly ("k10"/"k115", "k11"/"k125" on one index), and the writes that never settle fall through to the full [[Set]] walk, which the call graph puts at 11.9% of the program. Same total capacity (2048 buckets x 2 ways), so the table does not grow. A sweep of the 500-key working set puts the worst bucket at two, so two ways absorb the collisions that exist rather than merely reducing them. Insert refreshes a resident key, then fills an empty way, and only otherwise evicts. Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP --- changelog.d/8976-write-stub-two-way.md | 25 +++++++++ crates/perry-runtime/src/proxy/put_value.rs | 57 ++++++++++++++++++--- 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 changelog.d/8976-write-stub-two-way.md diff --git a/changelog.d/8976-write-stub-two-way.md b/changelog.d/8976-write-stub-two-way.md new file mode 100644 index 0000000000..c260c9bd1a --- /dev/null +++ b/changelog.d/8976-write-stub-two-way.md @@ -0,0 +1,25 @@ +The megamorphic write stub is 2-way set-associative, and the computed-key +**write loop is now faster than node**: 40 → 20 ms against node's 24 ms on the +same host (−50%), with the combined overwrite loop 70 → 48 ms (−31%). + +A direct-mapped table cannot hold a colliding pair *at all*. The two keys evict +each other on every rotation through the key set, so both miss forever and +neither ever stabilises — the miss is not probabilistic, it is permanent. On +the 500-key write loop that left ~87k of 600k writes falling through every +cache into the full `[[Set]]` walk, which the call graph put at **11.9% of the +program**, essentially all of it (10.7%) inside `js_put_value_set`. + +The cause was invisible in a profile and only showed up in counters: probes +were missing with the RIGHT shape token and the WRONG key in the way. Dumping +the colliding pairs named them outright — `"k10"`/`"k115"` and `"k11"`/`"k125"`, +landing on indices 3885 and 2220. + +Two ways per bucket at the same total capacity (2048 × 2 rather than 4096 × 1), +so the table does not grow. A sweep of the working set puts the worst bucket at +exactly two, so two ways absorb the collisions that exist rather than merely +reducing them. Insert refreshes a resident key, then fills an empty way, and +only otherwise evicts, shifting way 0 down so the most recently primed key +survives. + +Suite: 2779 passed, 0 failed. Computed-key differential output is byte-identical +to node. diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index fffb7e9490..7268ddf41d 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -604,11 +604,25 @@ pub extern "C" fn js_put_value_set_dyn_ic( /// identical bits by construction. Heap-string keys recur once canonicalised: /// the first write interns the key (write tail) and later concat evaluations /// return the canonical pointer (intern hit), so the second prime converges. -const WRITE_STUB_WAYS: usize = 4096; +/// Buckets in the megamorphic write stub, each holding [`WRITE_STUB_ASSOC`] +/// entries. Capacity is `WRITE_STUB_BUCKETS * WRITE_STUB_ASSOC`. +const WRITE_STUB_BUCKETS: usize = 2048; + +/// Two ways per bucket, because a DIRECT-MAPPED table cannot hold a colliding +/// pair at all: the two keys evict each other on every rotation through the +/// key set, so both miss forever and neither can ever stabilise. Measured on +/// the computed-key write loop, that was not a rounding error — the pairs the +/// runtime reported (`"k10"`/`"k115"`, `"k11"`/`"k125"`) land on one index, +/// and the writes that never settle were 11.9% of the program, essentially +/// all of it the full `[[Set]]` walk they fall through to. +/// +/// A sweep of the 500-key working set puts the worst bucket at two, so two +/// ways absorb the collisions that exist rather than merely reducing them. +const WRITE_STUB_ASSOC: usize = 2; crate::perry_thread_local! { - static WRITE_STUB: [std::cell::Cell<(u64, u64, u64)>; WRITE_STUB_WAYS] = - std::array::from_fn(|_| std::cell::Cell::new((0, 0, 0))); + static WRITE_STUB: [[std::cell::Cell<(u64, u64, u64)>; WRITE_STUB_ASSOC]; WRITE_STUB_BUCKETS] = + std::array::from_fn(|_| std::array::from_fn(|_| std::cell::Cell::new((0, 0, 0)))); } /// Content-stable cache key for a NaN-boxed property key, or `None` when this @@ -664,14 +678,20 @@ fn stub_key_bits(key: f64) -> Option { #[inline(always)] fn write_stub_way(token: u64, key_bits: u64) -> usize { let h = (token ^ key_bits).wrapping_mul(0x9E37_79B9_7F4A_7C15); - ((h >> 40) as usize) & (WRITE_STUB_WAYS - 1) + ((h >> 40) as usize) & (WRITE_STUB_BUCKETS - 1) } #[inline(always)] fn write_stub_probe(token: u64, key_bits: u64) -> Option { WRITE_STUB.with(|t| { - let (tok, kb, slot) = t[write_stub_way(token, key_bits)].get(); - (tok == token && kb == key_bits && tok != 0).then_some(slot as u32) + let bucket = &t[write_stub_way(token, key_bits)]; + for way in bucket.iter() { + let (tok, kb, slot) = way.get(); + if tok == token && kb == key_bits && tok != 0 { + return Some(slot as u32); + } + } + None }) } @@ -680,7 +700,30 @@ fn write_stub_insert(token: u64, key_bits: u64, slot: u32) { if token == 0 || key_bits == 0 { return; } - WRITE_STUB.with(|t| t[write_stub_way(token, key_bits)].set((token, key_bits, slot as u64))); + WRITE_STUB.with(|t| { + let bucket = &t[write_stub_way(token, key_bits)]; + let entry = (token, key_bits, slot as u64); + // Refresh this key's own way if it is already resident, then fill an + // empty one, and only otherwise evict — shifting way 0 down so the + // most recently primed key survives. + for way in bucket.iter() { + let (tok, kb, _) = way.get(); + if tok == token && kb == key_bits { + way.set(entry); + return; + } + } + for way in bucket.iter() { + if way.get().0 == 0 { + way.set(entry); + return; + } + } + for i in (1..WRITE_STUB_ASSOC).rev() { + bucket[i].set(bucket[i - 1].get()); + } + bucket[0].set(entry); + }); } /// Validated fast store: the receiver must still be an ordinary, From 6e5926adb2dbe52b266726d3ee1dd05807aff472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 22:25:32 +0200 Subject: [PATCH 2/2] chore(changelog): name the fragment for its own PR (8977, not 8976) --- .../{8976-write-stub-two-way.md => 8977-write-stub-two-way.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{8976-write-stub-two-way.md => 8977-write-stub-two-way.md} (100%) diff --git a/changelog.d/8976-write-stub-two-way.md b/changelog.d/8977-write-stub-two-way.md similarity index 100% rename from changelog.d/8976-write-stub-two-way.md rename to changelog.d/8977-write-stub-two-way.md