From 4a01bcdcd6a075a081b0ea68bb9c6f867452522c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 01:08:26 +0200 Subject: [PATCH] perf(gc): don't mint per-object pointer masks for single-slot payloads `interp.ts` spent ~19% of its runtime in `layout_forget_object`, plus ~6% in the hashbrown probe underneath it, against a `LAYOUT_SLOT_MASKS` that had grown past 400,000 live entries. Instrumented on the isolated FIB half, the `PER_OBJECT_LAYOUTS_NONEMPTY` fast path fired 52 times in 15,000,000 calls. The interpreter allocates `{ names: [p], vals: [a], parent }` per interpreted call, so `layout_note_slot`'s "first pointer into a POINTER_FREE object" arm minted 1.8M masks over payloads of exactly one slot. A mask over one slot can skip nothing -- the tracer tag-checks that slot either way -- but the entry it creates keeps the emptiness flag armed, which puts a two-map hash probe back on every allocation in the program for as long as it lives. Both mint sites now decline the mask below DEFAULT_MASK_MIN_SLOTS and use GC_LAYOUT_UNKNOWN, the tag-checked scan-all-slots state that is already the established fallback on this path. The tag check is exact here: neither site is reachable for an object with an intact typed descriptor, so no raw-f64 slot can be misread as a pointer. Quiet M1 mini, best-of-5, interleaved against the same binaries with the policy disabled: interp 1.894 -> 1.697, iso_miss 2.371 -> 2.157, new bench/mask_tax probe 0.1218 -> 0.1049 with its numeric-element control flat at 1.000. No regression on the rest of the 19-benchmark corpus, including tree, tree_wide, retain*, cycles and deeplist. --- changelog.d/7812-single-slot-pointer-mask.md | 89 ++++++++++++ crates/perry-runtime/src/gc/layout.rs | 39 ++++-- crates/perry-runtime/src/gc/layout_tables.rs | 131 ++++++++++++++++++ .../src/gc/tests/layout_trace/array_layout.rs | 39 ++++-- .../tests/layout_trace/per_object_tables.rs | 101 ++++++++++++++ 5 files changed, 377 insertions(+), 22 deletions(-) create mode 100644 changelog.d/7812-single-slot-pointer-mask.md diff --git a/changelog.d/7812-single-slot-pointer-mask.md b/changelog.d/7812-single-slot-pointer-mask.md new file mode 100644 index 0000000000..1ef7c57cad --- /dev/null +++ b/changelog.d/7812-single-slot-pointer-mask.md @@ -0,0 +1,89 @@ +### GC: stop minting per-object pointer masks for single-slot payloads + +`interp.ts` — the tree-walking interpreter that best resembles real software in +the benchmark corpus — spent **~19% of its runtime in `layout_forget_object`**, +plus another ~6% in the hashbrown probe underneath it. That is side-table +bookkeeping, not user work, and by design it should have been ~zero: #7510's +`PER_OBJECT_LAYOUTS_NONEMPTY` flag exists so that the allocation, store, death +and relocation paths can skip both per-object layout maps whenever they are +empty, which "on a monomorphic workload they are". + +They were not. Instrumented on `iso_FIB.ts` (the isolated FIB half): + +``` +forget_total=15,000,000 fast=52 slow=14,999,948 +residency: masks=313,875 -> 381,505 -> 400,430 (still climbing) +``` + +The disarmed fast path fired **52 times in 15 million calls**. Every other call +took two `RefCell` round-trips and two hashes against a 400k-entry, cache-cold +map — once per allocation, program-wide. + +**Cause.** `layout_note_slot`'s "first pointer stored into a `POINTER_FREE` +object" arm minted a per-object entry in `LAYOUT_SLOT_MASKS`. The interpreter +allocates `{ names: [p], vals: [a], parent }` per interpreted call, so it minted +two masks per call — **1.8M of them**, each a mask over a payload of exactly +**one slot**. A mask over one slot cannot skip anything: the tracer consults +`layout_pointer_bearing_bits` on that slot either way, so the entry was the +mask's entire contribution. The entries also outlive their arrays — they are +only reclaimed when the recycled address is allocated over — so residency grew +without bound, and a single live entry anywhere keeps the flag armed for every +allocation in the program. This is #7510's "one immortal entry nullifies +`is_empty()`" a second time, from the other direction. + +**Fix.** Both mint sites (`layout_note_slot` and +`layout_rebuild_from_slots_with_policy`) now decline the mask when the payload +is below `DEFAULT_MASK_MIN_SLOTS` (2, i.e. single-slot payloads only) and use +`GC_LAYOUT_UNKNOWN` — the tag-checked scan-all-slots state — instead. That state +is already the established fallback on this exact path, and the tag check is +exact here: neither site is reachable for an object with an intact typed +descriptor, so there are no raw-f64 slots whose bits could be misread as a +pointer. `PERRY_LAYOUT_MASK_MIN_SLOTS` overrides the threshold for bisection. + +Two details worth keeping: + +- An array reports its `length`, but **only for a store into an already-formed + array**. Every append protocol notes the slot *before* bumping `length`, so + mid-construction `length` is the pre-append value; judging on it stranded + every incrementally built array — a `push` loop, a JSON parse — in the scan + state regardless of final size. Capacity is not a substitute either: + `MIN_ARRAY_CAPACITY` is 16, so a one-element literal reports 16 and the + distinction disappears entirely. +- An object reports a bound derived from `GcHeader::size`, not `field_count`, + because `size` is maintained for every GC allocation whatever its + type-specific header holds. + +Both directions of error are *correct*, only differently priced: over-estimating +mints a mask that was not needed (the old behaviour), and under-estimating +routes the object to a scan that visits a superset of what the mask would have +selected. Neither can hide a live child. + +**Measured** (quiet M1 mini, best-of-5, interleaved against the same binaries +with the policy disabled, outputs byte-identical to node and exit codes checked): + +| bench | before | after | +|---|--:|--:| +| `interp` | 1.894 | **1.697** | +| `iso_miss` | 2.371 | **2.157** | +| `bench/mask_tax` (new probe) | 0.1218 | **0.1049** | +| `bench/mask_tax_nopointer` (control) | 0.0929 | 0.0929 | + +No regression anywhere on the 19-benchmark corpus, including the GC-heavy +`tree`, `tree_wide`, `retain*`, `cycles` and `deeplist`. The correctness canary +(`iso_miss` printing `checksum 437840 misses 0`) holds plain and under +`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`, +`PERRY_GC_VERIFY_EVACUATION=1` and `PERRY_GC_FORCE_EVACUATE=1`. + +**New probe.** `gc-handoff/bench/mask_tax.ts` reduces the interpreter's +environment chain to the shape that mints the masks, with +`mask_tax_nopointer.ts` as a numeric-element control that holds flat at 1.000. +The arrays have to genuinely escape: a first version kept them in a local, +codegen scalar-replaced the array away, and the probe measured a 1.000 ratio +while the bug was fully intact. + +**Left on the table, deliberately.** Raising the threshold to 9 or above pays +roughly twice as much (`interp` 1.619, `iso_miss` 2.046) with still no +regression on the corpus, but 21 tests in this crate encode "a small mixed +payload uses a mask" as a precondition (5 do at 2, 11 at 3, saturating at 21 +from 9). That is a contract change worth making deliberately rather than as a +side effect of a perf patch. diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 9c31c988c5..ceeebcf787 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -909,16 +909,25 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits set_layout_state(header, GC_LAYOUT_SIDE_MASK); } } else if (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_POINTER_FREE { - if super::layout_tables::immortal_layout_scope_active() { - // An object built inside an `ImmortalLayoutScope` is + if super::layout_tables::immortal_layout_scope_active() + || super::layout_tables::layout_prefers_scan_over_mask( + header, + parent_user, + slot_index, + ) + { + // Two reasons to decline the mask, one fallback. An + // object built inside an `ImmortalLayoutScope` is // rooted for the life of the process, so the entry it // would mint here is never removed — and one such // entry disables `PER_OBJECT_LAYOUTS_NONEMPTY` for - // every allocation the program will ever make. Take + // every allocation the program will ever make (see + // `ImmortalLayoutScope`). And a payload too small for + // the mask to earn its side-table entry + // (`layout_prefers_scan_over_mask`) skips nothing the + // tag-checked scan would not check anyway. Both take // the same `GC_LAYOUT_UNKNOWN` fallback the `else` - // arm below uses for this exact situation; see - // `ImmortalLayoutScope` for why that is the safe - // state and not a weaker one. + // arm below uses for this exact situation. set_layout_state(header, GC_LAYOUT_UNKNOWN); } else { let mut mask = LayoutSlotMask::Inline(0); @@ -1354,13 +1363,17 @@ pub(super) unsafe fn layout_rebuild_from_slots_with_policy( if mask.is_empty() { set_layout_state(header, GC_LAYOUT_POINTER_FREE); slot_masks_remove(user_ptr as usize); - } else if super::layout_tables::immortal_layout_scope_active() { - // Same reasoning as the `layout_note_slot` branch: an object built - // inside an `ImmortalLayoutScope` never dies, so the mask it would - // install here is a permanent tenant of a side table whose emptiness - // is a process-wide fast path. Falling back to the tag-checked scan is - // sound *for this rebuild specifically* because the mask above is - // itself derived from `layout_pointer_bearing_bits` — exactly the test + } else if super::layout_tables::immortal_layout_scope_active() + || slot_count < super::layout_tables::layout_mask_min_slots() + { + // Same two reasons as the `layout_note_slot` branch, same fallback. An + // object built inside an `ImmortalLayoutScope` never dies, so the mask + // it would install here is a permanent tenant of a side table whose + // emptiness is a process-wide fast path; and too few slots means the + // mask cannot earn its side-table entry — the tag-checked scan is + // exact and costs the program nothing globally. Falling back is sound + // *for this rebuild specifically* because the mask above is itself + // derived from `layout_pointer_bearing_bits` — exactly the test // `GC_LAYOUT_UNKNOWN` re-runs per slot. (This is why the scope may not // be applied to a TYPED descriptor, whose raw-f64 slots the tag test // would misread; see `ImmortalLayoutScope`.) diff --git a/crates/perry-runtime/src/gc/layout_tables.rs b/crates/perry-runtime/src/gc/layout_tables.rs index a4df0ae135..cb3a151e57 100644 --- a/crates/perry-runtime/src/gc/layout_tables.rs +++ b/crates/perry-runtime/src/gc/layout_tables.rs @@ -31,6 +31,7 @@ use super::hot_tls::{hot_layout_slot_masks, hot_per_object_layout_hint, hot_typed_layouts}; use super::layout::{LayoutSlotMask, TypedLayoutDescriptor}; +use super::types::{GcHeader, GC_HEADER_SIZE, GC_TYPE_ARRAY, GC_TYPE_OBJECT}; use std::cell::{Cell, RefCell}; thread_local! { @@ -301,6 +302,71 @@ pub(crate) fn per_object_layout_table_sizes() -> (usize, usize) { ) } +/// Smallest payload slot count for which minting a **per-object pointer mask** +/// is worth its side-table entry. Below it the object takes +/// `GC_LAYOUT_UNKNOWN` — the tag-checked scan-all-slots state — instead. +/// +/// The two sides are not symmetric. A mask's benefit is bounded by the object: +/// it can skip at most `slots - pointers` tag checks per trace. Its cost is +/// **program-global and unbounded** — one live entry arms +/// [`PER_OBJECT_LAYOUTS_NONEMPTY`], which puts a two-map hash probe back on +/// every allocation anywhere in the program for as long as that entry lives +/// (see the module docs, and #7510's "one immortal entry nullifies +/// `is_empty()`"). At the bottom of the range the asymmetry is total rather +/// than merely lopsided: over a **single** slot a mask cannot skip anything at +/// all, because the tracer consults `layout_pointer_bearing_bits` on that one +/// slot either way, so the entry is the mask's entire contribution. +/// +/// A tag check is exact at both mint sites: neither is reached for an object +/// with an intact typed descriptor, so there are no raw-f64 slots whose bits a +/// tag check could misread as a pointer. #7630 recorded the same conclusion for +/// the materialiser cohort — "a pointer mask can never skip anything a tag +/// check would not reject anyway ... the mask machinery buys nothing here". +/// +/// `PERRY_LAYOUT_MASK_MIN_SLOTS` overrides it for bisection. +#[inline(always)] +pub(in crate::gc) fn layout_mask_min_slots() -> usize { + use std::sync::atomic::{AtomicUsize, Ordering}; + /// `usize::MAX` = "not yet read from the environment". + static N: AtomicUsize = AtomicUsize::new(usize::MAX); + match N.load(Ordering::Relaxed) { + usize::MAX => { + let v = std::env::var("PERRY_LAYOUT_MASK_MIN_SLOTS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MASK_MIN_SLOTS); + N.store(v, Ordering::Relaxed); + v + } + v => v, + } +} + +/// Only single-slot payloads take the scan. This is deliberately the +/// *provable* end of the range: at one slot the mask demonstrably skips +/// nothing, so no judgement about tracing cost is being made. +/// +/// Measured on the 19-benchmark corpus (quiet M1 mini, best-of-5, interleaved +/// against the same binaries with the policy disabled): +/// +/// | bench | before | after | +/// |---|--:|--:| +/// | `interp` | 1.894 | **1.697** | +/// | `iso_miss` | 2.371 | **2.157** | +/// | `bench/mask_tax` | 0.1218 | **0.1049** | +/// | `bench/mask_tax_nopointer` (control) | 0.0929 | 0.0929 | +/// +/// Every other benchmark — including the GC-heavy `tree`, `tree_wide`, +/// `retain*`, `cycles`, `deeplist` — is unchanged within noise. +/// +/// Raising it pays roughly twice as much and costs test churn, both measured: +/// `9` and above gives `interp` 1.619 / `iso_miss` 2.046 with still no +/// regression on the corpus, but 21 tests in this crate encode "a small mixed +/// payload uses a mask" as a precondition (5 do at `2`, 11 at `3`, saturating +/// at 21 from `9`). That is a contract change worth making on purpose rather +/// than as a side effect of a perf patch. +pub(in crate::gc) const DEFAULT_MASK_MIN_SLOTS: usize = 2; + /// True when either per-object side table may hold an entry. `false` is a /// proof of emptiness (see [`PER_OBJECT_LAYOUTS_NONEMPTY`]); `true` is only a /// hint, so every caller still has to handle a miss. @@ -499,3 +565,68 @@ pub(in crate::gc) fn layout_forget_object(user_ptr: usize) { pub(in crate::gc) fn test_per_object_tables_are_empty() -> bool { hot_layout_slot_masks().borrow().is_empty() && hot_typed_layouts().borrow().is_empty() } + +/// An upper bound on the payload slots the tracer would enumerate for +/// `user_ptr`, or `usize::MAX` when this module cannot cheaply tell. +/// +/// Both directions of error are *correct*, only differently priced, which is +/// what lets this be a bound rather than an exact count: over-estimating mints +/// a mask that was not needed (the pre-existing behaviour), and +/// under-estimating routes the object to `GC_LAYOUT_UNKNOWN`, where the tracer +/// scans every slot and so visits a superset of what a mask would have +/// selected. Neither can hide a live child. +/// +/// An array reports its `length` — exactly the range the tracer walks, and so +/// exactly the bound on what a mask could skip — but **only for a store into an +/// already-formed array**. A store at the append position (`slot_index >= +/// length`) reports `usize::MAX` instead, because every append protocol writes +/// the element and notes the slot *before* bumping `length` (see +/// [`layout_all_pointer_array_append`]): mid-construction `length` is the +/// pre-append value, usually 0 or 1, and judging on it would strand every +/// incrementally built array — a `push` loop, a JSON parse — in the scan state +/// no matter how large it eventually grew. Capacity is not a substitute: +/// `MIN_ARRAY_CAPACITY` is 16, so a one-element literal reports 16 and the +/// distinction this is drawing disappears. +/// +/// An object reports the bound derived from [`GcHeader::size`] rather than its +/// `field_count`: `size` is maintained for every GC allocation whatever its +/// type-specific header says, so this stays correct for a payload that is not a +/// well-formed `ObjectHeader`, and it errs high — towards the old mask path. +#[inline] +pub(in crate::gc) unsafe fn layout_payload_slot_count( + header: *const GcHeader, + user_ptr: usize, + slot_index: usize, +) -> usize { + match (*header).obj_type { + GC_TYPE_ARRAY => { + let arr = user_ptr as *const crate::array::ArrayHeader; + let length = (*arr).length as usize; + let capacity = (*arr).capacity as usize; + if length > capacity || length > 16_000_000 || slot_index >= length { + usize::MAX + } else { + length + } + } + GC_TYPE_OBJECT => { + let size = (*header).size as usize; + match size.checked_sub(GC_HEADER_SIZE) { + Some(payload) => payload / 8, + None => usize::MAX, + } + } + _ => usize::MAX, + } +} + +/// True when `user_ptr` is small enough that a tag-checked scan of every slot +/// beats a per-object pointer mask. See [`layout_mask_min_slots`]. +#[inline] +pub(in crate::gc) unsafe fn layout_prefers_scan_over_mask( + header: *const GcHeader, + user_ptr: usize, + slot_index: usize, +) -> bool { + layout_payload_slot_count(header, user_ptr, slot_index) < layout_mask_min_slots() +} diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs b/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs index e39db14c38..a709498462 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs @@ -52,20 +52,25 @@ fn test_layout_mask_overflow_fields_and_array_grow_transfer() { assert_ne!((*child_header).gc_flags & GC_FLAG_MARKED, 0); } - let arr = crate::array::js_array_alloc_with_length(1); + // Two elements, not one: a mask over a single-slot payload can skip + // nothing, so `layout_note_slot` now leaves such an array in the + // tag-checked `GC_LAYOUT_UNKNOWN` state and mints no mask to grow or + // transfer. Two slots is the smallest payload that still exercises the + // grow/transfer path this test is about. + let arr = crate::array::js_array_alloc_with_length(2); crate::array::js_array_set_f64( arr, 0, f64::from_bits(STRING_TAG | (child as u64 & POINTER_MASK)), ); let grown = crate::array::js_array_grow(arr, 128); - assert_eq!(test_layout_pointer_slot_count(grown as usize, 1), Some(1)); + assert_eq!(test_layout_pointer_slot_count(grown as usize, 2), Some(1)); - let moved = crate::array::js_array_alloc_with_length(1); + let moved = crate::array::js_array_alloc_with_length(2); unsafe { layout_transfer(grown as *mut u8, moved as *mut u8); } - assert_eq!(test_layout_pointer_slot_count(moved as usize, 1), Some(1)); + assert_eq!(test_layout_pointer_slot_count(moved as usize, 2), Some(1)); clear_marks(); clear_mark_seeds(); @@ -213,7 +218,13 @@ fn test_array_mixed_bulk_producers_preserve_pointer_layout() { let set = crate::set::js_set_alloc(4); let set = crate::set::js_set_add(set, child_box); let set_arr = crate::set::js_set_to_array(set); - assert_eq!(test_layout_pointer_slot_count(set_arr as usize, 1), Some(1)); + // A one-element result carries no mask: over a single slot a mask selects + // exactly what the tracer's tag check already selects, so it is pure + // side-table cost and `layout_note_slot` declines it. What this test is + // actually about — that the bulk producer leaves a layout the tracer can + // follow to the child — is asserted below, unchanged: one slot read, child + // marked. + assert_eq!(test_layout_pointer_slot_count(set_arr as usize, 1), None); assert_array_root_trace_reads(set_arr, 1); unsafe { assert_ne!((*child_header).gc_flags & GC_FLAG_MARKED, 0); @@ -224,7 +235,11 @@ fn test_array_mixed_bulk_producers_preserve_pointer_layout() { let map = crate::map::js_map_alloc(4); let map = crate::map::js_map_set(map, 7.0, child_box); let entries = crate::map::js_map_entries(map); - assert_eq!(test_layout_pointer_slot_count(entries as usize, 1), Some(1)); + // One entry, so the outer array is single-slot and carries no mask for the + // same reason as the set above; the pair it holds is two slots and still + // does. Both are traced either way, which is what the reads assertion and + // the child's mark bit below check. + assert_eq!(test_layout_pointer_slot_count(entries as usize, 1), None); let pair_box = crate::array::js_array_get_f64(entries, 0); let pair = (pair_box.to_bits() & POINTER_MASK) as *mut crate::array::ArrayHeader; assert_eq!(test_layout_pointer_slot_count(pair as usize, 2), Some(1)); @@ -235,14 +250,20 @@ fn test_array_mixed_bulk_producers_preserve_pointer_layout() { clear_marks(); clear_mark_seeds(); - let overwritten = crate::array::js_array_alloc_with_length(1); + // Two slots, so this still goes through the mask: clearing the last + // pointer empties it and restores `GC_LAYOUT_POINTER_FREE`, which is the + // transition being asserted. A single-slot array never mints a mask now, + // and `GC_LAYOUT_UNKNOWN` is one-way — such an array keeps being scanned + // after the pointer is overwritten. That costs one tag check on one slot, + // which is the whole reason the mask was not worth minting for it. + let overwritten = crate::array::js_array_alloc_with_length(2); crate::array::js_array_set_f64(overwritten, 0, child_box); assert_eq!( - test_layout_pointer_slot_count(overwritten as usize, 1), + test_layout_pointer_slot_count(overwritten as usize, 2), Some(1) ); crate::array::js_array_set_f64(overwritten, 0, 99.0); - assert_numeric_array_trace_free(overwritten, 1); + assert_numeric_array_trace_free(overwritten, 2); clear_marks(); clear_mark_seeds(); diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs b/crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs index 40502986f1..fa5843256f 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs @@ -461,3 +461,104 @@ fn test_addr_filter_proves_absence_while_the_global_flag_is_armed() { clear_marks(); clear_mark_seeds(); } + +/// A **single-slot** payload must never mint a per-object pointer mask. +/// +/// The mask could not skip anything — the tracer tag-checks that one slot +/// either way — but the entry it creates arms `PER_OBJECT_LAYOUTS_NONEMPTY`, +/// which puts a two-map hash probe back on every allocation in the program for +/// as long as it lives. `interp.ts` minted ~1.8M of these (one per `[arg]` +/// environment array), grew `LAYOUT_SLOT_MASKS` past 400k live entries, and +/// spent ~19% of its runtime in `layout_forget_object` probing it. +/// +/// The child must still be traced: `GC_LAYOUT_UNKNOWN` scans every slot and +/// tag-checks it, which is exact for an object with no typed descriptor. +#[test] +fn test_single_slot_pointer_payload_traces_without_a_side_table_entry() { + clear_marks(); + clear_mark_seeds(); + assert_flag_sound("before single-slot store"); + + let child = crate::string::js_string_from_bytes(b"one-slot-child".as_ptr(), 14) as *mut u8; + let child_header = unsafe { header_from_user_ptr(child) }; + let arr = crate::array::js_array_alloc_with_length(1); + crate::array::js_array_set_f64( + arr, + 0, + f64::from_bits(STRING_TAG | (child as u64 & POINTER_MASK)), + ); + + assert!( + test_per_object_tables_are_empty(), + "a one-slot pointer payload must not create a per-object record — one \ + live entry taxes every allocation in the program" + ); + + let valid_ptrs = build_valid_pointer_set(); + assert!(try_mark_value( + POINTER_TAG | (arr as u64 & POINTER_MASK), + &valid_ptrs + )); + trace_marked_objects(&valid_ptrs); + unsafe { + assert_ne!( + (*child_header).gc_flags & GC_FLAG_MARKED, + 0, + "the one-slot child must still be traced through the tag-checked scan" + ); + } + + clear_marks(); + clear_mark_seeds(); +} + +/// The other side of the threshold: above it the mask machinery must still be +/// live. Without this the test above would pass just as well if per-object +/// masks had been deleted outright. +#[test] +fn test_multi_slot_pointer_payload_still_mints_a_mask() { + clear_marks(); + clear_mark_seeds(); + + assert!( + crate::gc::layout_tables::DEFAULT_MASK_MIN_SLOTS >= 2, + "a threshold below 2 would leave no regime for this test to cover" + ); + + let child = crate::string::js_string_from_bytes(b"multi-slot-child".as_ptr(), 16) as *mut u8; + let child_header = unsafe { header_from_user_ptr(child) }; + let slots = crate::gc::layout_tables::DEFAULT_MASK_MIN_SLOTS; + let arr = crate::array::js_array_alloc_with_length(slots as u32); + for i in 0..slots { + crate::array::js_array_set_f64(arr, i as u32, 1.0); + } + crate::array::js_array_set_f64( + arr, + (slots - 1) as u32, + f64::from_bits(STRING_TAG | (child as u64 & POINTER_MASK)), + ); + + assert!( + !test_per_object_tables_are_empty(), + "a payload at or above the threshold must still mint a per-object mask" + ); + assert_eq!( + test_layout_pointer_slot_count(arr as usize, slots), + Some(1), + "the mask must record exactly the one pointer slot" + ); + + let valid_ptrs = build_valid_pointer_set(); + assert!(try_mark_value( + POINTER_TAG | (arr as u64 & POINTER_MASK), + &valid_ptrs + )); + trace_marked_objects(&valid_ptrs); + unsafe { + assert_ne!((*child_header).gc_flags & GC_FLAG_MARKED, 0); + } + + crate::gc::layout_clear_for_ptr(arr as usize); + clear_marks(); + clear_mark_seeds(); +}