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
16 changes: 16 additions & 0 deletions changelog.d/7007-scalar-replaced-local-rooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
### Fixed

- **GC: a heap value in a scalar-replaced object field or array element is now a precise root (#6968).**

Scalar replacement deletes the object and keeps one entry-block alloca per field/element. Those allocas belong to no HIR local, so `collect_pointer_typed_locals` — which assigns shadow slots by walking `Stmt::Let` — never saw them and nothing bound them. With precise roots only (`PERRY_CONSERVATIVE_STACK_SCAN=off`) a collection landing between the store and the read swept the value out from under the alloca: `{ const o = { a: fresh(0), b: churn(N) }; console.log(o.a, o.b) }` printed an empty `o.a`, or a recycled one, with no crash and no diagnostic. The array form (`const a = [fresh(0), churn(N)]`) was identical.

#6951/#6972's object-literal rooting could not reach this shape: that path roots the object *handle*, and scalar replacement leaves no handle. The object local *does* get a shadow slot reserved — it is pointer-typed — but lowering only ever **cleared** it.

Each replaced slot is now shadow-bound at the store, the same treatment `emit_shadow_slot_update_for_expr` gives an ordinary pointer-typed local, at object-literal fields, array-literal elements, scalar-replaced `split()` parts, anonymous-shape constructor arguments, and both `expr::property_set` arms. Two properties keep it cheap:

- **The frame grows on demand.** `LlFunction::reserve_shadow_slot` rewrites the slot-count operand of the already-emitted `js_shadow_frame_push` (creating the frame if the pre-lowering count was zero), because the escape facts that decide scalar replacement are not computed until after the frame is sized.
- **Reservation is lazy and gated on the lowering, never a declared type** (#6997): the predicate is `expr_is_known_non_pointer_shadow_value`. A literal whose fields are all numbers takes no slot, emits no call, and does not grow the frame. Measured: `{ x: i & 1023, y: (i>>3) & 1023 }` over 40 M iterations is unchanged (313–355 ms → 319–353 ms), as is the array twin. A pointer-capable field store costs **~2.6 ns** (118–121 ms → 144–148 ms over 10 M iterations) — against 4682–4997 ms for the heap object scalar replacement removes, so the optimization still wins by ~32× after paying for the root.

Corpus effect, `scripts/gc_repsel_matrix.sh --pressure 8` on the evacuating precise-roots arm: `test_gap_repsel_gc_stress` goes FAIL → PASS (deterministic over 3 repeats, `moved=1 230 900` in both arms), and no cell regresses. Ten of the thirteen files #6981 lists compile to **byte-identical LLVM IR** with and without this change, so #6968 is provably not their cause; they belong to the argument-passing families (#6969/#6970/#6971) and their neighbours.

New coverage: `test-files/test_gap_repsel_scalar_replaced_locals.ts` (registered in `test-parity/gc_repsel_corpus.txt`; red on `cons_scan_off` — a PR arm — before this change, green after) and `crates/perry-codegen/tests/scalar_replaced_slot_roots.rs` (5 codegen-contract tests, teeth verified in both directions, including the gate tests against a deliberately coarsened gate).
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ pub(super) fn compile_closure(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: native_facts.non_escaping_new_used_fields().clone(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ pub(super) fn compile_module_entry(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: main_native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: main_native_facts.non_escaping_new_used_fields().clone(),
Expand Down Expand Up @@ -1412,6 +1413,7 @@ pub(super) fn compile_module_entry(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: init_native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: init_native_facts.non_escaping_new_used_fields().clone(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ pub(super) fn compile_function(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: native_facts.non_escaping_new_used_fields().clone(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ pub(super) fn compile_method(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: native_facts.non_escaping_new_used_fields().clone(),
Expand Down Expand Up @@ -1577,6 +1578,7 @@ pub(super) fn compile_static_method(
scalar_replaced_arrays: std::collections::HashMap::new(),
scalar_replaced_split_part_lengths: std::collections::HashMap::new(),
scalar_replaced_uppercase_sources: std::collections::HashMap::new(),
scalar_slot_shadow_slots: std::collections::HashMap::new(),
scalar_ctor_target: Vec::new(),
non_escaping_news: native_facts.non_escaping_news().clone(),
non_escaping_new_used_fields: native_facts.non_escaping_new_used_fields().clone(),
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub(crate) use write_barrier::{
// under 2000 lines. Inherent methods (`record_value`) need no re-export.
mod dispatch;
mod record_value;
mod scalar_slot_root;
mod shadow_slot;
mod slot_rep;
pub(crate) mod temp_root;
Expand All @@ -142,6 +143,9 @@ pub(crate) use slot_rep::{
};

pub(crate) use dispatch::{lower_expr, lower_math_operand};
pub(crate) use scalar_slot_root::{
root_scalar_replaced_slot, root_scalar_replaced_slot_unconditional,
};
pub(crate) use shadow_slot::{
emit_shadow_slot_bind_for_local, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr,
enable_persistent_shadow_slot_for_array_alias, expr_is_known_non_pointer_shadow_value,
Expand Down Expand Up @@ -1006,6 +1010,14 @@ pub(crate) struct FnCtx<'a> {
/// original receiver. Only fused string operations may consume it.
pub scalar_replaced_uppercase_sources: std::collections::HashMap<u32, String>,

/// Shadow-frame slot reserved for a scalar-replacement alloca, keyed by
/// the alloca's SSA name (#6968). These allocas belong to no HIR local,
/// so `collect_pointer_typed_locals` cannot see them and the frame is
/// grown on demand — see `expr::scalar_slot_root`. Populated the first
/// time a possibly-pointer value is stored into the alloca; a field that
/// only ever holds numbers never appears here and costs nothing.
pub scalar_slot_shadow_slots: std::collections::HashMap<String, u32>,

/// Non-escaping array literals identified by escape analysis. Maps
/// local_id → length. Used by the Stmt::Let lowering to intercept
/// `let arr = [a, b, c]` and emit per-index allocas instead of a
Expand Down
14 changes: 14 additions & 0 deletions crates/perry-codegen/src/expr/property_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
val_double.clone()
};
ctx.block().store(DOUBLE, &stored_value, &slot);
// #6968: bind the field alloca as a precise GC root, the
// same treatment `emit_shadow_slot_update_for_expr` gives
// an ordinary pointer-typed local. Skipped for a
// `numeric_store`, whose stored bits are a canonicalized
// raw `f64` by construction.
if !numeric_store {
crate::expr::root_scalar_replaced_slot(ctx, &slot, value);
}
// String-alias fix (mirror of `let y = x` in stmt/let_stmt.rs):
// a string-typed local stored into a scalar-replaced field's
// alloca slot aliases the same heap buffer. The runtime
Expand Down Expand Up @@ -301,6 +309,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
val_double.clone()
};
ctx.block().store(DOUBLE, &stored_value, &slot);
// #6968: see the `ScalarObjectFieldSet` path above —
// an inlined constructor's `this.f = …` writes the
// same kind of unrooted per-field alloca.
if !numeric_store {
crate::expr::root_scalar_replaced_slot(ctx, &slot, value);
}
// String-alias fix: see the ScalarObjectFieldSet path
// above. `this.field = s` into a scalar-replaced ctor slot
// aliases the string buffer; mark it shared so a later
Expand Down
99 changes: 99 additions & 0 deletions crates/perry-codegen/src/expr/scalar_slot_root.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! GC rooting for scalar-replaced object fields and array elements (#6968).
//!
//! # The hole this closes
//!
//! Scalar replacement turns `const o = { a: fresh(), b: n }` into one
//! entry-block alloca per field and deletes the object. Those allocas belong
//! to no HIR local, so `collect_pointer_typed_locals` — which assigns shadow
//! slots by walking `Stmt::Let` — never sees them, and nothing ever calls
//! `js_shadow_slot_bind` for them:
//!
//! ```llvm
//! %r13 = call double @perry_fn_m__fresh(double 0.0)
//! store double %r13, ptr %r10 ; o.a — a bare, unrooted alloca
//! %r16 = call double @perry_fn_m__churn(double %r15) ; collects; %r10 swept
//! ```
//!
//! The object *local* does get a slot reserved (it is pointer-typed), but
//! lowering only ever clears it — there is no object handle to bind, which is
//! why #6951/#6972's object-literal rooting does not reach this shape.
//!
//! Until #6977 this was invisible: `gc_check_trigger` forces a conservative
//! native-stack scan, which finds the alloca. With precise roots only
//! (`PERRY_CONSERVATIVE_STACK_SCAN=off`) the value is swept out from under
//! the alloca and the program reads freed memory.
//!
//! # What is emitted
//!
//! At each store into such an alloca whose value may be a heap reference:
//! reserve one shadow-frame slot for that alloca (once) and bind it, exactly
//! as `expr::emit_shadow_slot_update_for_expr` does for an ordinary
//! pointer-typed local. `js_shadow_slot_bind` records `slot_ptrs[slot] =
//! alloca`, so both a mark-sweep root walk and an evacuating minor's
//! rewrite pass reach the real alloca rather than a stale mirror.
//!
//! The bind is not repeated per alloca-per-store *shape*, only per store
//! *site*: the alloca is entry-hoisted and never moves, so one bind covers
//! the rest of the frame's life. Re-binding at a later store to the same
//! field is what re-runs the incremental-mark root barrier, which is
//! required for the same reason an ordinary local's re-assignment re-binds.
//!
//! # The gate
//!
//! Emission is skipped when the stored value cannot be a heap reference.
//! That decision is made from the **lowering**, never the declared type
//! (#6997): the predicate is `expr_is_known_non_pointer_shadow_value`, the
//! very one that decides whether an ordinary pointer local's slot is bound
//! or cleared. A field whose values are all numbers therefore costs nothing
//! — no slot, no call, no growth of the frame.

use super::*;

use perry_hir::Expr;

use crate::types::{I32, PTR};

/// Root the scalar-replacement alloca `slot` against the value expression
/// that was just stored into it.
///
/// Call *after* the `store` — `js_shadow_slot_bind` reads the alloca to seed
/// the shadow mirror and to run the root write barrier, so the new value has
/// to be in place. Callers that store a canonicalized raw `f64` (the
/// `numeric_store` arm of `expr::property_set`) must not call this at all:
/// those bits are a plain double by construction, and the shared root-word
/// decoder rejects them, but reserving a slot for them would be pure waste.
pub(crate) fn root_scalar_replaced_slot(ctx: &mut FnCtx<'_>, slot: &str, value: &Expr) {
if expr_is_known_non_pointer_shadow_value(ctx, value) {
return;
}
bind_scalar_replaced_slot(ctx, slot);
}

/// Root a scalar-replacement alloca whose stored value has no HIR expression
/// to gate on because codegen synthesized it.
///
/// Used by the scalar-replaced `String.prototype.split` arm, whose element
/// slots receive `js_string_split_part_value` results — heap strings with
/// nothing else referring to them.
pub(crate) fn root_scalar_replaced_slot_unconditional(ctx: &mut FnCtx<'_>, slot: &str) {
bind_scalar_replaced_slot(ctx, slot);
}

fn bind_scalar_replaced_slot(ctx: &mut FnCtx<'_>, slot: &str) {
let slot_idx = match ctx.scalar_slot_shadow_slots.get(slot).copied() {
Some(idx) => idx,
None => {
// `None` means shadow-stack emission is off for this build; the
// caller must not emit slot traffic either.
let Some(idx) = ctx.func.reserve_shadow_slot() else {
return;
};
ctx.scalar_slot_shadow_slots.insert(slot.to_string(), idx);
idx
}
};
ctx.block().call_void(
"js_shadow_slot_bind",
&[(I32, &slot_idx.to_string()), (PTR, slot)],
);
}
Loading
Loading