Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/11599-packed-loop-global-cache-root.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/stmt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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}"
);
}
Loading
Loading