From 77ecdf33594cacbe5cb5f34622984da10e6b0acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 21:21:46 +0000 Subject: [PATCH 1/4] fix(codegen): root the packed-range loop's cached module-global copy (#11590) --- crates/perry-codegen/src/stmt/loops.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index fb57264f31..d7c521a7a0 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3433,8 +3433,31 @@ fn lower_packed_f64_range_versioned_for( }; let slot = ctx.func.alloca_entry(DOUBLE); let g_ref = format!("@{global_name}"); + // #11590: the cache is a COPY of a GC root, so it must be a root too. + // Both clones' entry guards are runtime calls, and the SLOW clone + // polls on its back-edge and reaches `js_dyn_index_set_strict` (which + // grows the array) every iteration. An evacuating minor rewrites + // `@perry_global_*` but not a bare alloca, so an unrooted cache of a + // heap receiver (`let d: any = []; for (…) d[j] = …` with `d` read by + // some function) handed from-space to the very next store. A value + // proven non-pointer by the shared shadow-slot predicate stays a bare, + // register-promotable alloca, which is what the cache exists for. + let may_hold_pointer = !crate::expr::expr_is_known_non_pointer_shadow_value( + ctx, + &perry_hir::Expr::LocalGet(gid), + ); + if may_hold_pointer { + // `root_entry_alloca` hoists the bind into entry setup, so seed + // the slot before the collector can dereference it. + let undefined = + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.func.entry_allocas_push_store(DOUBLE, &undefined, &slot); + } let val = ctx.block().load(DOUBLE, &g_ref); ctx.block().store(DOUBLE, &val, &slot); + if may_hold_pointer { + crate::expr::root_entry_alloca(ctx, &slot); + } ctx.locals.insert(gid, slot); global_override_ids.push(gid); } From a9b0ecb378490ad736b527792936b12d78ba7b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 22:28:11 +0000 Subject: [PATCH 2/4] test(gc): #11590 gap test, codegen IR test, and loop-carried unrooted-alloca windows in the dominance checker --- crates/perry-codegen/src/stmt/mod.rs | 2 + ...packed_range_global_cache_rooting_tests.rs | 187 ++++++++++++++++++ scripts/gc_root_dominance_check.py | 127 +++++++++++- ..._11590_packed_loop_global_cache_rooting.ts | 69 +++++++ 4 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/packed_range_global_cache_rooting_tests.rs create mode 100644 test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index fe540a826c..9058281c89 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -41,6 +41,8 @@ mod let_stmt_var_redeclare_tests; mod loops; mod masked_window_region; #[cfg(test)] +mod packed_range_global_cache_rooting_tests; +#[cfg(test)] mod prealloc_module_global_tests; #[cfg(test)] mod prealloc_tdz_path_tests; diff --git a/crates/perry-codegen/src/stmt/packed_range_global_cache_rooting_tests.rs b/crates/perry-codegen/src/stmt/packed_range_global_cache_rooting_tests.rs new file mode 100644 index 0000000000..9752452c07 --- /dev/null +++ b/crates/perry-codegen/src/stmt/packed_range_global_cache_rooting_tests.rs @@ -0,0 +1,187 @@ +//! #11590: the packed-f64 range loop's cached copy of a module global must be +//! a GC root when the global can hold a heap value. +//! +//! `lower_packed_f64_range_versioned_for` copies every loop-invariant module +//! global the body reads into an entry alloca and aliases it into `ctx.locals` +//! for BOTH loop clones. For +//! +//! ```ts +//! let d: any = []; // read by name in some function +//! for (let j = 0; j < 80; j++) d[j] = v; // module scope +//! ``` +//! +//! the entry guard fails (length 0), the SLOW clone runs, and it polls for GC +//! on its back-edge and grows `d` through `js_dyn_index_set_strict` every +//! iteration — all through that cache. An evacuating minor rewrites +//! `@perry_global_*` (a registered root) but not a bare alloca, so the next +//! store dereferenced from-space: SIGSEGV under `PERRY_GC_SCHEDULE_SEED` + +//! `PERRY_GC_PROTECT_FROMSPACE=1` (the #10514 `grown`/`grown_typed` benches). +//! +//! The assertions read `main()`'s IR under both root lowerings. Each names the +//! exact slot the global's value is first stored into, so a root slot reserved +//! for something else in `main()` cannot satisfy them. +//! +//! Sabotage: force `may_hold_pointer` to `false` in `stmt/loops.rs` and +//! `a_heap_valued_global_cache_is_rooted_*` go red. + +use crate::codegen::helpers::NativeRootsPin; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, ModuleInitKind, Stmt, UpdateOp}; + +const D: u32 = 0; +const J: u32 = 1; + +fn counted_store_loop(target: u32, value: Expr) -> Stmt { + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: J, + name: "j".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(J)), + right: Box::new(Expr::Integer(80)), + }), + update: Some(Expr::Update { + id: J, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(target)), + key: Box::new(Expr::LocalGet(J)), + value: Box::new(value), + receiver: Box::new(Expr::LocalGet(target)), + strict: false, + })], + } +} + +/// `let d: any = []; for (…) d[j] = j * 7; function read() { return d }`. +fn grown_global_module() -> Module { + let mut m = Module::new("grown_global.ts"); + m.functions.push(Function { + id: 0, + name: "read".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::LocalGet(D)))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + m.init = vec![ + Stmt::Let { + id: D, + name: "d".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Array(Vec::new())), + }, + counted_store_loop( + D, + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::LocalGet(J)), + right: Box::new(Expr::Integer(7)), + }, + ), + ]; + m.init_kind = ModuleInitKind::Eager; + m +} + +fn main_ir(m: &Module) -> String { + let opts = crate::CompileOptions { + emit_ir_only: true, + is_entry_module: true, + ..Default::default() + }; + let ir = String::from_utf8(crate::compile_module(m, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let start = ir + .find("define i32 @main()") + .expect("entry module emits main()"); + let rest = &ir[start..]; + let end = rest.find("\n}\n").unwrap_or(rest.len()); + rest[..end].to_string() +} + +const GLOBAL: &str = "@perry_global_grown_global_ts__0"; + +/// The cache is the slot the global's value is stored into right after the +/// guard-preamble load. +fn cache_slot(main: &str) -> String { + let lines: Vec<&str> = main.lines().map(str::trim).collect(); + // The packed tier is the subject; without its guard nothing below means + // anything (CLAUDE.md: a gate must assert its subject was live). + assert!( + main.contains("packed_f64_range"), + "premise: the loop must lower through the packed-f64 range tier\n{main}" + ); + let load_reg = lines + .iter() + .rev() + .filter_map(|l| { + let (lhs, rhs) = l.split_once(" = ")?; + (rhs == format!("load double, ptr {GLOBAL}")).then(|| lhs.to_string()) + }) + .next() + .unwrap_or_else(|| panic!("premise: main() must load {GLOBAL}\n{main}")); + // Native lowering stores `inttoptr(bitcast %load)`; shadow stores the + // double itself. Follow the value through those two casts. + let mut names = vec![load_reg]; + for l in &lines { + if let Some((lhs, rhs)) = l.split_once(" = ") { + if (rhs.starts_with("bitcast double ") || rhs.starts_with("inttoptr i64 ")) + && names.iter().any(|n| rhs.contains(&format!(" {n} "))) + { + names.push(lhs.to_string()); + } + } + } + lines + .iter() + .find_map(|l| { + let rest = l.strip_prefix("store ")?; + let (val, slot) = rest.rsplit_once(", ptr ")?; + let val_reg = val.rsplit(' ').next()?; + (names.iter().any(|n| n == val_reg) && slot.starts_with('%')).then(|| slot.to_string()) + }) + .unwrap_or_else(|| panic!("premise: the loaded global must be cached in a slot\n{main}")) +} + +#[test] +fn a_heap_valued_global_cache_is_rooted_native() { + let _pin = NativeRootsPin::native(); + let main = main_ir(&grown_global_module()); + let slot = cache_slot(&main); + assert!( + main.contains(&format!("{slot} = alloca ptr addrspace(1)")), + "#11590: the packed loop's cache of heap-valued global {GLOBAL} ({slot}) must be \ + a native root (`alloca ptr addrspace(1)`), or an evacuating minor leaves it \ + pointing into from-space\n{main}" + ); +} + +#[test] +fn a_heap_valued_global_cache_is_rooted_shadow() { + let _pin = NativeRootsPin::shadow(); + let main = main_ir(&grown_global_module()); + let slot = cache_slot(&main); + assert!( + main.lines() + .any(|l| l.contains("@js_shadow_slot_bind(") && l.contains(&format!("ptr {slot})"))), + "#11590: the packed loop's cache of heap-valued global {GLOBAL} ({slot}) must be \ + bound as a shadow root\n{main}" + ); +} diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index cb6185b3a8..8852f5b6e5 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -1818,6 +1818,51 @@ def between_blocks(f, a_blk, b_blk): return (fwd & bwd) - {a_blk, b_blk} +def loop_carried_blocks(f, a_blk, b_blk, barrier_blks=()): + """Blocks on a cycle b_blk -> ... -> b_blk that enters neither a_blk nor + any of `barrier_blks` (blocks that re-store the slot), or None when there + is no such cycle. + + Only the unrooted-alloca mode uses this (#11590). A memory slot stored in + `a_blk` and loaded in a loop body `b_blk` keeps its value across the + back-edge, so a collector on that cycle is inside the window of every + load after the first. For an SSA register `between_blocks`'s refusal to + expand past `b_blk` is right; for a slot it misses exactly this shape. + """ + if a_blk == b_blk: + return None + stop = {a_blk} | set(barrier_blks) + if b_blk in stop: + return None + fwd = set() + q = deque(s for s in f.succs[b_blk] if s in f.insns and s not in stop) + while q: + x = q.popleft() + if x in fwd: + continue + fwd.add(x) + if x == b_blk: + continue + for s in f.succs[x]: + if s in f.insns and s not in stop: + q.append(s) + if b_blk not in fwd: + return None + bwd = set() + q = deque(p for p in f.preds[b_blk] if p in f.insns and p not in stop) + while q: + x = q.popleft() + if x in bwd: + continue + bwd.add(x) + if x == b_blk: + continue + for p in f.preds[x]: + if p in f.insns and p not in stop: + q.append(p) + return (fwd & bwd) - {b_blk} + + # ---------------------------------------------------------- slot activity (must) def must_active_slots(f): @@ -3289,7 +3334,12 @@ def check_func_unrooted_allocas(module, f, want_moving_only=False, if lm and lm.group(1) in allocas: loads[lm.group(1)].append(ins) - def window_hits(A, B): + store_blocks = defaultdict(set) # alloca -> blocks that (re)store it + for reg_, sts in stores.items(): + for st_ in sts: + store_blocks[reg_].add(st_.block) + + def window_hits(A, B, reg): hits = [] if A.block == B.block: return [c for c in f.insns[A.block] @@ -3300,6 +3350,19 @@ def window_hits(A, B): if is_collecting(c.callee) and c.idx < B.idx] for m_blk in between_blocks(f, A.block, B.block): hits += [c for c in f.insns[m_blk] if is_collecting(c.callee)] + # #11590: a slot stored ONCE before a loop and loaded in its body is + # the same value on every iteration, so a collection anywhere on the + # back-edge cycle sits between the store and every load after the + # first. `between_blocks` stops at the load's block (right for an SSA + # value re-defined per iteration), which hid the packed-range loop's + # module-global cache: its only moving collector is the slow clone's + # back-edge `js_gc_loop_safepoint`, AFTER the load. + cyc = loop_carried_blocks(f, A.block, B.block, store_blocks[reg]) + if cyc is not None: + hits += [c for c in f.insns[B.block] + if is_collecting(c.callee) and c.idx > B.idx] + for m_blk in cyc: + hits += [c for c in f.insns[m_blk] if is_collecting(c.callee)] return hits out = [] @@ -3339,7 +3402,7 @@ def window_hits(A, B): continue if st.block == ld.block and st.idx >= ld.idx: continue - hits = window_hits(st, ld) + hits = window_hits(st, ld, reg) if not hits: continue v = UnrootedAlloca(module, f.name, alloca_ins, st, ld, hits, @@ -3378,6 +3441,44 @@ def window_hits(A, B): } """ +# #11590: the packed-range loop's module-global cache, reduced. The slot is +# stored ONCE before the loop and loaded in the body; the only MOVING collector +# is the back-edge poll, which runs AFTER the load. A store->load window that +# stops at the load's block sees nothing; the second iteration's load reads +# whatever the first iteration's poll left behind. `@loop_rooted` differs only +# by the bind, the #11590 fix. +_SELFTEST_LOOP_CARRIED = """\ +@perry_global_selftest__0 = global double 0.0 + +define void @perry_fn_selftest__loop_unrooted() { +entry.0: + %slot = alloca double + %g = load double, ptr @perry_global_selftest__0 + store double %g, ptr %slot + br label %cond.1 +cond.1: + %i = phi i32 [ 0, %entry.0 ], [ %n, %poll.3 ] + %c = icmp slt i32 %i, 80 + br i1 %c, label %body.2, label %exit.4 +body.2: + %d = load double, ptr %slot + %r = call double @js_dyn_index_set_strict(double %d, double 0.0, double 1.0, i32 0) + %n = add i32 %i, 1 + br label %poll.3 +poll.3: + call void @js_gc_loop_safepoint() + br label %cond.1 +exit.4: + ret void +} +""" + +_SELFTEST_LOOP_ROOTED = _SELFTEST_LOOP_CARRIED.replace( + "loop_unrooted", "loop_rooted").replace( + " store double %g, ptr %slot\n", + " store double %g, ptr %slot\n" + " call void @js_shadow_slot_bind(i32 0, ptr %slot)\n") + _SELFTEST_ROOTED = """\ define double @perry_fn_selftest__rooted(double %a) { entry.0: @@ -5524,6 +5625,28 @@ def self_test(): "allocas, expected 1", file=sys.stderr) ok = False + # #11590: the loop-carried window, both directions, under the gated + # `--moving-only` filter (the poll is the only mover). + lc_bad = os.path.join(td, "loop_carried_unrooted.ll") + lc_ok = os.path.join(td, "loop_carried_rooted.ll") + for p, text in ((lc_bad, _SELFTEST_LOOP_CARRIED), + (lc_ok, _SELFTEST_LOOP_ROOTED)): + with open(p, "w") as fh: + fh.write(text) + found, _ = _scan_unrooted([lc_bad], moving_only=True) + if len(found) != 1: + print(f"self-test FAIL: loop-carried unrooted-alloca fixture " + f"(#11590) -> {len(found)} --moving-only violations, " + "expected 1. A slot stored before a loop and loaded in its " + "body is stale after the back-edge poll.", file=sys.stderr) + ok = False + found, _ = _scan_unrooted([lc_ok], moving_only=True) + if found: + print(f"self-test FAIL: loop-carried rooted control (#11590) -> " + f"{len(found)} violations, expected 0; it differs from the " + "planted fixture only by the bind.", file=sys.stderr) + ok = False + # And it must not fire on the bind-anchored fixtures, nor the reverse: # the two populations are disjoint by construction and a checker that # double-counts would make both numbers meaningless. diff --git a/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts b/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts new file mode 100644 index 0000000000..34b4cf7cfc --- /dev/null +++ b/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts @@ -0,0 +1,69 @@ +// #11590: a packed-f64 range loop at module scope caches every loop-invariant +// module global it reads in an entry alloca, for both loop clones. When the +// global holds a heap receiver — here an array grown from `[]` by `d[j] = v`, +// so the entry guard fails and the SLOW clone runs, which polls for GC on its +// back-edge and grows the array through `js_dyn_index_set_strict` — that copy +// must be a GC root. It was a bare alloca: an evacuating minor rewrote the +// global but not the cache, and the next store dereferenced from-space +// (SIGSEGV under PERRY_GC_SCHEDULE_SEED + PERRY_GC_PROTECT_FROMSPACE=1). +// +// Each global is read BY NAME inside a function (`snapshot`), which is what +// keeps it a module global instead of a main()-local. Every value printed is a checksum Node agrees on; +// without the GC knobs this is a plain behavioural test. + +let grown: any = []; +for (let j = 0; j < 80; j++) grown[j] = (j * 7) & 0xfffff; + +let grownTyped: number[] = []; +for (let j = 0; j < 96; j++) grownTyped[j] = j * 3 + 1; + +let pushed: any = []; +for (let j = 0; j < 64; j++) pushed.push(j); +for (let j = 0; j < 64; j++) pushed[j] = pushed[j] * 2 + 1; + +// Two grown globals in one loop body, plus a numeric global (the cache's +// original purpose: a non-pointer that stays a bare, promotable slot). +const scale = 3; +let left: any = []; +let right: any = []; +for (let j = 0; j < 72; j++) left[j] = j; +for (let j = 0; j < 72; j++) right[j] = left[j] * scale + 1; + +// Grow past several capacity doublings so the array is forwarded repeatedly. +let big: any = []; +for (let j = 0; j < 2000; j++) big[j] = j % 97; + +function sumAny(d: any, n: number): number { + let s = 0; + for (let j = 0; j < n; j++) s += d[j]; + return s; +} +function sumTyped(d: number[], n: number): number { + let s = 0; + for (let j = 0; j < n; j++) s += d[j]; + return s; +} +function report(name: string, d: any, n: number): void { + console.log(name, d.length, sumAny(d, n), d[0], d[n - 1]); +} + +function snapshot(): string { + return [grown.length, grownTyped.length, pushed.length, left.length, right.length, big.length].join(","); +} +console.log("lengths", snapshot()); + +report("grown", grown, 80); +console.log("grownTyped", grownTyped.length, sumTyped(grownTyped, 96), grownTyped[95]); +report("pushed", pushed, 64); +report("left", left, 72); +report("right", right, 72); +report("big", big, 2000); + +// Churn after the fact: every array must still be intact and readable. +let acc = 0; +for (let r = 0; r < 200; r++) { + const tmp: number[] = []; + for (let j = 0; j < 50; j++) tmp.push(r + j); + acc = (acc + sumAny(grown, 80) + sumTyped(grownTyped, 96) + tmp[49]) % 1000000007; +} +console.log("churn", acc, sumAny(big, 2000), sumAny(right, 72)); From d72ed009921576adc1b5124fca468872dfda58bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 23:02:45 +0000 Subject: [PATCH 3/4] test-parity: register the #11590 gap test in the GC repsel corpus --- test-parity/gc_repsel_corpus.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index d4de2160d5..e1a2dc6536 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -872,3 +872,9 @@ test_gap_gc_store_ic_rhs_collects test_gap_gc_store_ic_rhs_reshapes test_gap_gc_store_ic_old_to_young test_gap_gc_store_ic_generic_receivers + +# --- #11590: packed-range loop's cached module-global copy ----------------- +# A module global grown from `[]` inside a top-level counted loop: the slow +# clone polls and grows through a cached copy of the global, which must be a +# GC root. SIGSEGV'd on every seed under the seeded schedule before the fix. +test_gap_gc_11590_packed_loop_global_cache_rooting From 347ba0ab3d2239ddf4291edfa28fd658d004f801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 23:11:01 +0000 Subject: [PATCH 4/4] changelog: #11599 packed-range loop global cache rooting --- changelog.d/11599-packed-loop-global-cache-root.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/11599-packed-loop-global-cache-root.md diff --git a/changelog.d/11599-packed-loop-global-cache-root.md b/changelog.d/11599-packed-loop-global-cache-root.md new file mode 100644 index 0000000000..9e62eb7c18 --- /dev/null +++ b/changelog.d/11599-packed-loop-global-cache-root.md @@ -0,0 +1,4 @@ +- **GC: the packed-f64 range loop's cached copy of a module global is now a GC root (#11590).** `lower_packed_f64_range_versioned_for` copies every loop-invariant module global the loop body reads into an entry alloca and aliases it into `ctx.locals` for both loop clones. When the global holds a heap receiver, for example `let d: any = []; for (let j = 0; j < 80; j++) d[j] = v;` at module scope with `d` read by name in some function, the copy was a bare `alloca double`. The entry guard fails on the empty array, so the slow clone runs. That clone polls for GC on its back-edge and grows `d` through `js_dyn_index_set_strict`, and it does both through the cache. An evacuating minor rewrote `@perry_global_*` but not the cache, so the next store dereferenced from-space. That is the SIGSEGV behind the #10514 `grown` / `grown_typed` microbench variants under `PERRY_GC_SCHEDULE_SEED` + `PERRY_GC_PROTECT_FROMSPACE=1`, on every seed. + - **The fix.** The cache slot is seeded to `undefined` at entry and bound with `root_entry_alloca`: a native `addrspace(1)` root under RS4GC, or a shadow-slot bind otherwise. A global proven non-pointer by `expr_is_known_non_pointer_shadow_value` keeps the old bare, register-promotable slot, which is what the cache exists for. + - **The checker could not see it.** `gc_root_dominance_check.py --unrooted-allocas` measured the window only from a store to the load's block. A slot stored once before a loop and loaded in the body keeps its value across the back-edge, and the only moving collector here is the back-edge poll, which runs after the load. The mode now also counts collectors on a back-edge cycle through the load that re-enters neither the store's block nor any block that re-stores the slot. A planted fixture and its bind-only control were added to `--self-test`. On the new gap test the gated `--unrooted-allocas --moving-only` run reports 6 violations with the fix reverted and 0 with it. + - **New gap test.** `test_gap_gc_11590_packed_loop_global_cache_rooting.ts` joins the root-dominance corpus through its `test_gap_gc_` prefix. There is also a codegen unit test, `stmt/packed_range_global_cache_rooting_tests.rs`, which checks the cache slot under both root lowerings.