From c866804fa17af1ba4179a74625850627287027c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 19 Aug 2026 01:45:19 +0200 Subject: [PATCH 1/2] perf(codegen): skip redundant async frame capture tracking --- changelog.d/8384-async-box-frame-cost.md | 7 ++ crates/perry-codegen/src/expr/closure.rs | 68 +++++++++++++++++-- .../tests/release_boxes_lowering.rs | 57 ++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 changelog.d/8384-async-box-frame-cost.md diff --git a/changelog.d/8384-async-box-frame-cost.md b/changelog.d/8384-async-box-frame-cost.md new file mode 100644 index 0000000000..05241cf59e --- /dev/null +++ b/changelog.d/8384-async-box-frame-cost.md @@ -0,0 +1,7 @@ +## Restore async frame release selectivity + +Compiler-private async step closures no longer register their complete boxed +activation frame as escaped closure edges. Their queued and running lifetime is +already covered by the async activation token, so terminal cells can publish +as soon as that token drains. User closures created inside the step keep the +exact per-cell capture tracking added for #8213. diff --git a/crates/perry-codegen/src/expr/closure.rs b/crates/perry-codegen/src/expr/closure.rs index 221a33bd48..9a22c66624 100644 --- a/crates/perry-codegen/src/expr/closure.rs +++ b/crates/perry-codegen/src/expr/closure.rs @@ -5,13 +5,66 @@ //! `lower_expr`'s outer dispatch. use anyhow::Result; -use perry_hir::Expr; +use perry_hir::{Expr, Stmt}; use crate::type_analysis::compute_auto_captures; use crate::types::{DOUBLE, I32, I64, PTR}; use super::{lower_expr, nanbox_pointer_inline, FnCtx}; +/// Whether this is the compiler-private step closure for a lowered plain +/// async activation. `ReleaseBoxes` is emitted only in that closure's +/// terminal arms; user-authored closures can never contain it. +/// +/// Queued and running instances of this closure are already covered by the +/// activation token's refcount. Counting its boxed capture slots as escaping +/// GC-closure edges would make every cell in the complete activation frame +/// wait for a full collection, even when no user closure can observe it. +fn is_plain_async_step_body(stmts: &[Stmt]) -> bool { + stmts.iter().any(|stmt| match stmt { + Stmt::ReleaseBoxes(_) => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + is_plain_async_step_body(then_branch) + || else_branch.as_deref().is_some_and(is_plain_async_step_body) + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => is_plain_async_step_body(body), + Stmt::For { init, body, .. } => { + init.as_deref() + .is_some_and(|stmt| is_plain_async_step_body(std::slice::from_ref(stmt))) + || is_plain_async_step_body(body) + } + Stmt::Try { + body, + catch, + finally, + } => { + is_plain_async_step_body(body) + || catch + .as_ref() + .is_some_and(|catch| is_plain_async_step_body(&catch.body)) + || finally.as_deref().is_some_and(is_plain_async_step_body) + } + Stmt::Switch { cases, .. } => cases + .iter() + .any(|case| is_plain_async_step_body(&case.body)), + Stmt::Labeled { body, .. } => is_plain_async_step_body(std::slice::from_ref(body.as_ref())), + Stmt::Let { .. } + | Stmt::Expr(_) + | Stmt::Return(_) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::Throw(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => false, + }) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::Closure { @@ -324,16 +377,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // 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. + // slot initialized here. The compiler-private plain-async step + // closure is different: its activation refcount already covers + // every queued/running instance, 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. + let is_plain_async_step = is_plain_async_step_body(body); let boxed_capture_slots = auto_captures .iter() .map(|cap_id| ctx.boxed_vars.contains(cap_id)) .collect::>(); let blk = ctx.block(); for (idx, val_bits) in captured_value_bits.iter().enumerate() { - if !captured_singleton || boxed_capture_slots[idx] { + let track_box_capture = boxed_capture_slots[idx] && !is_plain_async_step; + if !captured_singleton || track_box_capture { let idx_str = idx.to_string(); - let setter = if boxed_capture_slots[idx] { + let setter = if track_box_capture { "js_closure_set_box_capture_ptr" } else { "js_closure_set_capture_bits" diff --git a/crates/perry-codegen/tests/release_boxes_lowering.rs b/crates/perry-codegen/tests/release_boxes_lowering.rs index 89828a9faf..0371bd9fb4 100644 --- a/crates/perry-codegen/tests/release_boxes_lowering.rs +++ b/crates/perry-codegen/tests/release_boxes_lowering.rs @@ -218,6 +218,63 @@ fn release_boxes_lowers_through_closure_captures() { "release must lower inside the step closure (missing `{call}`):\n{ir}" ); } + assert!( + !ir.contains("call void @js_closure_set_box_capture_ptr("), + "the compiler-private step closure is covered by the activation refcount, \ + so its complete frame must not become escaped GC-closure edges:\n{ir}" + ); +} + +/// #8213 still requires exact lifetime tracking for a user closure created by +/// the generated step. Only the nested user closure should declare an edge; +/// the step closure's own complete-frame captures are activation-owned. +#[test] +fn escaped_user_closure_inside_step_keeps_its_box_capture_edge() { + let mut body = activation_frame(); + body.push(Stmt::Expr(Expr::Closure { + func_id: 900, + params: Vec::new(), + return_type: Type::Any, + body: vec![ + Stmt::Expr(Expr::Closure { + func_id: 901, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::LocalGet(SENT)))], + captures: vec![SENT], + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_strict: false, + is_async: false, + is_generator: false, + }), + Stmt::ReleaseBoxes(vec![STATE, DONE, SENT]), + Stmt::Return(Some(Expr::Undefined)), + ], + captures: vec![STATE, DONE, SENT], + mutable_captures: vec![STATE, DONE, SENT], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_strict: false, + is_async: false, + is_generator: false, + })); + body.push(Stmt::Return(Some(Expr::Undefined))); + + 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(")) + .count(); + assert_eq!( + tracked_edges, 1, + "only the nested user closure should retain the released box cell:\n{ir}" + ); } /// A release with no visible cell must be a silent skip, not an error: the From 47803138a0694e36bad54ab1f920f8a145fb76f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 19 Aug 2026 01:46:30 +0200 Subject: [PATCH 2/2] chore: name changelog fragment for PR 8392 --- ...{8384-async-box-frame-cost.md => 8392-async-box-frame-cost.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{8384-async-box-frame-cost.md => 8392-async-box-frame-cost.md} (100%) diff --git a/changelog.d/8384-async-box-frame-cost.md b/changelog.d/8392-async-box-frame-cost.md similarity index 100% rename from changelog.d/8384-async-box-frame-cost.md rename to changelog.d/8392-async-box-frame-cost.md