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
19 changes: 19 additions & 0 deletions changelog.d/8143-rs4gc-boxed-slots-leaf-accessors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
**codegen/GC: stop GC-typing variable-box pointers and leaf-mark the audited capture/box accessors (#8132 direction 1)**

RS4GC's output scales as safepoints × live GC values, and on next\@16.3.0's bundled `jsonwebtoken` one webpack module factory concentrated 95% of the unit's 1.5M `gc.relocate`s: 5,536 statepoints × a mean of 259 live `addrspace(1)` values (`perry_closure_jsonwebtoken_js__227`). Dissecting the live sets showed two populations this change removes at the source, per the issue's "not modelling every value as a GC pointer where a proof exists":

- **Variable-box pointers are not GC pointers.** A boxed local's slot only ever holds a `js_box_alloc_bits`-family result (or the `TAG_UNDEFINED` sentinel): boxes are `std::alloc` allocations outside the GC heap, never moved, never freed (`BOX_REGISTRY` is monotonic), and the JSValue inside is traced by the registered `scan_box_roots_mut` scanner — the premise `scripts/gc_root_dominance_check.py`'s IMMOVABLE_SOURCES "box" probes already machine-check, and the one `expr/literals_vars.rs` already relies on to carry a box address across collecting calls. `emit_shadow_slot_bind_for_local` now skips boxed locals (`boxed_vars && !module_globals`, the exact test every store site uses), so their slots stay plain `alloca i64` and RS4GC never relocates them. On the fixture ~300 preallocated boxes were live across ~90% of the monolith's statepoints.

- **The audited capture/box accessors are leaf calls.** `js_closure_get/set_capture_bits` (+ `_ptr`), `js_box_set_bits`, `js_box_alloc_bits`, and the i32/bool box helpers are raw slot reads/writes plus already-admitted barrier/layout bookkeeping, or `std::alloc` allocation that cannot arm a Perry GC trigger. They join `GcCallEffect::CannotCollect` (and the checker's NONCOLLECTING, preserving the one-way containment). They were 2,168 of the monolith's 5,537 statepoint-forming calls. `js_box_get_bits` is deliberately **not** admitted: its TDZ arm allocates a ReferenceError before unwinding, and a test pins it to `Unknown`.

Measured on the #8132 fixture (stock `opt` 22.1.4, `function(mem2reg,sccp),rewrite-statepoints-for-gc` on unit0):

| metric | before | after |
|---|---|---|
| fn227 statepoints | 5,536 | 3,368 (−39%) |
| fn227 gc.relocate | 1,432,110 | 477,377 (−67%) |
| fn227 mean live values/statepoint | 258.7 | 141.7 |
| unit0 gc.relocate | 1,503,308 | 522,099 (−65%) |
| unit0 post-RS4GC IR | 412 MB | 161 MB (−61%) |

Tests: the boxed local's slot is asserted un-retyped beside an unboxed twin that still lowers `alloca ptr addrspace(1)` (discriminating in both directions), and a statepoint-rewrite probe asserts the audited accessors stay direct calls while an unaudited callee beside them is statepoint-wrapped.
23 changes: 23 additions & 0 deletions crates/perry-codegen/src/expr/shadow_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,29 @@ pub(crate) fn emit_shadow_slot_bind_for_local(ctx: &mut FnCtx<'_>, local_id: u32
if ctx.persistent_shadow_slots.contains(&slot_idx) {
return;
}
// #8132: a boxed local's alloca never holds a GC-heap value, so rooting it
// protects nothing and (under the RS4GC lowering) costs a relocation of
// the box pointer at EVERY statepoint it is live across. Every store site
// routes through the same `boxed_vars && !module_globals` test
// (`stmt/mod.rs` prealloc, `let_stmt.rs`'s boxed arm,
// `codegen/arguments.rs::store_param_slot`, `lower_call/new_ctor_args.rs`),
// and each of them stores only a `js_box_alloc_bits`-family result or the
// TAG_UNDEFINED sentinel into the slot — the VALUE always goes inside the
// box. Boxes are `std::alloc` allocations outside the GC heap: no
// collector phase moves them, box.rs never frees them (`BOX_REGISTRY` is
// monotonic), and the JSValue inside is traced and rewritten by the
// registered `scan_box_roots_mut` scanner. All three premises are pinned
// by `scripts/gc_root_dominance_check.py`'s IMMOVABLE_SOURCES "box" entry,
// whose probes fail the lint if boxes ever become arena-allocated or grow
// a free path — at which point this skip must be reverted with them.
//
// On the webpack-factory monolith of #8132, ~300 preallocated boxes were
// live across ~90% of one function's 5.5k statepoints; unbinding them is
// what "not modelling every value as a GC pointer where a proof exists"
// means for this shape.
if ctx.boxed_vars.contains(&local_id) && !ctx.module_globals.contains_key(&local_id) {
return;
}
let Some(local_slot) = ctx.locals.get(&local_id).cloned() else {
return;
};
Expand Down
85 changes: 85 additions & 0 deletions crates/perry-codegen/src/function/precise_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,91 @@ pub(super) fn lower_precise_roots_to_native_stack(
mod tests {
use super::lower_precise_roots_to_native_stack;

/// #8132: the audited capture/box accessors must not become statepoints.
/// On the bundled-module-factory shape they were ~45% of one function's
/// 5.5k statepoints, each relocating every live GC value (~259 mean).
///
/// The unknown callee beside them is the discriminating control: it MUST
/// be statepoint-wrapped, so this test fails in both directions — a leaf
/// marking that stops being applied (extra statepoints, the direct-call
/// assertions break) and an over-rotation that leafs everything (the
/// control's statepoint disappears).
#[test]
fn audited_capture_and_box_accessors_take_no_statepoint() {
let _native = crate::codegen::helpers::NativeRootsPin::native();
let target = crate::codegen::default_target_triple();
let mut module = crate::module::LlModule::new(target.clone());
use crate::types::{I32, I64, PTR, VOID};
module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]);
module.declare_function("js_closure_get_capture_bits", I64, &[I64, I32]);
module.declare_function("js_closure_set_capture_bits", VOID, &[I64, I32, I64]);
module.declare_function("js_box_alloc_bits", I64, &[I64]);
module.declare_function("js_box_set_bits", VOID, &[I64, I64]);
module.declare_function("js_map_alloc", I64, &[I32]);

let function = module.define_function("leaf_probe", I64, vec![]);
function.enable_shadow_frame(0);
let idx = function
.reserve_shadow_slot()
.expect("native pin reserves a precise-root slot");
let root = function.alloca_entry(I64);
function.entry_allocas_push_store(I64, "0", &root);
function.entry_setup_call_void(
"js_shadow_slot_bind",
&[(I32, &idx.to_string()), (PTR, &root)],
);
let entry = function.create_block("entry");
let dynamic = entry.call(I64, "js_map_alloc", &[(I32, "0")]);
entry.store(I64, &dynamic, &root);
// The audited leaf calls, with the root live across every one.
let box_ptr = entry.call(I64, "js_box_alloc_bits", &[(I64, "0")]);
entry.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, "1")]);
let cap = entry.call(
I64,
"js_closure_get_capture_bits",
&[(I64, "0"), (I32, "0")],
);
entry.call_void(
"js_closure_set_capture_bits",
&[(I64, "0"), (I32, "0"), (I64, &cap)],
);
// Control: an unaudited callee stays a genuine safepoint.
let unknown = entry.call(I64, "js_map_alloc", &[(I32, "1")]);
let live = entry.load(I64, &root);
let acc = entry.xor(I64, &live, &cap);
let acc = entry.xor(I64, &acc, &box_ptr);
let acc = entry.xor(I64, &acc, &unknown);
entry.ret(I64, &acc);

let rewritten =
crate::inprocess::statepoint_rewritten_ir(&module.to_ir(), &target, "leaf_probe")
.expect("leaf probe must survive RS4GC");
// Exactly the two js_map_alloc calls become statepoints.
assert_eq!(
rewritten
.matches("@llvm.experimental.gc.statepoint")
.count(),
// one declare line + two wrapped call sites
3,
"only the two unaudited js_map_alloc calls may be statepoints:\n{rewritten}"
);
for direct in [
"call i64 @js_box_alloc_bits(",
"call void @js_box_set_bits(",
"call i64 @js_closure_get_capture_bits(",
"call void @js_closure_set_capture_bits(",
] {
assert!(
rewritten.contains(direct),
"audited accessor must remain a direct call ({direct}):\n{rewritten}"
);
}
assert!(
!rewritten.contains("= call i64 @js_map_alloc("),
"the control callee must be statepoint-wrapped, not direct:\n{rewritten}"
);
}

#[test]
fn one_logical_slot_can_root_disjoint_physical_allocas() {
let ir = r#"define void @f() {
Expand Down
87 changes: 86 additions & 1 deletion crates/perry-codegen/src/gc_call_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,49 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect {
// TLS dynamic-call context only.
| "js_implicit_this_set"
| "js_new_target_get"
| "js_new_target_set" => GcCallEffect::CannotCollect,
| "js_new_target_set"
// Closure capture-slot accessors (#8132). `closure/alloc.rs`:
// `get` is a null check, a bounds check, and a raw slot read;
// `set` is the raw slot write plus `note_closure_capture_slot`, whose
// whole body is `layout_note_slot` + `runtime_write_barrier_gc_slot` —
// the same side-table/barrier bodies already admitted above as
// `js_gc_note_slot_layout` / `js_write_barrier_slot`. The `_ptr`
// spellings are one-line wrappers over the `_bits` pair. All four are
// in `gc_root_dominance_check.py`'s NONCOLLECTING (the audit
// authority this table must stay a subset of). On #8132's bundled
// module factory these were 1,495 of 5,537 statepoints.
| "js_closure_get_capture_bits"
| "js_closure_set_capture_bits"
| "js_closure_get_capture_ptr"
| "js_closure_set_capture_ptr"
// Variable-box accessors and allocators (#8132), `box.rs`. Boxes are
// `std::alloc::alloc` allocations OUTSIDE the GC heap — allocating
// one arms no Perry GC trigger (the malloc-count trigger counts
// `MALLOC_STATE` GC objects, not raw Rust allocations), and the
// registry insert is a TLS set. `gc_root_dominance_check.py`'s
// IMMOVABLE_SOURCES "box" probes pin exactly this: std::alloc, no
// arena allocation, no dealloc — if boxes ever become GC objects the
// lint fails and these entries must be demoted with it. The setters
// are a registry membership check, the raw cell write, and (for the
// JSValue box) `runtime_write_barrier_root_nanbox`, admitted above.
// The i32/bool getters are registry check + raw read; they have no
// TDZ path.
//
// `js_box_get_bits` is deliberately ABSENT: its TDZ arm calls
// `js_throw_reference_error_tdz`, which allocates the ReferenceError
// (string + error object) before unwinding — a genuine route into
// collection, per the `js_throw*` audit note below. The checker's
// NONCOLLECTING currently lists it anyway; this table does not
// inherit that entry, it only requires containment in the safe
// direction.
| "js_box_alloc_bits"
| "js_i32_box_alloc"
| "js_bool_box_alloc"
| "js_box_set_bits"
| "js_i32_box_set"
| "js_bool_box_set"
| "js_i32_box_get"
| "js_bool_box_get" => GcCallEffect::CannotCollect,
// Audited allocate-but-never-reenter helpers (2026-07-31): each body
// was checked for closure invocation, coercion (valueOf/toString),
// and accessor dispatch — none present, and none takes a receiver
Expand Down Expand Up @@ -215,6 +257,49 @@ mod tests {
}
}

/// #8132: capture-slot and variable-box accessors are leaf calls. On the
/// bundled-module-factory shape these were ~45% of all statepoints, each
/// paying a relocation for every live GC value.
#[test]
fn capture_and_box_accessors_cannot_collect() {
for name in [
"js_closure_get_capture_bits",
"js_closure_set_capture_bits",
"js_closure_get_capture_ptr",
"js_closure_set_capture_ptr",
"js_box_alloc_bits",
"js_i32_box_alloc",
"js_bool_box_alloc",
"js_box_set_bits",
"js_i32_box_set",
"js_bool_box_set",
"js_i32_box_get",
"js_bool_box_get",
] {
assert_eq!(
classify_direct_callee(name),
GcCallEffect::CannotCollect,
"{name}"
);
}
}

/// The discriminating negative for the family above: `js_box_get_bits`
/// reads a TDZ-seeded box's sentinel and calls
/// `js_throw_reference_error_tdz`, which ALLOCATES the ReferenceError
/// (string + error object) before unwinding. A leaf marking would leave
/// the catch handler's relocations unrecorded on the unwind edge. If a
/// future split gives the non-TDZ boxes their own entry point, THAT
/// symbol can be admitted; this one cannot.
#[test]
fn the_tdz_capable_box_getter_stays_a_safepoint() {
assert_eq!(
classify_direct_callee("js_box_get_bits"),
GcCallEffect::Unknown,
"js_box_get_bits can throw (and allocate) on the TDZ path"
);
}

#[test]
fn collection_and_unknown_calls_stay_conservative() {
for name in [
Expand Down
Loading
Loading