From 31473aada9fc0a497988f8375f249d80b37b9fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 09:32:05 +0200 Subject: [PATCH 1/3] perf(runtime): lower INLINE_SLOT_FLOOR 4 -> 2 (#7916) --- .../src/expr/property_get/generic_dispatch.rs | 23 ++--- .../src/expr/property_get/tests.rs | 12 +-- .../perry-codegen/src/expr/proxy_reflect.rs | 2 +- .../perry-codegen/src/lower_call/new_alloc.rs | 6 +- crates/perry-codegen/src/target_layout.rs | 52 +++++++++++ .../gc/tests/copying/pointer_publish_7154.rs | 5 +- crates/perry-runtime/src/json/mod.rs | 2 +- .../src/json/stringify_shape_template.rs | 10 ++- crates/perry-runtime/src/object/alloc.rs | 10 ++- crates/perry-runtime/src/object/mod.rs | 29 +++++- crates/perry-runtime/src/object/tests.rs | 90 +++++++++++++++++++ 11 files changed, 210 insertions(+), 31 deletions(-) diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 86253accf1..e24b927ff1 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -18,9 +18,10 @@ use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; /// **Must equal `perry_runtime::object::field_get_set::PIC_CACHE_WORDS`** — /// the runtime writes this memory through a `*mut [i64; PIC_CACHE_WORDS]`, so a /// smaller global here is an out-of-bounds store. perry-codegen does not depend -/// on perry-runtime (the same reason `INLINE_SLOT_FLOOR` is spelled `4` inline -/// below), so the pairing is held by `pic_cache_layout_matches_runtime` here and -/// `pic_cache_words_match_codegen` in the runtime: change one and both fail. +/// on perry-runtime (the same reason `INLINE_SLOT_FLOOR` is duplicated in +/// `target_layout`), so the pairing is held by `pic_cache_layout_matches_runtime` +/// here and `pic_cache_words_match_codegen` in the runtime: change one and both +/// fail. pub(crate) const PIC_CACHE_WORDS: usize = 12; /// First word of the polymorphic way array (words 0..2 are the MRU entry and /// word 3 is the gate). Mirrors the runtime's `PIC_WAY_BASE`. @@ -40,13 +41,15 @@ pub(crate) const PIC_WAY_STATE: usize = 3; /// Spelled as the equivalent disjunction `slot < FLOOR || slot < field_count` /// rather than as a `max` followed by one compare. The predicate is identical /// for every input (`x < max(a, b)` ⟺ `x < a ∨ x < b`), but the `max` had to be -/// materialised — `mov w, #4` / `cmp` / `csel` — and that `csel` was the single -/// hottest instruction in `interp.ts` (4.65% of `evalNode`, #7907), because it -/// sits on the dependency chain out of the `field_count` load. The disjunction -/// has no such node: LLVM folds the pair into `cmp` + `ccmp`, and the -/// `slot < 4` half does not depend on the load at all. +/// materialised — `mov w, #FLOOR` / `cmp` / `csel` — and that `csel` was the +/// single hottest instruction in `interp.ts` (4.65% of `evalNode`, #7907), +/// because it sits on the dependency chain out of the `field_count` load. The +/// disjunction has no such node: LLVM folds the pair into `cmp` + `ccmp`, and +/// the `slot < FLOOR` half does not depend on the load at all. fn emit_slot_in_bounds(ctx: &mut FnCtx<'_>, slot: &str, field_count: &str) -> String { - let below_floor = ctx.block().icmp_ult(I64, slot, "4"); // INLINE_SLOT_FLOOR + let below_floor = ctx + .block() + .icmp_ult(I64, slot, crate::target_layout::INLINE_SLOT_FLOOR_LIT); let below_count = ctx.block().icmp_ult(I64, slot, field_count); ctx.block().or(I1, &below_floor, &below_count) } @@ -535,7 +538,7 @@ pub(crate) fn lower_generic_property_get( // slots live in its OVERFLOW map) — a slot primed from a // larger-capacity sibling must not drive a raw load past this // receiver's field region. `alloc_limit = max(field_count, - // INLINE_SLOT_FLOOR=4)` mirrors the miss handler's cacheability + // INLINE_SLOT_FLOOR)` mirrors the miss handler's cacheability // rule; an out-of-bounds slot falls to the miss path, which reads // the overflow map correctly (and records the guard failure — // `record_guard_pass` only fires after the bounds check passes). diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 6d66c6502b..0a3c8ff5e4 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -332,16 +332,18 @@ fn pic_miss_reuses_the_token_blocks_values_instead_of_re_deriving_them() { /// notices. #[test] fn cached_slot_bound_is_a_disjunction_not_a_materialised_max() { + let floor = crate::target_layout::INLINE_SLOT_FLOOR_LIT; let ir = emit(false, None); assert!( - ir.contains("icmp ult i64 ") && ir.contains(", 4"), + ir.lines() + .any(|l| l.contains("icmp ult i64 ") && l.ends_with(&format!(", {floor}"))), "test premise: the emitted bound compares a slot against \ - INLINE_SLOT_FLOOR:\n{ir}" + INLINE_SLOT_FLOOR ({floor}):\n{ir}" ); assert!( - !ir.contains(", i64 4, i64 %"), - "a `select …, i64 4, i64 %fc` is the materialised max this deliberately \ - does not emit:\n{ir}" + !ir.contains(&format!(", i64 {floor}, i64 %")), + "a `select …, i64 {floor}, i64 %fc` is the materialised max this \ + deliberately does not emit:\n{ir}" ); } diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index de7e5d8389..14ffe76ce4 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -961,7 +961,7 @@ fn lower_put_value_dyn_ic_inline( /// perry-runtime `object::INLINE_SLOT_FLOOR` (the runtime pads every object /// to at least this many physical slots; a codegen value larger than the /// runtime's would widen inline stores into unallocated memory). -const INLINE_SLOT_FLOOR_LIT: &str = "4"; +const INLINE_SLOT_FLOOR_LIT: &str = crate::target_layout::INLINE_SLOT_FLOOR_LIT; fn static_write_key(ctx: &FnCtx<'_>, key: &Expr) -> Option { match key { diff --git a/crates/perry-codegen/src/lower_call/new_alloc.rs b/crates/perry-codegen/src/lower_call/new_alloc.rs index dce17528f3..7073f7354f 100644 --- a/crates/perry-codegen/src/lower_call/new_alloc.rs +++ b/crates/perry-codegen/src/lower_call/new_alloc.rs @@ -371,8 +371,10 @@ fn emit_instance_alloc_inner( // Inline-slot floor — MUST match perry-runtime `object::INLINE_SLOT_FLOOR` // (they independently pad `new` objects to the same minimum; a mismatch // where codegen allocs fewer slots than the runtime's get/set bound-check - // assumes is heap corruption). Lowered 8->4 to shrink small-object footprint. - const MIN_FIELD_SLOTS: u64 = 4; + // assumes is heap corruption). Single source of truth, paired with the + // runtime by `target_layout::tests::inline_slot_floor_matches_runtime`. + // Lowered 8->4 (#6712) then 4->2 (#7916) to shrink small-object footprint. + const MIN_FIELD_SLOTS: u64 = crate::target_layout::INLINE_SLOT_FLOOR; const GC_TYPE_OBJECT: u64 = 2; const GC_FLAG_ARENA: u64 = 0x02; // PR #1146: pointer-free hint for inline-allocated regular diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index b2e336034e..136957188d 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -46,10 +46,62 @@ pub fn object_header_size_bytes(target_triple: &str) -> u64 { } } +/// Minimum number of inline field slots `perry-runtime` allocates for EVERY +/// object, mirroring `perry_runtime::object::INLINE_SLOT_FLOOR`. +/// +/// perry-codegen deliberately does not depend on perry-runtime (the same reason +/// `PIC_CACHE_WORDS` is duplicated), so the pairing is held by +/// `inline_slot_floor_matches_runtime` here and +/// `inline_slot_floor_matches_codegen` in `perry-runtime/src/object/tests.rs`: +/// change one and both fail. +/// +/// Two independent consumers, with OPPOSITE failure modes — which is why they +/// must share one constant rather than two spellings of the same digit: +/// +/// - **`lower_call/new_alloc.rs`** sizes the inline-`new` bump allocation as +/// `max(field_count, INLINE_SLOT_FLOOR)` slots. A value SMALLER than the +/// runtime's makes the runtime's bound checks admit slots the emitted +/// allocation never reserved → writes into the neighbouring arena object. +/// - **the emitted property bounds checks** (`expr/property_get`, +/// `expr/proxy_reflect`) gate a raw inline slot load/store on +/// `slot < max(field_count, INLINE_SLOT_FLOOR)`. A value LARGER than the +/// runtime's widens those raw accesses past the allocation. +/// +/// So codegen must be exactly equal, not conservatively either way. +pub const INLINE_SLOT_FLOOR: u64 = 2; + +/// `INLINE_SLOT_FLOOR` as the string literal the IR emitters splice in. +pub const INLINE_SLOT_FLOOR_LIT: &str = "2"; + #[cfg(test)] mod tests { use super::*; + /// Paired with `inline_slot_floor_matches_codegen` in + /// `perry-runtime/src/object/tests.rs` (#7916). + #[test] + fn inline_slot_floor_matches_runtime() { + assert_eq!( + INLINE_SLOT_FLOOR, 2, + "perry-runtime's object::INLINE_SLOT_FLOOR is 2; update both sides together" + ); + assert_eq!( + INLINE_SLOT_FLOOR_LIT, + INLINE_SLOT_FLOOR.to_string(), + "the spliced literal must be the constant" + ); + // The inline-`new` allocation is `GcHeader + ObjectHeader + 8 * slots` + // and the bump allocator's offset invariant requires a multiple of 8. + for triple in ["aarch64-apple-darwin", "arm64_32-apple-watchos"] { + let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR; + assert_eq!( + total % 8, + 0, + "{triple}: floor-sized allocation must be 8-aligned" + ); + } + } + #[test] fn object_header_size_matches_pointer_width() { // 64-bit targets: 4×u32 + two 8-byte-aligned pointers (keys_array + diff --git a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs index feadbd8b99..78454322ad 100644 --- a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs +++ b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs @@ -273,9 +273,10 @@ fn test_fresh_closure_capture_slots_are_initialized_7154() { /// `object/field_set_by_name/tail.rs`'s two "#7154 publication order" sites. /// /// `perry_ffi::alloc_object()` calls `js_object_alloc(0, 0)`: `field_count = -/// 0` with `INLINE_SLOT_FLOOR` (4) physical slots undefined-initialized. +/// 0` with `INLINE_SLOT_FLOOR` physical slots undefined-initialized. /// `js_object_set_field(obj, 0, pointer_value)` passes the bounds check -/// (`0 < max(field_count, 4)`) and stores the pointer, but — unlike +/// (`0 < max(field_count, INLINE_SLOT_FLOOR)`) and stores the pointer, but — +/// unlike /// `tail.rs`'s by-name writer — never bumps `field_count`. The collector's /// view of the payload (`object::gc_field_slot_range`, and downstream /// `heap_payload_slot_selection`'s `payload.is_empty()` short-circuit) is diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index b28c0e209a..c64d190f89 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -1375,7 +1375,7 @@ mod tests { // The array fast path built its shape template with // `min(keys_len, field_count)`. `field_count` is PHYSICAL — it never // exceeds the object's inline slot allocation, so an object grown by - // name past `INLINE_SLOT_FLOOR` reports the floor (4) while the + // name past `INLINE_SLOT_FLOOR` reports the floor while the // remaining values live in overflow storage. `JSON.parse`'s tape // materializer produces exactly that shape (`js_object_alloc(0, 0)` + // `js_object_set_field_by_name` per key), so `JSON.stringify` of a diff --git a/crates/perry-runtime/src/json/stringify_shape_template.rs b/crates/perry-runtime/src/json/stringify_shape_template.rs index aaf06dd61d..3f5e26b567 100644 --- a/crates/perry-runtime/src/json/stringify_shape_template.rs +++ b/crates/perry-runtime/src/json/stringify_shape_template.rs @@ -172,10 +172,12 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option // every property by name, so a 6-key record reports `field_count == 4`. // // The old `min(keys_len, field_count)` therefore truncated EVERY element of - // a homogeneous array to the first 4 properties with no diagnostic — silent - // data loss in `JSON.stringify(JSON.parse(x))` (#7264). Latent since the - // template landed (v0.5.65); exposed for ordinary 5–8-field records when - // #6712 lowered `INLINE_SLOT_FLOOR` from 8 to 4. + // a homogeneous array to the first `INLINE_SLOT_FLOOR` properties with no + // diagnostic — silent data loss in `JSON.stringify(JSON.parse(x))` (#7264). + // Latent since the template landed (v0.5.65); exposed for ordinary + // 5–8-field records when #6712 lowered `INLINE_SLOT_FLOOR` from 8 to 4 + // (#7916 then lowered it again to 2, which is why this must never go back + // to reading `field_count`). // // `min` was never needed for the opposite skew either: a pre-sized object // (`js_object_alloc(0, 8)` holding 2 real keys) has `field_count > keys_len`, diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 831ab131b4..deb1a624f8 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -127,10 +127,12 @@ pub extern "C" fn js_object_alloc_with_parent( } let header_size = std::mem::size_of::(); - // Allocate at least 8 field slots to match js_object_set_field_by_name's alloc_limit - // assumption (max(field_count, 8)). Without this, empty objects ({}) with field_count=0 - // would have 0 field slots but js_object_set_field_by_name writes up to 8 fields inline, - // causing heap buffer overflow into adjacent arena objects. + // Allocate at least INLINE_SLOT_FLOOR field slots to match + // js_object_set_field_by_name's alloc_limit assumption + // (max(field_count, INLINE_SLOT_FLOOR)). Without this, empty objects ({}) + // with field_count=0 would have 0 field slots but + // js_object_set_field_by_name writes up to the floor inline, causing a heap + // buffer overflow into adjacent arena objects. let alloc_field_count = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); let fields_size = alloc_field_count * std::mem::size_of::(); let total_size = header_size + fields_size; diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 6019711722..17e0489348 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -20,8 +20,33 @@ use std::sync::RwLock; /// every field get/set bounds check, and every direct-slot read MUST use the /// SAME floor, or a write/read past the allocated slots corrupts the heap. It is /// centralized here so all sites move in lockstep. (Also mirrored in -/// perry-codegen `lower_call/new.rs` MIN_FIELD_SLOTS for the PERRY_INLINE_NEW path.) -pub(crate) const INLINE_SLOT_FLOOR: usize = 4; +/// perry-codegen `lower_call/new_alloc.rs` MIN_FIELD_SLOTS for the inline-`new` +/// path and `expr::inline_slot_floor::INLINE_SLOT_FLOOR` for the emitted bounds +/// checks; paired by `inline_slot_floor_matches_codegen` here and +/// `inline_slot_floor_matches_runtime` there.) +/// +/// # Why this number is a footprint dial, not a safety one (#7916) +/// +/// It is *the* padding term in a small object's size: +/// `8 (GcHeader) + 32 (ObjectHeader) + 8 * max(field_count, INLINE_SLOT_FLOOR)`. +/// At 4, a two-field literal `{a, b}` costs **72 bytes to store 16 bytes of +/// payload**, of which 16 bytes are slots 2–3 that the shape can never use — +/// `gc-handoff/bench/retain.ts` writes 216 MB to store 48 MB of doubles. +/// +/// Lowering it is sound at any value because `field_count` is *capped* by the +/// same expression it feeds: the by-name append path +/// (`field_set_by_name/tail.rs`) only bumps `field_count` for a slot it placed +/// INLINE, and anything at or past `alloc_limit` spills to overflow storage +/// instead. So `alloc_limit` is a fixed point of the allocation — it can never +/// grow past the physical slot count — and the floor is purely a +/// *growth-headroom* dial for objects that gain properties by name after birth. +/// (#6712 moved it 8 → 4 on the same reasoning; #7916 moved it 4 → 2.) +/// +/// 2 rather than 1 or 0: those three are indistinguishable in footprint for +/// every shape in the perf corpus (a 2-field literal allocates 2 slots under +/// all of them), so 2 is chosen as the one that keeps the most inline headroom +/// for a dynamically-grown `{}` at zero byte cost. +pub(crate) const INLINE_SLOT_FLOOR: usize = 2; // Submodules (issue #1103): behavior-preserving split of the former // 11.2k-line object.rs. Public re-exports keep FFI symbols stable. diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index f89562ac9a..83526520d2 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -618,6 +618,96 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { } } +/// #7916: the per-object footprint accounting this issue is about, pinned as an +/// executable fact rather than a comment. +/// +/// A two-field object literal is `GcHeader (8) + ObjectHeader (32) + 8 * +/// max(field_count, INLINE_SLOT_FLOOR)`. At `INLINE_SLOT_FLOOR = 4` that is +/// **72 bytes to store 16 bytes of payload** and `gc-handoff/bench/retain.ts` +/// writes 216 MB to hold 48 MB of doubles. Lowering the floor to 2 removes the +/// two unusable slots. +/// +/// This reads the size the ALLOCATOR recorded (`GcHeader::size`), not a +/// recomputation of the same formula, so it fails if any allocation path +/// silently stops honouring the floor. +#[test] +fn two_field_literal_footprint_is_exactly_accounted() { + assert_eq!( + std::mem::size_of::(), + 32, + "the ObjectHeader half of the accounting: 4 u32 + 2 pointers" + ); + assert_eq!(crate::gc::GC_HEADER_SIZE, 8); + + let keys = b"a\0b\0"; + let obj = js_object_alloc_with_shape(0x7916_0001, 2, keys.as_ptr(), keys.len() as u32); + assert!(!obj.is_null()); + let recorded = unsafe { + let gc = (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + (*gc).size as usize + }; + let expected = crate::gc::GC_HEADER_SIZE + + std::mem::size_of::() + + 8 * std::cmp::max(2, crate::object::INLINE_SLOT_FLOOR); + assert_eq!( + recorded, expected, + "a 2-field literal must occupy exactly {expected} bytes" + ); + assert_eq!( + recorded, 56, + "#7916: the 2-field literal footprint is 56 bytes (was 72 at floor 4). \ + Raising INLINE_SLOT_FLOOR back to 4 re-adds 16 bytes of unusable slots \ + to every small object" + ); +} + +/// Paired with `inline_slot_floor_matches_runtime` in +/// `perry-codegen/src/target_layout.rs` (#7916). +/// +/// perry-codegen cannot depend on perry-runtime, so it carries its own copy of +/// this constant and uses it BOTH to size the inline-`new` bump allocation and +/// to emit `slot < max(field_count, FLOOR)` bounds checks around raw inline +/// slot loads/stores. The two failure modes point in opposite directions +/// (codegen too small under-allocates; codegen too large over-reads), so the +/// values must be exactly equal — pin the number on both sides. +#[test] +fn inline_slot_floor_matches_codegen() { + assert_eq!( + crate::object::INLINE_SLOT_FLOOR, + 2, + "perry-codegen's target_layout::INLINE_SLOT_FLOOR is 2; update both sides together" + ); +} + +/// #7916: lowering the floor must not change what `{}` + by-name growth does, +/// only where the inline/overflow boundary sits. Fields placed past the +/// boundary go to overflow storage and must still read back — the property +/// that makes the floor a footprint dial rather than a correctness one. +#[test] +fn by_name_growth_past_the_floor_reads_back() { + unsafe { + let obj = js_object_alloc(0, 0); + assert!(!obj.is_null()); + let names: [&[u8]; 6] = [b"k0", b"k1", b"k2", b"k3", b"k4", b"k5"]; + for (i, n) in names.iter().enumerate() { + let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); + js_object_set_field_by_name(obj, key, i as f64); + } + for (i, n) in names.iter().enumerate() { + let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); + let got = js_object_get_field_by_name(obj, key); + assert!( + got.is_number() && got.as_number() == i as f64, + "field {} ({}) read back as {:#x}; the inline/overflow boundary \ + must be invisible to reads", + i, + std::str::from_utf8(n).unwrap(), + got.bits() + ); + } + } +} + #[test] fn test_object_alloc_and_fields() { let obj = js_object_alloc(1, 3); From 7e83f7708598800c4dd154b3455e809c0997acf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 09:52:51 +0200 Subject: [PATCH 2/3] docs(changelog): #7916 inline-slot-floor fragment --- changelog.d/7928-inline-slot-floor.md | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 changelog.d/7928-inline-slot-floor.md diff --git a/changelog.d/7928-inline-slot-floor.md b/changelog.d/7928-inline-slot-floor.md new file mode 100644 index 0000000000..8a5f1dbcba --- /dev/null +++ b/changelog.d/7928-inline-slot-floor.md @@ -0,0 +1,60 @@ +### perf(runtime): right-size small objects — `INLINE_SLOT_FLOOR` 4 → 2 + +Closes the front half of #7916 and all of #7714. + +**The accounting.** A two-field object literal `{a: number, b: number}` occupied **72 bytes +to store 16 bytes of payload**: 8 `GcHeader` + 32 `ObjectHeader` (`object_type` 4, +`class_id` 4, `parent_class_id` 4, `field_count` 4, `keys_array` 8, `meta` 8) + 4 × 8 slot +bytes, of which only two slots are reachable. Alignment and capacity rounding contribute +**zero** — `ObjectHeader` is `#[repr(C)]` with no interior padding and +`gc_padded_total_size(64, 8)` finds `8 + 64` already 8-aligned. 22.2% of the allocation was +payload, 22.2% was the slot floor. `gc-handoff/bench/retain.ts` wrote 216 MB to store 48 MB +of doubles. + +**Why the floor is a dial, not a safety constant.** Its doc comment called it +corruption-critical and 55 runtime sites plus 3 codegen sites independently compute +`max(field_count, FLOOR)` as the inline/overflow boundary — but the by-name append path +(`field_set_by_name/tail.rs`) only bumps `field_count` for a slot it placed *inline*, and +spills anything at or past `alloc_limit` to overflow storage. `alloc_limit` is therefore a +fixed point of the allocation and can never grow past the physical slot count, at any +FLOOR ≥ 0. (#6712 moved it 8 → 4 on the same reasoning.) 2 rather than 1 or 0 because all +three are indistinguishable in footprint for every shape in the perf corpus, so 2 keeps the +most inline headroom for a dynamically-grown `{}` at zero byte cost. + +**Result.** `{}` / 1-field / 2-field literals 72 → **56 bytes**; 3-field 72 → **64**; ≥4 +fields unchanged (their overhead is entirely the two headers). `retain` writes 168 MB +instead of 216 MB — write amplification **4.5x → 3.5x**. Peak RSS (bit-exact run to run): +`tree` −18.6%, `retain` −15.3%, `retain1` −12.8%, `deeplist` −11.3%, everything else ≤0.4%. + +**The interaction worth knowing about.** `retain1` and `deeplist` retire 12–14% *more* +instructions, and none of it is mutator cost. Every minor GC in both arms fires at the same +byte mark and processes the same bytes — but 1.286 = 72/56 times as many *objects* +(`retain1` minor 1: 245 752 → 315 969 objects at 17 694 064 → 17 694 216 bytes). GC pause +39.60 → 50.10 ms, which exceeds the program's entire cycle delta: the mutator got faster and +the collector got slower, at an unchanged ~50 ns per promoted object. **The collector's +trigger is denominated in bytes; its cost is denominated in objects**, so every future +object-shrinking change is taxed back until the nursery/promotion budgets carry an +object-count term. Total promotion work is set by the surviving object count (unchanged), so +these microbenchmarks are seeing work pulled *forward* into their measurement window, not +created. The rest of the corpus moves the other way: `churn` −1.2%, `churn_alloc` −1.4%, +`push_cls` −1.4%, `tree` −0.8% instructions. + +**Codegen pairing.** perry-codegen carried two separately-spelled `4`s held together by a +comment, used for opposite purposes: sizing the inline-`new` bump allocation (too small → +writes past the allocation) and emitting the property bounds checks (too large → reads past +it). Both now read `target_layout::INLINE_SLOT_FLOOR`, paired with the runtime by +`inline_slot_floor_matches_runtime` / `inline_slot_floor_matches_codegen`, the mechanism +`PIC_CACHE_WORDS` already uses. + +**Validation.** 19/19 corpus programs byte-exact vs `node --experimental-strip-types` +26.5.1 with exit 0, and again under `PERRY_GC_PROTECT_FROMSPACE=1 +PERRY_GC_VERIFY_EVACUATION=1` (a layout change is GC-visible); `iso_miss` canary +`checksum 437840 misses 0`; gap suite; `cargo test --release -p perry-codegen +-p perry-runtime`. New tests: `two_field_literal_footprint_is_exactly_accounted` reads the +size the *allocator recorded* in `GcHeader::size` rather than recomputing the formula, so it +fails if any allocation path stops honouring the floor, and +`by_name_growth_past_the_floor_reads_back` pins that the inline/overflow boundary stays +invisible to reads. + +Full byte-level write-up and the projection for shrinking `ObjectHeader` itself: +`gc-handoff/REPR-NOTES.md`. From aebfcaab356f6e01c6e05a2925a99905c0ce6829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 10:00:52 +0200 Subject: [PATCH 3/3] test(codegen): derive the typed-shape header word from INLINE_SLOT_FLOOR (#7916) --- .../src/lower_call/typed_shape_bake_tests.rs | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index cbb6b49f86..3f932fdc08 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -52,18 +52,47 @@ const ANY_ATOMIC_LOAD: &str = "load atomic i32, ptr @PERRY_PER_OBJECT_LAYOUTS_ANY monotonic, align 4"; /// The packed `GcHeader` word the inline bump writes for a two-`number`-field -/// class, WITH the baked layout: +/// class: /// /// ```text -/// obj_type GC_TYPE_OBJECT = 0x02 bits 0..7 -/// gc_flags GC_FLAG_ARENA = 0x02 bits 8..15 -/// _reserved GC_LAYOUT_POINTER_FREE | INTACT = 0x5000 bits 16..31 -/// size 8 + 32 + max(2,4)*8 = 72 bits 32..63 +/// obj_type GC_TYPE_OBJECT = 0x02 bits 0..7 +/// gc_flags GC_FLAG_ARENA = 0x02 bits 8..15 +/// _reserved GC_LAYOUT_POINTER_FREE [| INTACT] = 0x4000 [| 0x1000] bits 16..31 +/// size 8 + 32 + max(2, INLINE_SLOT_FLOOR)*8 bits 32..63 /// ``` -const BAKED_HEADER_WORD: &str = "store i64 310579823106,"; -/// The same word WITHOUT `GC_OBJ_TYPED_LAYOUT_INTACT` (0x1000 << 16 less) — -/// what the pointer-bearing class still writes. -const UNBAKED_HEADER_WORD: &str = "store i64 310311387650,"; +/// +/// Computed from `INLINE_SLOT_FLOOR` rather than spelled as a literal: #7916 +/// moved the floor 4 → 2, which changes `size` 72 → 56 and therefore both +/// words. A hard-coded constant here fails the moment the footprint changes +/// and says nothing about what this test is actually for (whether +/// `GC_OBJ_TYPED_LAYOUT_INTACT` is claimed), so derive the part that is +/// incidental and keep asserting the part that is not. +fn header_word(intact: bool) -> String { + const GC_TYPE_OBJECT: u64 = 0x02; + const GC_FLAG_ARENA: u64 = 0x02; + const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; + const GC_OBJ_TYPED_LAYOUT_INTACT: u64 = 0x1000; + let slots = std::cmp::max(2, crate::target_layout::INLINE_SLOT_FLOOR); + let size = + 8 + crate::target_layout::object_header_size_bytes("aarch64-apple-darwin") + 8 * slots; + let reserved = GC_LAYOUT_POINTER_FREE + | if intact { + GC_OBJ_TYPED_LAYOUT_INTACT + } else { + 0 + }; + let word = (size << 32) | (reserved << 16) | (GC_FLAG_ARENA << 8) | GC_TYPE_OBJECT; + format!("store i64 {word},") +} + +/// The packed word WITH the baked `GC_OBJ_TYPED_LAYOUT_INTACT`. +fn baked_header_word() -> String { + header_word(true) +} +/// The same word WITHOUT it — what the pointer-bearing class still writes. +fn unbaked_header_word() -> String { + header_word(false) +} fn ir_opts() -> CompileOptions { CompileOptions { @@ -318,7 +347,7 @@ pub(super) fn emit(m: &Module) -> String { fn a_pointer_free_shape_bakes_its_layout_into_the_header_constant() { let ir = emit(&loop_new_module("Pair", Type::Number, Expr::Integer(2))); assert!( - ir.contains(BAKED_HEADER_WORD), + ir.contains(&baked_header_word()), "the inline-bump header constant does not carry \ GC_OBJ_TYPED_LAYOUT_INTACT, so the bake did not fire and every \ construction still pays the runtime declare:\n{ir}" @@ -354,7 +383,7 @@ fn a_pointer_bearing_shape_keeps_the_runtime_declare() { lookup reads:\n{ir}" ); assert!( - ir.contains(UNBAKED_HEADER_WORD) && !ir.contains(BAKED_HEADER_WORD), + ir.contains(&unbaked_header_word()) && !ir.contains(&baked_header_word()), "the header constant must NOT claim GC_OBJ_TYPED_LAYOUT_INTACT for a \ shape whose descriptor is installed at runtime:\n{ir}" );