Skip to content
Closed
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
3 changes: 3 additions & 0 deletions changelog.d/11177-closure-box-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Share compiler-declared boxed-capture layouts by function pointer. Generated closures now register a constant bitmap in one batch and retain only a weak owner address, replacing the per-instance capture-index/cell list. Fresh boxed closures also use bulk capture initialization. Per-box lifetime counts remain in place so frame and async release still wait for the final escaped capture.

GC tracing, relocation, death pruning, singleton reuse, and runtime clones use the shared layout. The existing dynamic setter retains its exact-edge representation for compatibility. Regression coverage includes sparse layouts beyond 64 slots, duplicate edges, dynamic replacement, clone rebinding, released payload tracing, and wide/async compiled closures.
87 changes: 47 additions & 40 deletions crates/perry-codegen/src/expr/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// is created per-call but always with the same `this` (the
// World) and same captures (`this._changeset`).
// Boxed captures may use the bulk cache helper, but codegen still
// follows it with `js_closure_set_box_capture_ptr` for only those
// slots. The idempotent write declares exact lifetime edges
// follows it with `js_closure_register_box_layout`. The idempotent
// registration declares exact lifetime edges
// without guessing from arbitrary pointer-shaped values.
//
// IDENTITY CAVEAT (#4831 follow-up — Stripe `protoExtend`):
Expand Down Expand Up @@ -370,16 +370,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
None
};

// Bulk-init admission: a fresh closure whose captures are all plain
// bits. Box-cell captures keep the per-slot setter path — their
// `set_closure_box_capture` bookkeeping has no bulk twin.
// Fresh closures can initialize all captures in bulk. Box lifetime
// edges are registered afterward, independently of the stores.
let bulk_fresh_init = !no_capture_singleton
&& !captured_singleton
&& total_caps > 0
&& !captured_value_bits.is_empty()
&& auto_captures.iter().all(|cap_id| {
!ctx.boxed_vars.contains(cap_id) || uncounted_box_capture(cap_id)
});
&& !captured_value_bits.is_empty();
let closure_handle = if no_capture_singleton {
let blk = ctx.block();
blk.call(I64, "js_closure_alloc_singleton", &[(PTR, &func_ref)])
Expand Down Expand Up @@ -460,41 +456,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
.call_void("js_register_closure_async_function", &[(PTR, &func_ref)]);
}

// The captured-singleton helper writes captures internally. Boxed
// slots still take the dedicated, idempotent setter afterward so
// their lifetime edges are declared; fresh closures need every
// slot initialized here. The compiler-private plain-async step
// closure is different: its activation refcount already covers
// every queued/running instance of its OWN cells, so declaring its
// whole boxed frame as escaped would delay every terminal cell
// until a full GC. User closures nested inside it still take the
// dedicated setter and therefore preserve #8213's escaped-cell
// lifetime, and so does a step closure's capture of an enclosing
// scope's cell (#10464).
let tracked_box_capture_slots = auto_captures
.iter()
.map(|cap_id| ctx.boxed_vars.contains(cap_id) && !uncounted_box_capture(cap_id))
.collect::<Vec<_>>();
let blk = ctx.block();
for (idx, val_bits) in captured_value_bits.iter().enumerate() {
let track_box_capture = tracked_box_capture_slots[idx];
if bulk_fresh_init {
// Every slot was written by `js_closure_alloc_init`.
continue;
// Lifetime edges are declared once after initialization, using a
// function-wide immutable bitmap. The async step's own cells are
// retained by its activation; only enclosing cells escape here.
let mut box_mask = vec![0u64; auto_captures.len().div_ceil(64)];
for (index, cap_id) in auto_captures.iter().enumerate() {
if ctx.boxed_vars.contains(cap_id) && !uncounted_box_capture(cap_id) {
box_mask[index / 64] |= 1 << (index % 64);
}
if !captured_singleton || track_box_capture {
let idx_str = idx.to_string();
let setter = if track_box_capture {
"js_closure_set_box_capture_ptr"
} else {
"js_closure_set_capture_bits"
};
blk.call_void(
setter,
&[(I64, &closure_handle), (I32, &idx_str), (I64, val_bits)],
}
if !bulk_fresh_init && !captured_singleton {
for (idx, val_bits) in captured_value_bits.iter().enumerate() {
ctx.block().call_void(
"js_closure_set_capture_bits",
&[
(I64, &closure_handle),
(I32, &idx.to_string()),
(I64, val_bits),
],
);
}
}
if box_mask.iter().any(|&word| word != 0) {
let name = format!(
"perry_box_layout_{}_{}",
ctx.strings.module_prefix(),
ctx.ic_site_counter
);
ctx.ic_site_counter += 1;
let words = box_mask
.iter()
.map(|word| format!("i64 {word}"))
.collect::<Vec<_>>()
.join(", ");
ctx.typed_parse_rodata.push(format!(
"@{name} = private unnamed_addr constant [{} x i64] [{words}]",
box_mask.len(),
));
ctx.block().call_void(
"js_closure_register_box_layout",
&[
(I64, &closure_handle),
(PTR, &format!("@{name}")),
(I32, &box_mask.len().to_string()),
],
);
}
// Issue #291: when the closure is built inside a method
// body (or constructor), the enclosing frame's `this` is the
// topmost entry on `this_stack`; load and write that into
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ pub(crate) struct FnCtx<'a> {
pub resolved_versioned_loop_callback_targets: std::collections::HashMap<(u32, usize), String>,
/// This is an internal clone of a compiler-proven direct arrow body. Its
/// boxed capture slots were installed through
/// `js_closure_set_box_capture_ptr`, so captured-box accesses may use the
/// `js_closure_register_box_layout`, so captured-box accesses may use the
/// raw helpers. Public and dynamically dispatched closure bodies keep the
/// defensive runtime registry validation.
pub trusted_box_captures: bool,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/gc_call_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect {
| "js_closure_get_capture_bits"
| "js_closure_set_capture_bits"
| "js_closure_set_box_capture_ptr"
| "js_closure_register_box_layout"
| "js_closure_get_capture_ptr"
| "js_closure_set_capture_ptr"
// Variable-box accessors and allocators (#8132), `box.rs`. Boxes are
Expand Down Expand Up @@ -836,6 +837,7 @@ mod tests {
"js_closure_get_capture_bits",
"js_closure_set_capture_bits",
"js_closure_set_box_capture_ptr",
"js_closure_register_box_layout",
"js_closure_get_capture_ptr",
"js_closure_set_capture_ptr",
"js_box_alloc_bits",
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/root_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ const NON_COLLECTING: &[&str] = &[
// verified non-allocating bookkeeping stores/reads
"js_closure_set_capture_bits",
"js_closure_set_box_capture_ptr",
"js_closure_register_box_layout",
"js_closure_get_capture_bits",
"js_closure_set_capture_ptr",
"js_closure_get_capture_ptr",
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
);
module.declare_function("js_closure_set_capture_bits", VOID, &[I64, I32, I64]);
module.declare_function("js_closure_set_box_capture_ptr", VOID, &[I64, I32, I64]);
module.declare_function("js_closure_register_box_layout", VOID, &[I64, PTR, I32]);
module.declare_function("js_closure_get_capture_bits", I64, &[I64, I32]);
module.declare_function("js_closure_set_capture_f64", VOID, &[I64, I32, DOUBLE]);
module.declare_function("js_closure_get_capture_f64", DOUBLE, &[I64, I32]);
Expand Down
40 changes: 38 additions & 2 deletions crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ fn captured_reassigned_let_is_released_at_every_return() {
"premise: n is boxed\n{ir}"
);
assert!(
ir.contains("js_closure_set_box_capture_ptr"),
ir.contains("js_closure_register_box_layout"),
"premise: counted edge\n{ir}"
);
let per_ret = releases_before_each_ret(&ir);
Expand Down Expand Up @@ -280,7 +280,7 @@ fn plain_async_step_counts_only_enclosing_cells_and_frame_keeps_its_own() {
"premise: both cells are minted by this frame:\n{ir}"
);
assert_eq!(
ir.matches("call void @js_closure_set_box_capture_ptr(")
ir.matches("call void @js_closure_register_box_layout(")
.count(),
1,
"the enclosing cell is a counted edge, the activation's own is not:\n{ir}"
Expand All @@ -291,3 +291,39 @@ fn plain_async_step_counts_only_enclosing_cells_and_frame_keeps_its_own() {
"the frame releases OUTER only; OWN belongs to the activation ({per_ret:?}):\n{ir}"
);
}

#[test]
fn wide_box_captures_emit_one_constant_bitmap_and_one_registration() {
let ids: Vec<u32> = (100..166).collect();
let mut body: Vec<Stmt> = ids
.iter()
.map(|&id| let_stmt(id, Expr::Integer(0)))
.collect();
let nested_body = ids
.iter()
.map(|&id| Stmt::Expr(Expr::LocalSet(id, Box::new(Expr::Integer(1)))))
.collect();
body.push(Stmt::Return(Some(closure(2, nested_body, ids))));
let mut module = Module::new("wide_box_layout.ts");
module
.functions
.push(function("wide_boxes", Vec::new(), body));
let ir = String::from_utf8(
crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(),
)
.unwrap();
assert!(
ir.contains("constant [2 x i64] [i64 18446744073709551615, i64 3]"),
"{ir}"
);
assert_eq!(
ir.matches("call void @js_closure_register_box_layout(")
.count(),
1,
"{ir}"
);
assert!(
!ir.contains("call void @js_closure_set_box_capture_ptr("),
"{ir}"
);
}
2 changes: 1 addition & 1 deletion crates/perry-codegen/tests/native_proof_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7856,7 +7856,7 @@ fn boxed_local_slot_uses_i64_js_value_bits_until_helper_edges() {
);
assert!(
ir.contains("call i64 @js_closure_get_capture_bits")
&& ir.contains("call void @js_closure_set_box_capture_ptr"),
&& ir.contains("call void @js_closure_register_box_layout"),
"generated boxed capture traffic should declare exact i64 box capture slots:\n{ir}"
);
for old_helper in [
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/tests/release_boxes_lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ fn release_boxes_lowers_through_closure_captures() {
);
}
assert!(
!ir.contains("call void @js_closure_set_box_capture_ptr("),
!ir.contains("call void @js_closure_register_box_layout("),
"the compiler-private step closure is covered by the activation refcount, \
so its complete frame must not become escaped GC-closure edges:\n{ir}"
);
Expand Down Expand Up @@ -276,7 +276,7 @@ fn escaped_user_closure_inside_step_keeps_its_box_capture_edge() {
let ir = ir_for_fn_body("release_nested_user_capture", body);
let tracked_edges = ir
.lines()
.filter(|line| line.contains("call void @js_closure_set_box_capture_ptr("))
.filter(|line| line.contains("call void @js_closure_register_box_layout("))
.count();
assert_eq!(
tracked_edges, 1,
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ fn box_get_bits_named(ptr: *mut Box, name: f64) -> i64 {
/// Raw read for an internal closure body selected from an exact arrow target.
///
/// Codegen emits this only for capture slots installed by
/// `js_closure_set_box_capture_ptr`. The public closure body retains
/// `js_closure_register_box_layout`. The public closure body retains
/// `js_box_get_bits` and its authoritative registry check. A live closure's
/// exact capture edge keeps the non-moving box cell from being published for
/// reuse, so the compiler-installed pointer stays valid for this call. TDZ
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/closure/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ pub extern "C" fn js_closure_set_box_capture_ptr(
index: u32,
value: i64,
) {
super::box_captures::prepare_dynamic_box_capture(closure);
js_closure_set_capture_bits(closure, index, value as u64);
let cell = crate::r#box::registered_box_capture_addr(value as usize);
super::box_captures::set_closure_box_capture(closure, index, cell);
Expand Down
48 changes: 44 additions & 4 deletions crates/perry-runtime/src/closure/box_captures.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Lifetime bridge between GC closures and malloc-side async box cells.
//!
//! Codegen identifies the capture slots which contain raw box addresses. We
//! Codegen identifies raw box slots through the shared `box_layout` bitmap.
//! This module keeps per-cell counts and legacy dynamically declared edges. We
//! count only those declared edges before a box reaches terminal
//! `ReleaseBoxes`; arbitrary JS values must never be guessed to be boxes from
//! pointer-shaped bits. Once its async activation drains, the box runtime
Expand Down Expand Up @@ -55,7 +56,8 @@ impl BoxCaptureSlots {
}

crate::perry_thread_local! {
/// Closure address -> compiler-declared `(capture index, box address)` edges.
/// Legacy/dynamically patched closures only. Generated closures use the
/// shared function bitmap and weak owner set in `box_layout`.
static CLOSURE_BOX_CELLS: RefCell<crate::fast_hash::PtrHashMap<usize, BoxCaptureSlots>> =
RefCell::new(crate::fast_hash::new_ptr_hash_map());
/// Box address -> packed edge record: the number of capture slots naming
Expand All @@ -73,7 +75,7 @@ const FRAME_RELEASE_TAG_SHIFT: u32 = 60;
const FRAME_RELEASE_TAG_MASK: usize = 0b11 << FRAME_RELEASE_TAG_SHIFT;
const EDGE_COUNT_MASK: usize = (1 << FRAME_RELEASE_TAG_SHIFT) - 1;

fn increment_cell_capture_count(cell: usize, amount: usize) {
pub(super) fn increment_cell_capture_count(cell: usize, amount: usize) {
BOX_CAPTURE_COUNTS.with(|counts| {
let mut counts = counts.borrow_mut();
let record = counts.entry(cell).or_default();
Expand All @@ -85,7 +87,7 @@ fn increment_cell_capture_count(cell: usize, amount: usize) {
});
}

fn decrement_cell_capture_count(cell: usize, amount: usize) {
pub(super) fn decrement_cell_capture_count(cell: usize, amount: usize) {
// `Some(record)` when the final edge disappeared.
let reached_zero = BOX_CAPTURE_COUNTS.with(|counts| {
let mut counts = counts.borrow_mut();
Expand Down Expand Up @@ -173,6 +175,7 @@ pub(crate) fn visit_closure_box_payload_slots_mut(closure: usize, mut visit: imp
if closure == 0 || !crate::gc::full_trace_active() {
return;
}
super::box_layout::visit_payloads(closure, &mut visit);
CLOSURE_BOX_CELLS.with(|captures| {
let captures = captures.borrow();
let Some(cells) = captures.get(&closure) else {
Expand All @@ -184,6 +187,27 @@ pub(crate) fn visit_closure_box_payload_slots_mut(closure: usize, mut visit: imp
});
}

pub(super) fn has_dynamic_box_captures(closure: *mut ClosureHeader) -> bool {
CLOSURE_BOX_CELLS.with(|all| {
let all = all.borrow();
!all.is_empty() && all.contains_key(&(closure as usize))
})
}

/// Preserve counted edges before a legacy caller mutates a shared-layout closure.
pub(super) fn prepare_dynamic_box_capture(closure: *mut ClosureHeader) {
let edges = super::box_layout::take_dynamic_edges(closure);
if !edges.is_empty() {
CLOSURE_BOX_CELLS.with(|all| {
let mut slots = BoxCaptureSlots::default();
for (index, cell) in edges {
slots.push(index, cell);
}
all.borrow_mut().insert(closure as usize, slots);
});
}
}

/// Record a compiler-declared boxed capture slot.
pub(super) fn set_closure_box_capture(
closure: *mut ClosureHeader,
Expand Down Expand Up @@ -225,6 +249,19 @@ pub(crate) fn clone_closure_box_captures(
if source.is_null() || destination.is_null() || source.cast_mut() == destination {
return;
}
if super::box_layout::clone_owner(source, destination) {
return;
}
let shared_edges = super::box_layout::copy_edges(source);
if !shared_edges.is_empty() {
for (index, _) in shared_edges {
let cell = crate::r#box::registered_box_capture_addr(
super::js_closure_get_capture_bits(destination, index) as usize,
);
set_closure_box_capture(destination, index, cell);
}
return;
}
let source = source as usize;
let destination = destination as usize;
let copied = CLOSURE_BOX_CELLS.with(|all| {
Expand All @@ -245,6 +282,7 @@ pub(crate) fn closure_box_captures_owner_moved(old_owner: usize, new_owner: usiz
if old_owner == 0 || new_owner == 0 || old_owner == new_owner {
return;
}
super::box_layout::owner_moved(old_owner, new_owner);
CLOSURE_BOX_CELLS.with(|all| {
let mut all = all.borrow_mut();
if let Some(cells) = all.remove(&old_owner) {
Expand All @@ -255,6 +293,7 @@ pub(crate) fn closure_box_captures_owner_moved(old_owner: usize, new_owner: usiz
}

pub(crate) fn prune_dead_closure_box_capture_owners(is_dead_closure: &dyn Fn(usize) -> bool) {
super::box_layout::prune(is_dead_closure);
// One pass: dropping a dead owner and collecting its edges together avoids
// a second probe per dead closure. Counts (and any publication they
// trigger) are settled after the table borrow ends.
Expand All @@ -276,6 +315,7 @@ pub(crate) fn prune_dead_closure_box_capture_owners(is_dead_closure: &dyn Fn(usi

#[cfg(test)]
pub(crate) fn test_clear_closure_box_capture_indexes() {
super::box_layout::clear();
CLOSURE_BOX_CELLS.with(|all| all.borrow_mut().clear());
BOX_CAPTURE_COUNTS.with(|all| all.borrow_mut().clear());
}
Loading
Loading