From e58dc460477e6adf94387ae9c7dd4861e42fbe17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 20:16:54 +0200 Subject: [PATCH] perf(runtime): one shape-descriptor lookup per dyn write IC hit, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shape_descriptor_by_id was 10.5% of self time in a computed-key write loop — the single largest item — and half of it was a duplicate. object_shape_id runs a full table lookup and copies the whole ShapeDescriptor out purely to prove the stamped id is live, then discards it; dyn_ic_try_store's bound check then looked the same id up again, and both write re-prime sites did the same after already holding a descriptor. Read the stamp off the header instead. In try_store the token compare now runs first, so a wrong-shape receiver costs a load and a compare with no table work at all, and the single lookup that supplies the slot bound doubles as the liveness proof: a stamp with no live descriptor returns None exactly where object_shape_id's 0 made the token compare fail before. At the two re-prime sites the descriptor is already in hand, so the token comes from the header word directly. Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP --- .../8975-single-shape-lookup-per-ic-hit.md | 24 +++++++++++++++ crates/perry-runtime/src/proxy/put_value.rs | 29 +++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 changelog.d/8975-single-shape-lookup-per-ic-hit.md diff --git a/changelog.d/8975-single-shape-lookup-per-ic-hit.md b/changelog.d/8975-single-shape-lookup-per-ic-hit.md new file mode 100644 index 0000000000..2cda949340 --- /dev/null +++ b/changelog.d/8975-single-shape-lookup-per-ic-hit.md @@ -0,0 +1,24 @@ +The dynamic write IC does one shape-descriptor lookup per hit instead of two. + +`object_shape_id` runs a full shape-table lookup — and copies the whole +`ShapeDescriptor` out of the table — purely to prove the stamped id is live, +then discards it. `dyn_ic_try_store` called it for the token compare and then +looked the SAME id up again for the slot bound; both write re-prime sites did +the same while already holding a descriptor. + +It now reads the stamp off the header. In `dyn_ic_try_store` the token compare +runs first, so a wrong-shape receiver costs a load and a compare with no table +work at all, and the single lookup that supplies the slot bound doubles as the +liveness proof: a stamp with no live descriptor returns `None` exactly where +`object_shape_id`'s 0 made the token compare fail before. + +Verified by profile rather than by wall clock, because the effect is close to +the noise floor of the benchmark. In a computed-key write loop +`shape_descriptor_by_id` falls **7.01% → 3.49%** of self time and +`object_shape_id` (1.32%) leaves the profile entirely. + +Wall clock, interleaved A/B min-of-21 at quiet load, against the exact parent +commit: write-only loop 42 → 40 ms min (−4.8%), mean 46 → 45. The combined +overwrite loop and the read loop are unchanged, as expected for a change +confined to the write IC. Computed-key differential output is byte-identical +to node; suite 2779 passed, 0 failed. diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 0839d0423f..fffb7e9490 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -451,8 +451,11 @@ pub extern "C" fn js_put_value_set_ic_miss( return result; } + // The descriptor above already proves this stamp is live, so the + // token comes from the header word rather than from a second full + // lookup-and-copy of the same id (see `dyn_ic_try_store`). let shape_token = crate::object::shapes::PIC_ID_TOKEN_BIT - | crate::object::shapes::object_shape_id(obj) as u64; + | crate::object::shapes::object_shape_stamp(obj) as u64; // Publish the token last conceptually: a zero-initialized or stale // token cannot hit this slot until it matches this receiver's current @@ -712,12 +715,23 @@ unsafe fn dyn_ic_try_store(target: f64, token: u64, slot: u32, value: f64) -> Op { return None; } - let current_token = crate::object::shapes::PIC_ID_TOKEN_BIT - | crate::object::shapes::object_shape_id(obj) as u64; - if current_token != token { + // ONE descriptor lookup, not two. `object_shape_id` runs a full lookup — + // and copies the whole `ShapeDescriptor` out of the table — purely to + // prove the stamped id is live, then throws the descriptor away; the bound + // check below then looked the SAME id up again. `shape_descriptor_by_id` + // was 10.5% of self time in a computed-key write loop, the single largest + // item, and half of that was this duplicate. + // + // Read the stamp straight off the header, reject on the token first (a + // load and a compare, so a wrong-shape receiver costs no table work at + // all), and let the one lookup that supplies the bound double as the + // liveness proof: a stamp with no live descriptor returns `None` here, + // exactly as `object_shape_id`'s 0 made the token compare fail before. + let stamp = crate::object::shapes::object_shape_stamp(obj); + if (crate::object::shapes::PIC_ID_TOKEN_BIT | stamp as u64) != token { return None; } - let shape = crate::object::shapes::object_shape_descriptor(obj)?; + let shape = crate::object::shapes::shape_descriptor_by_id(stamp)?; if slot >= shape.live_inline_slot_count { // Overflow slot. The token compare above already proved the receiver // is in the exact shape the (key → slot) pair was learned in, so the @@ -877,8 +891,11 @@ pub extern "C" fn js_put_value_set_dyn_ic_miss( let Some(idx) = own_idx else { return result; }; + // The descriptor above already proves this stamp is live, so the + // token comes from the header word rather than from a second full + // lookup-and-copy of the same id (see `dyn_ic_try_store`). let shape_token = crate::object::shapes::PIC_ID_TOKEN_BIT - | crate::object::shapes::object_shape_id(obj) as u64; + | crate::object::shapes::object_shape_stamp(obj) as u64; let key_bits = key.to_bits() as i64; // Preserve the empty-way sentinel invariant: never prime bits 0 // (only the JS number 0 has them, and numeric keys cannot prime