diff --git a/changelog.d/8589-root-spill.md b/changelog.d/8589-root-spill.md new file mode 100644 index 0000000000..9ce5caba4a --- /dev/null +++ b/changelog.d/8589-root-spill.md @@ -0,0 +1,3 @@ +### Changed + +- Native GC-root spilling (#8583): a function whose estimated statepoint relocation count — `live_root_slots × safepoints` — exceeds `PERRY_ROOT_SPILL_RELOCATIONS` (default 4,000,000) keeps its GC roots in a heap shadow frame instead of native statepoints. `rewrite-statepoints-for-gc` adds one relocation per live root per safepoint, so a minified-bundle entry function (measured: 795 root slots × ~106k safepoints, grown 439k → 6.5M instructions under RS4GC) drove the `-Os` middle-end super-linear and did not finish; the same unit optimizes in ~5s once that one function is spilled. The function is still compiled at the requested optimization level — only its root representation changes — and its roots stay precise: the runtime already scans shadow-frame and stack-map roots in one walk, and the frame pointer is kept so the FP-chain walker steps over the spilled frame. Each spilled function is reported at default verbosity. `PERRY_ROOT_SPILL_RELOCATIONS=0` disables spilling (every function on native statepoints, the previous behavior). diff --git a/changelog.d/8593-native-unit-workers.md b/changelog.d/8593-native-unit-workers.md new file mode 100644 index 0000000000..ee2652e500 --- /dev/null +++ b/changelog.d/8593-native-unit-workers.md @@ -0,0 +1,3 @@ +### Changed + +- The default number of concurrent native LLVM codegen-unit workers is now CPU-aware on non-Windows: half the machine's logical CPUs, clamped to `[2, 8]`, instead of a hard-coded `2` (#8583). The `2` default (#8017) was chosen for Windows pagefile pressure and applied everywhere; on a large real bundle (the Claude Code `cli.js` lowers to ~84 units) it left most cores idle while dozens of ~7-minute units ran two at a time. With the giant entry function's roots spilled (#8583) no unit carries an unbounded RS4GC fan-out, so per-unit peak RSS is a bounded ~1-2 GiB and the two-worker cap — not memory — was the wall. Windows keeps the conservative `2`. `PERRY_CODEGEN_UNIT_JOBS` still overrides on every platform. diff --git a/changelog.d/8597-short-heap-string-equality.md b/changelog.d/8597-short-heap-string-equality.md new file mode 100644 index 0000000000..7c329eac39 --- /dev/null +++ b/changelog.d/8597-short-heap-string-equality.md @@ -0,0 +1,5 @@ +Improved equality checks between statically proven strings by comparing heap-string +lengths and up to three payload bytes inline before falling back to the full runtime +helper. This targets the short identifiers used by tree-walking interpreters: retired +instructions fell 2.41% for `interp` and 1.62% for `iso_miss`, with RSS effectively +unchanged. Generic-key comparisons retain the smaller existing dispatch. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index d5e6a1543d..05fbfb3f43 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -612,6 +612,12 @@ pub(super) fn compile_closure( // which spans the body. let capture_root_slots = u32::from(captures_this || enclosing_class.is_some()) + u32::from(captures_new_target); + crate::codegen::helpers::maybe_spill_roots_to_shadow_frame( + lf, + &llvm_name, + m.len() + capture_root_slots as usize, + body, + ); lf.enable_shadow_frame(m.len() as u32 + capture_root_slots); m } else { diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 313675d96e..a0a2b91e54 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -554,6 +554,12 @@ pub(super) fn compile_function( cross_module.flat_const_arrays.keys().copied().collect(); let m = crate::collectors::collect_pointer_typed_locals(&f.params, &f.body, &flat_const_ids); + crate::codegen::helpers::maybe_spill_roots_to_shadow_frame( + lf, + &llvm_name, + m.len(), + &f.body, + ); lf.enable_shadow_frame(m.len() as u32); m } else { diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 0f54dcb522..88ccc1f869 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -357,6 +357,74 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 { }) } +/// #8583: statepoint relocation estimate above which a function keeps its GC +/// roots in a shadow frame instead of native statepoints. +/// +/// `rewrite-statepoints-for-gc` adds one relocation per GC value live across +/// each safepoint, so the optimizer's post-rewrite cost scales with +/// `live_roots × safepoints`. Past a point that fan-out makes the `-Os`/`-O3` +/// middle-end super-linear and the compile does not finish (the Claude Code +/// bundle's 68 MB entry body measured 795 root slots × ~106k safepoints ≈ 8.4e7 +/// and grew 439k → 6.5M instructions under RS4GC; without RS4GC the same unit +/// optimized at `-Os` in ~5s). Real functions sit orders of magnitude below +/// this: hundreds of call sites times tens of slots is ~1e4–1e5. The default +/// is set well under the measured pathological point and well over ordinary +/// code, and the post-RS4GC instruction-budget assertion (#8583, inprocess.rs) +/// backstops any function the estimate misses. +/// +/// `PERRY_ROOT_SPILL_RELOCATIONS=` overrides it; `0` disables spilling +/// (every function stays on native statepoints, the pre-#8583 behavior). +const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 4_000_000; + +fn root_spill_relocation_threshold() -> usize { + std::env::var("PERRY_ROOT_SPILL_RELOCATIONS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(DEFAULT_ROOT_SPILL_RELOCATIONS) +} + +/// The relocation estimate for a function with `slot_count` GC-root slots and +/// a body containing `safepoint_sites` call-like expressions. Saturating so a +/// pathological product cannot wrap. +pub(crate) fn root_relocation_estimate(slot_count: usize, safepoint_sites: usize) -> usize { + slot_count.saturating_mul(safepoint_sites) +} + +/// Decide whether `func` should spill its roots to the shadow frame, and if so +/// mark it (BEFORE its `enable_*_shadow_frame` call) and report it. Only +/// meaningful under native stack-map roots — the shadow frame is already the +/// lowering otherwise. Reporting is at default verbosity because #8421 requires +/// that a change to how a function is compiled is never silent; the message +/// states that the optimization level is unchanged. +pub(super) fn maybe_spill_roots_to_shadow_frame( + func: &mut crate::function::LlFunction, + fn_name: &str, + slot_count: usize, + body: &[perry_hir::Stmt], +) { + if !native_stack_roots_enabled() { + return; + } + let threshold = root_spill_relocation_threshold(); + if threshold == 0 { + return; + } + let sites = crate::collectors::count_safepoint_sites(body); + let estimate = root_relocation_estimate(slot_count, sites); + if estimate <= threshold { + return; + } + func.request_shadow_frame_spill(); + eprintln!( + "perry: `{fn_name}` keeps its {slot_count} GC roots in a shadow frame instead of \ + statepoints: an estimated {estimate} relocations ({slot_count} roots × {sites} \ + safepoints) would make rewrite-statepoints-for-gc fan-out super-linear in the \ + optimizer (> {threshold}). The function is still compiled at the requested \ + optimization level; only its GC-root representation changes, and its roots stay \ + precise (#8583). Override with PERRY_ROOT_SPILL_RELOCATIONS." + ); +} + pub(super) fn enable_module_init_shadow_frame( func: &mut crate::function::LlFunction, stmts: &[perry_hir::Stmt], @@ -368,6 +436,10 @@ pub(super) fn enable_module_init_shadow_frame( let shadow_slot_map = crate::collectors::collect_pointer_typed_locals(&[], stmts, flat_const_ids); + // #8583: the module-entry body is the minified-bundle IIFE — the function + // that fans out catastrophically under RS4GC. Decide its root lowering + // before the frame is built. + maybe_spill_roots_to_shadow_frame(func, "main", shadow_slot_map.len(), stmts); func.enable_post_init_shadow_frame(shadow_slot_map.len() as u32); let shadow_slot_clears_after_stmt = crate::collectors::collect_shadow_slot_clear_points(stmts, &shadow_slot_map); diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 3041666574..23a700d198 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -322,6 +322,12 @@ pub(super) fn compile_method( &method.body, &flat_const_ids, ); + crate::codegen::helpers::maybe_spill_roots_to_shadow_frame( + lf, + &llvm_name, + m.len() + 1, + &method.body, + ); lf.enable_shadow_frame(m.len() as u32 + 1); m } else { @@ -1395,6 +1401,12 @@ pub(super) fn compile_static_method( cross_module.flat_const_arrays.keys().copied().collect(); let m = crate::collectors::collect_pointer_typed_locals(&f.params, &f.body, &flat_const_ids); + crate::codegen::helpers::maybe_spill_roots_to_shadow_frame( + lf, + &llvm_name, + m.len() + 1, + &f.body, + ); lf.enable_shadow_frame(m.len() as u32 + 1); m } else { diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 23a2ce34f2..010c4a513f 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -42,6 +42,7 @@ mod ptr_shape_report; mod ptr_shape_returns; mod refs; mod repsel_benefit; +mod safepoint_sites; mod scalar_method_dispatch; mod scalar_methods; mod shadow_slots; @@ -93,6 +94,7 @@ pub(crate) use ptr_shape_returns::collect_exported_return_shapes; pub(crate) use refs::{ collect_let_ids, collect_ref_ids_in_expr, collect_ref_ids_in_stmts, is_clamp_call, }; +pub(crate) use safepoint_sites::count_safepoint_sites; pub(crate) use scalar_method_dispatch::{ collect_module_dispatch_facts, mark_unstable_scalar_method_receivers, ModuleDispatchFacts, }; diff --git a/crates/perry-codegen/src/collectors/safepoint_sites.rs b/crates/perry-codegen/src/collectors/safepoint_sites.rs new file mode 100644 index 0000000000..c87cd77e46 --- /dev/null +++ b/crates/perry-codegen/src/collectors/safepoint_sites.rs @@ -0,0 +1,219 @@ +//! Count the GC safepoints in a function body (#8583). +//! +//! `rewrite-statepoints-for-gc` inserts, at every safepoint, one relocation +//! per GC value live across it — so the optimizer's post-rewrite work grows +//! with `live_roots × safepoints`. A function whose product is large enough +//! makes the `-Os`/`-O3` middle-end super-linear: the 68 MB minified entry +//! body of the Claude Code bundle measured 795 root slots × ~106k safepoints +//! and grew 439k → 6.5M instructions under RS4GC, and a single `-Os` pass on +//! the result did not finish in practical time (#8583). +//! `codegen/helpers::maybe_spill_roots_to_shadow_frame` multiplies this count +//! by the function's root-slot count and, past a threshold, keeps that +//! function's roots in a shadow frame instead of statepoints. +//! +//! A safepoint is any call-like expression: a call can re-enter the runtime +//! and collect. The count is an over-approximation biased toward spilling — +//! a false positive is a shadow frame on a function that would have been fine +//! (cheap; the shadow lowering is the pre-#7370 default), while a false +//! negative would let relocation fan-out reach the optimizer. Nested closures +//! are NOT counted: each compiles to its own `LlFunction` with its own frame, +//! so its safepoints belong to it (`walk_expr_children` does not descend into +//! a closure's body, only its parameter defaults). + +use perry_hir::{Expr, Stmt}; + +/// Total call-like expressions reachable from `stmts` without descending into +/// nested closures. +pub fn count_safepoint_sites(stmts: &[Stmt]) -> usize { + let mut n = 0usize; + for s in stmts { + count_in_stmt(s, &mut n); + } + n +} + +/// A call-like expression is a potential safepoint: anything whose lowering +/// emits a call that can re-enter the runtime. Nodes not listed contribute +/// nothing themselves but are still recursed into, so adding a new call +/// variant can only make the estimate more conservative (a possible +/// under-count that the post-RS4GC instruction-budget assertion backstops), +/// never wrong in a way that hides a fan-out. +fn is_safepoint(e: &Expr) -> bool { + matches!( + e, + Expr::Call { .. } + | Expr::CallSpread { .. } + | Expr::NativeMethodCall { .. } + | Expr::StaticMethodCall { .. } + | Expr::SuperCall(_) + | Expr::SuperCallSpread(_) + | Expr::SuperMethodCall { .. } + | Expr::SuperMethodCallSpread { .. } + | Expr::ObjectSuperMethodCall { .. } + | Expr::New { .. } + | Expr::NewDynamic { .. } + | Expr::NewDynamicSpread { .. } + | Expr::Await(_) + | Expr::Yield { .. } + | Expr::AsyncFirstCall { .. } + ) +} + +fn count_in_expr(e: &Expr, n: &mut usize) { + if is_safepoint(e) { + *n += 1; + } + // Generic recursion into direct sub-expressions. `walk_expr_children` does + // not descend into a closure's statement body (only its param defaults), + // which is exactly the boundary we want: a nested closure is a separate + // frame and its safepoints are not this function's. + perry_hir::walker::walk_expr_children(e, &mut |child| count_in_expr(child, n)); +} + +fn count_in_stmt(s: &Stmt, n: &mut usize) { + match s { + Stmt::Let { init: Some(e), .. } + | Stmt::Expr(e) + | Stmt::Throw(e) + | Stmt::Return(Some(e)) => count_in_expr(e, n), + Stmt::Let { init: None, .. } | Stmt::Return(None) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + count_in_expr(condition, n); + for st in then_branch { + count_in_stmt(st, n); + } + if let Some(else_branch) = else_branch { + for st in else_branch { + count_in_stmt(st, n); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + count_in_expr(condition, n); + for st in body { + count_in_stmt(st, n); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + count_in_stmt(init, n); + } + if let Some(condition) = condition { + count_in_expr(condition, n); + } + if let Some(update) = update { + count_in_expr(update, n); + } + for st in body { + count_in_stmt(st, n); + } + } + Stmt::Labeled { body, .. } => count_in_stmt(body, n), + Stmt::Try { + body, + catch, + finally, + } => { + for st in body { + count_in_stmt(st, n); + } + if let Some(catch) = catch { + for st in &catch.body { + count_in_stmt(st, n); + } + } + if let Some(finally) = finally { + for st in finally { + count_in_stmt(st, n); + } + } + } + Stmt::Switch { + discriminant, + cases, + } => { + count_in_expr(discriminant, n); + for c in cases { + if let Some(t) = &c.test { + count_in_expr(t, n); + } + for st in &c.body { + count_in_stmt(st, n); + } + } + } + // No expression children. + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } +} + +#[cfg(test)] +mod tests { + use super::count_safepoint_sites; + use perry_hir::types::Type; + use perry_hir::{Expr, Stmt}; + + fn call(args: Vec) -> Expr { + Expr::Call { + callee: Box::new(Expr::Undefined), + args, + type_args: vec![], + byte_offset: 0, + } + } + + fn empty_closure(body: Vec) -> Expr { + Expr::Closure { + func_id: 0, + params: vec![], + return_type: Type::Any, + body, + captures: vec![], + mutable_captures: vec![], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: false, + } + } + + #[test] + fn counts_calls_across_control_flow_but_not_into_closures() { + let stmts = vec![ + Stmt::Expr(call(vec![])), + Stmt::While { + condition: Expr::Bool(true), + body: vec![Stmt::Expr(call(vec![]))], + }, + // A call buried in a nested closure body must NOT be counted. + Stmt::Expr(empty_closure(vec![Stmt::Expr(call(vec![]))])), + Stmt::Return(Some(call(vec![]))), + ]; + assert_eq!(count_safepoint_sites(&stmts), 3); + } + + #[test] + fn call_arguments_are_themselves_safepoints() { + // f(g(), h()) is three calls. + let nested = call(vec![call(vec![]), call(vec![])]); + assert_eq!(count_safepoint_sites(&[Stmt::Expr(nested)]), 3); + } +} diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index ab33db7e17..339208ad2d 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -432,6 +432,119 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { ) } +/// Resolve equal-length heap strings of up to three bytes inline, and reject +/// longer pairs on a length or endpoint mismatch before using the full helper. +/// The caller has already proven both values carry `STRING_TAG`. +fn lower_short_heap_string_eq( + ctx: &mut FnCtx<'_>, + lh: &str, + rh: &str, + true_l: &str, + false_l: &str, + merge_l: &str, +) -> (String, String) { + let heap_valid_idx = ctx.new_block("streq.heap.valid"); + let heap_len_idx = ctx.new_block("streq.heap.len"); + let heap_first_idx = ctx.new_block("streq.heap.first"); + let heap_one_idx = ctx.new_block("streq.heap.one"); + let heap_last_idx = ctx.new_block("streq.heap.last"); + let heap_two_idx = ctx.new_block("streq.heap.two"); + let heap_middle_idx = ctx.new_block("streq.heap.middle"); + let heap_middle_byte_idx = ctx.new_block("streq.heap.middle.byte"); + let heap_slow_idx = ctx.new_block("streq.heap.slow"); + let heap_valid_l = ctx.block_label(heap_valid_idx); + let heap_len_l = ctx.block_label(heap_len_idx); + let heap_first_l = ctx.block_label(heap_first_idx); + let heap_one_l = ctx.block_label(heap_one_idx); + let heap_last_l = ctx.block_label(heap_last_idx); + let heap_two_l = ctx.block_label(heap_two_idx); + let heap_middle_l = ctx.block_label(heap_middle_idx); + let heap_middle_byte_l = ctx.block_label(heap_middle_byte_idx); + let heap_slow_l = ctx.block_label(heap_slow_idx); + + // Preserve `js_string_equals`' handling of deliberately forged low + // `STRING_TAG` payloads: only dereference real heap addresses here. + let lh_valid = ctx.block().icmp_ugt(I64, lh, "4095"); + let rh_valid = ctx.block().icmp_ugt(I64, rh, "4095"); + let both_valid = ctx.block().and(I1, &lh_valid, &rh_valid); + ctx.block() + .cond_br(&both_valid, &heap_valid_l, &heap_slow_l); + + ctx.current_block = heap_valid_idx; + let lp = ctx.block().inttoptr(I64, lh); + let rp = ctx.block().inttoptr(I64, rh); + let llenp = ctx + .block() + .gep_inbounds(I8, &lp, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let rlenp = ctx + .block() + .gep_inbounds(I8, &rp, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let llen = ctx.block().load(I32, &llenp); + let rlen = ctx.block().load(I32, &rlenp); + let same_len = ctx.block().icmp_eq(I32, &llen, &rlen); + ctx.block().cond_br(&same_len, &heap_len_l, false_l); + + ctx.current_block = heap_len_idx; + let empty = ctx.block().icmp_eq(I32, &llen, "0"); + ctx.block().cond_br(&empty, true_l, &heap_first_l); + + // A non-empty, same-length pair can be rejected on its first byte. For a + // one-byte pair that byte also settles equality. + ctx.current_block = heap_first_idx; + let data_off = STRING_HEADER_SIZE.to_string(); + let lfirstp = ctx.block().gep_inbounds(I8, &lp, &[(I64, &data_off)]); + let rfirstp = ctx.block().gep_inbounds(I8, &rp, &[(I64, &data_off)]); + let lfirst = ctx.block().load(I8, &lfirstp); + let rfirst = ctx.block().load(I8, &rfirstp); + let same_first = ctx.block().icmp_eq(I8, &lfirst, &rfirst); + ctx.block().cond_br(&same_first, &heap_one_l, false_l); + + ctx.current_block = heap_one_idx; + let one_byte = ctx.block().icmp_eq(I32, &llen, "1"); + ctx.block().cond_br(&one_byte, true_l, &heap_last_l); + + // The length checks above prove that this dynamic last-byte offset is in + // both payloads. Together with the first byte it settles two-byte strings. + ctx.current_block = heap_last_idx; + let llen64 = ctx.block().zext(I32, &llen, I64); + let last_off = ctx + .block() + .add(I64, &llen64, &(STRING_HEADER_SIZE - 1).to_string()); + let llastp = ctx.block().gep_inbounds(I8, &lp, &[(I64, &last_off)]); + let rlastp = ctx.block().gep_inbounds(I8, &rp, &[(I64, &last_off)]); + let llast = ctx.block().load(I8, &llastp); + let rlast = ctx.block().load(I8, &rlastp); + let same_last = ctx.block().icmp_eq(I8, &llast, &rlast); + ctx.block().cond_br(&same_last, &heap_two_l, false_l); + + ctx.current_block = heap_two_idx; + let two_bytes = ctx.block().icmp_eq(I32, &llen, "2"); + ctx.block().cond_br(&two_bytes, true_l, &heap_middle_l); + + // For three-byte strings the middle byte is the only byte not checked yet. + // Longer strings retain the full runtime content comparison. + ctx.current_block = heap_middle_idx; + let three_bytes = ctx.block().icmp_eq(I32, &llen, "3"); + ctx.block() + .cond_br(&three_bytes, &heap_middle_byte_l, &heap_slow_l); + + ctx.current_block = heap_middle_byte_idx; + let lmiddlep = ctx.block().gep_inbounds(I8, &lp, &[(I64, "21")]); + let rmiddlep = ctx.block().gep_inbounds(I8, &rp, &[(I64, "21")]); + let lmiddle = ctx.block().load(I8, &lmiddlep); + let rmiddle = ctx.block().load(I8, &rmiddlep); + let same_middle = ctx.block().icmp_eq(I8, &lmiddle, &rmiddle); + ctx.block().cond_br(&same_middle, true_l, false_l); + + ctx.current_block = heap_slow_idx; + let heap_res = ctx + .block() + .call(I32, "js_string_equals", &[(I64, lh), (I64, rh)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(merge_l); + (heap_res, heap_pred) +} + /// Inline prefix for the `===`/`!==` string arms that have **no** literal /// operand — `names[i] === name` in an environment lookup, say. /// @@ -444,13 +557,20 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { /// * both operands SSO with different bits => different content, again by /// canonicality. /// -/// The remaining arms are exactly what each caller emitted before, so this is -/// behaviour-preserving. That matters most for `legacy_unified`, whose fallback -/// keeps the `js_get_string_pointer_unified` composition — including its -/// number-coercing behaviour for operands whose `string` annotation lies. Note -/// that composition *materializes* an SSO operand onto the heap, so routing -/// SSO x SSO around it removes two allocations per comparison as well as the -/// calls. +/// When both operands are statically proven strings, heap values first compare +/// their lengths and up to three payload bytes inline. Besides rejecting every +/// different-length pair, that completely settles the short identifiers that +/// dominate environment lookup in tree-walking interpreters (`n`, `go`, +/// `fib`, ...). Longer same-length pairs still use the old helper. Generic-key +/// comparisons skip this larger prefix because their strings may be longer. +/// +/// The remaining representation arms are exactly what each caller emitted +/// before, so this is behaviour-preserving. That matters most for +/// `legacy_unified`, whose fallback keeps the +/// `js_get_string_pointer_unified` composition — including its number-coercing +/// behaviour for operands whose `string` annotation lies. Note that +/// composition *materializes* an SSO operand onto the heap, so routing SSO x +/// SSO around it removes two allocations per comparison as well as the calls. /// /// Returns an `i32` that is 1 iff the operands are `===`. fn lower_string_strict_eq_inline( @@ -458,6 +578,7 @@ fn lower_string_strict_eq_inline( l: &str, r: &str, legacy_unified: bool, + inline_short_heap: bool, ) -> String { let l_bits = ctx.block().bitcast_double_to_i64(l); let r_bits = ctx.block().bitcast_double_to_i64(r); @@ -495,11 +616,16 @@ fn lower_string_strict_eq_inline( ctx.current_block = heap_idx; let lh = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); let rh = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); - let heap_res = ctx - .block() - .call(I32, "js_string_equals", &[(I64, &lh), (I64, &rh)]); - let heap_pred = ctx.block().label.clone(); - ctx.block().br(&merge_l); + let (heap_res, heap_pred) = if inline_short_heap { + lower_short_heap_string_eq(ctx, &lh, &rh, &true_l, &false_l, &merge_l) + } else { + let heap_res = ctx + .block() + .call(I32, "js_string_equals", &[(I64, &lh), (I64, &rh)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_l); + (heap_res, heap_pred) + }; ctx.current_block = sso_idx; let l_sso = ctx @@ -795,7 +921,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // only `js_string_equals`. The boxed arm is byte-for-byte // the old helper composition, so a lying annotation keeps // its existing behaviour. - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true, false); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { @@ -890,7 +1016,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { CompareOp::Eq | CompareOp::LooseEq | CompareOp::Ne | CompareOp::LooseNe ) { - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, false); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, false, true); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { @@ -921,7 +1047,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // SSO x SSO are answered inline, which is what keeps a pair of // short runtime strings (`charAt`, `substring`) from // materializing two throwaway heap copies per comparison. - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true, true); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { diff --git a/crates/perry-codegen/src/expr/compare_tests.rs b/crates/perry-codegen/src/expr/compare_tests.rs index 489fc5d79a..453197b951 100644 --- a/crates/perry-codegen/src/expr/compare_tests.rs +++ b/crates/perry-codegen/src/expr/compare_tests.rs @@ -287,6 +287,60 @@ fn string_vs_generic_key_uses_the_identity_first_dispatch() { ir.contains("streq.tag"), "string-vs-generic comparison did not enter the identity-first dispatch:\n{ir}" ); + assert!( + !ir.contains("streq.heap.len"), + "generic-key equality paid the short-string checks without two proven strings:\n{ir}" + ); +} + +#[test] +fn string_equality_inlines_short_heap_content_checks_before_the_helper() { + let ir = ir_for( + "streq_short_heap", + vec![ + Stmt::Let { + id: X, + name: "left".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("left".to_string())), + }, + Stmt::Let { + id: Y, + name: "right".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("right".to_string())), + }, + Stmt::Let { + id: R, + name: "same".to_string(), + ty: Type::Boolean, + mutable: false, + init: Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(X)), + right: Box::new(Expr::LocalGet(Y)), + }), + }, + ], + ); + + for label in [ + "streq.heap.len", + "streq.heap.first", + "streq.heap.last", + "streq.heap.middle", + ] { + assert!( + ir.contains(label), + "missing {label} short-string arm:\n{ir}" + ); + } + assert!( + ir.contains("call i32 @js_string_equals("), + "long heap strings lost their full-content fallback:\n{ir}" + ); } /// The SSO immediate is hand-built here but consumed by `perry-runtime`'s diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 71d56352f1..3807c36395 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -158,6 +158,18 @@ pub struct LlFunction { /// final IR pass resolves these indices to the native allocas named by /// `js_shadow_slot_bind` calls, removes the calls, and emits stack maps. stack_map_slot_count: u32, + /// #8583: force this function onto the heap-backed shadow frame even when + /// native stack-map roots are the build default. Set for a function whose + /// estimated statepoint relocation count (`live_roots × safepoints`) would + /// make `rewrite-statepoints-for-gc` fan-out super-linear in the optimizer + /// (`codegen/helpers::maybe_spill_roots_to_shadow_frame`). The shadow-frame + /// lowering is the pre-#7370 default, walked by the same runtime root scan + /// as stack maps, so a spilled function's roots stay precise — it simply + /// carries no `gc "statepoint-example"` strategy and RS4GC skips it. The + /// frame pointer is unaffected (kept for every native-roots build), so the + /// FP-chain walker steps over the spilled frame exactly as it does the + /// runtime's own. + force_shadow_frame: bool, /// Runtime hooks emitted immediately before each non-pointer `ret`. /// Entry/module-init functions use this for process-level diagnostics /// that must run regardless of which block reaches the normal epilogue. @@ -264,6 +276,7 @@ impl LlFunction { shadow_frame_slot_count: 0, stack_map_requested: false, stack_map_slot_count: 0, + force_shadow_frame: false, pre_return_void_calls: Vec::new(), } } @@ -289,6 +302,19 @@ impl LlFunction { /// into every caller's hot loop. Skip the frame entirely; the /// to_ir() rewrite pass keys off `shadow_frame_slot.is_some()`, /// so no matching pop is emitted either. + /// #8583: route this function's precise roots through the heap shadow + /// frame instead of native statepoints. Must be called BEFORE + /// `enable_shadow_frame` / `enable_post_init_shadow_frame` so the frame is + /// built in shadow form. No effect once a frame has been emitted. + pub fn request_shadow_frame_spill(&mut self) { + self.force_shadow_frame = true; + } + + /// Whether this function spills its roots to the shadow frame (#8583). + pub fn spills_roots_to_shadow_frame(&self) -> bool { + self.force_shadow_frame + } + pub fn enable_shadow_frame(&mut self, slot_count: u32) { self.enable_shadow_frame_inner(slot_count, false); } @@ -304,7 +330,7 @@ impl LlFunction { } fn enable_shadow_frame_inner(&mut self, slot_count: u32, post_init: bool) { - if crate::codegen::helpers::native_stack_roots_enabled() { + if crate::codegen::helpers::native_stack_roots_enabled() && !self.force_shadow_frame { self.shadow_frame_requested = true; self.shadow_frame_post_init_region = post_init; self.stack_map_requested = slot_count != 0; @@ -398,7 +424,7 @@ impl LlFunction { if !self.shadow_frame_requested { return None; } - if crate::codegen::helpers::native_stack_roots_enabled() { + if crate::codegen::helpers::native_stack_roots_enabled() && !self.force_shadow_frame { let idx = self.stack_map_slot_count; self.stack_map_slot_count += 1; self.stack_map_requested = true; @@ -834,12 +860,19 @@ impl LlFunction { // #7174: the `!has_try` exclusion is gone with the field. Try/catch no // longer lowers to setjmp/longjmp (#7302), so nothing can jump past a // `gc.relocate` any more and statepoints cover every function. - let gc_strategy = - if self.stack_map_requested && crate::codegen::helpers::native_stack_roots_enabled() { - " gc \"statepoint-example\"" - } else { - "" - }; + // A spilled function (#8583) keeps precise roots in the shadow frame, + // so it must NOT carry the statepoint strategy — RS4GC would then run + // on it and reintroduce the relocation fan-out the spill avoids. Its + // `stack_map_requested` is already false (enable_shadow_frame_inner + // took the shadow branch), so this is belt-and-braces. + let gc_strategy = if self.stack_map_requested + && !self.force_shadow_frame + && crate::codegen::helpers::native_stack_roots_enabled() + { + " gc \"statepoint-example\"" + } else { + "" + }; // Invoke-EH (#7302): functions containing landing/funclet pads name // their personality on the define line. LLVM's grammar orders these // `[fn attrs] [gc] [personality]`, so the strategy precedes it. @@ -1244,6 +1277,92 @@ mod define_header_tests { } } + /// #8583 root spilling: under the native-roots build, a function that + /// requested a shadow-frame spill BEFORE `enable_shadow_frame` must take + /// the heap shadow lowering — no `gc "statepoint-example"` strategy (so + /// RS4GC skips it and cannot fan out its relocations) — while a sibling + /// that did not request the spill keeps native statepoints. The frame + /// pointer is kept regardless, so the FP-chain root walker still steps + /// over the spilled frame. + #[test] + fn a_spilled_function_takes_the_shadow_lowering_while_its_sibling_keeps_statepoints() { + use crate::codegen::helpers::NativeRootsPin; + use crate::types::{I64, PTR}; + const STRATEGY: &str = "gc \"statepoint-example\""; + const FRAME_PTR: &str = "\"frame-pointer\"=\"non-leaf\""; + + // Build a function with one bound root across a call: enough for both + // lowerings to have real content to render. + fn rooted(spill: bool) -> LlFunction { + let mut f = LlFunction::new("perry_fn_probe", crate::types::VOID, vec![]); + if spill { + f.request_shadow_frame_spill(); + } + f.enable_shadow_frame(0); + let idx = f.reserve_shadow_slot().expect("a frame yields a root slot"); + let root = f.alloca_entry(I64); + f.entry_allocas_push_store(I64, "0", &root); + f.entry_setup_call_void( + "js_shadow_slot_bind", + &[(crate::types::I32, &idx.to_string()), (PTR, &root)], + ); + let entry = f.create_block("entry"); + let _ = entry.call(I64, "may_collect", &[]); + entry.ret_void(); + f + } + + let _native = NativeRootsPin::native(); + + let native = rooted(false); + assert_eq!( + native.stack_map_slot_count, 1, + "the un-spilled sibling must take the stack-map path" + ); + let native_hdr = native.define_header(false); + assert!( + native_hdr.contains(STRATEGY), + "the un-spilled sibling must carry the statepoint strategy:\n{native_hdr}" + ); + + let spilled = rooted(true); + assert!(spilled.spills_roots_to_shadow_frame()); + assert_eq!( + spilled.stack_map_slot_count, 0, + "a spilled function must NOT take the stack-map path even under the \ + native-roots pin — that is the whole point of the spill" + ); + let spilled_hdr = spilled.define_header(false); + assert!( + !spilled_hdr.contains(STRATEGY), + "a spilled function must NOT claim the statepoint strategy, or RS4GC \ + would run on it and reintroduce the relocation fan-out:\n{spilled_hdr}" + ); + assert!( + spilled_hdr.contains(FRAME_PTR) && native_hdr.contains(FRAME_PTR), + "both lowerings keep the non-leaf frame pointer so the FP-chain \ + walker can step over the frame:\nspilled: {spilled_hdr}\nnative: {native_hdr}" + ); + + // The spilled function builds the heap shadow frame (`js_shadow_frame_enter` + // + retained `js_shadow_slot_bind`); the native sibling has neither — its + // roots are stack-map slots that RS4GC lowers, and the bind calls are + // stripped. + let spilled_ir = spilled.to_ir(); + assert!( + spilled_ir.contains("@js_shadow_frame_enter") + && spilled_ir.contains("@js_shadow_slot_bind"), + "a spilled function keeps the runtime shadow frame:\n{spilled_ir}" + ); + let native_ir = native.to_ir(); + assert!( + !native_ir.contains("@js_shadow_frame_enter") + && !native_ir.contains("@js_shadow_slot_bind"), + "the native sibling has no heap shadow frame; its roots are stack-map \ + slots and its binds are lowered away:\n{native_ir}" + ); + } + /// `force_external` drops only the linkage keyword. The codegen-unit path /// depends on that and on nothing else changing. #[test] diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index fd51c8a8b1..b21d815308 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -274,6 +274,32 @@ fn stream_frozen_functions<'ctx>( /// ~whole/n, same bound as the per-unit clang model), functions stream with /// external linkage forced (mirror of `render_fn_external`), and the unit /// objects partial-link exactly like the text path. +/// Default number of concurrent LLVM unit workers when `PERRY_CODEGEN_UNIT_JOBS` +/// is unset. +/// +/// This was a hard-coded `2` (#8017), chosen conservatively for Windows +/// pagefile pressure and applied on every platform. On a large real bundle +/// that left most cores idle: the Claude Code `cli.js` lowers to ~84 codegen +/// units and, with the giant entry function's roots spilled (#8583) so no unit +/// carries an unbounded RS4GC fan-out, per-unit peak RSS is a bounded ~1-2 GiB, +/// so the two-worker cap — not memory — was the wall (a ~440s unit × dozens, +/// two at a time, is hours). Each worker still holds one whole translation +/// unit, so the count stays bounded, not one-thread-per-unit. +/// +/// Non-Windows: half the machine's logical CPUs, clamped to `[2, 8]`. The 8 +/// ceiling keeps peak at ~8 × per-unit against a 64 GiB-class host with margin; +/// projects that know their headroom raise it with `PERRY_CODEGEN_UNIT_JOBS`. +/// Windows keeps the conservative `2` until its pagefile behavior under higher +/// fan-out is measured — the platform the original cap was chosen for. +fn default_unit_workers() -> usize { + if cfg!(target_os = "windows") { + return 2; + } + std::thread::available_parallelism() + .map(|p| (p.get() / 2).clamp(2, 8)) + .unwrap_or(2) +} + pub fn compile_module_units_native( llmod: &mut LlModule, n: usize, @@ -370,7 +396,7 @@ pub fn compile_module_units_native( .ok() .and_then(|v| v.parse::().ok()) .filter(|&v| v > 0) - .unwrap_or(2) + .unwrap_or_else(default_unit_workers) .min(parts.len()); if show_progress { let estimated_mib: f64 = parts @@ -620,6 +646,23 @@ fn debug_dump(module: &Module<'_>, module_prefix: &str) { mod tests { use super::*; use crate::module::LlModule; + + #[test] + fn default_unit_workers_are_bounded_and_platform_aware() { + let n = super::default_unit_workers(); + if cfg!(target_os = "windows") { + assert_eq!( + n, 2, + "Windows keeps the conservative 2-worker default (#8017)" + ); + } else { + assert!( + (2..=8).contains(&n), + "non-Windows default must stay in [2, 8], got {n}" + ); + } + } + use crate::types::{I1, I32, I64, PTR, VOID}; fn precise_root_fixture(extra_plain_function: bool) -> LlModule { diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index a62b88f331..7118d0b3b7 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -44,6 +44,10 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // The post-RS4GC per-function instruction budget (#8583): a unit that one // setting refuses must not be served from a build another accepted. "PERRY_LL_RS4GC_MAX_INSTRS", + // #8583: the relocation estimate above which a function spills its GC roots + // to a shadow frame. It changes which functions carry statepoints, so it + // changes the generated code and must be a cache input. + "PERRY_ROOT_SPILL_RELOCATIONS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 5e682373c4..450af15f70 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -849,6 +849,13 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // #8583: root-spill threshold changes which functions carry statepoints. + h.field( + "env_root_spill_relocations", + env_var("PERRY_ROOT_SPILL_RELOCATIONS") + .as_deref() + .unwrap_or(""), + ); // Explicit-safepoint contract: flips audited AllocNoReentry helpers // between statepoint and plain call. Two arms sharing a cached object // would make the contract's metadata reduction unmeasurable. diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 1ab35caa09..7ab7dcaa53 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -622,6 +622,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_RS4GC", "PERRY_LL_SIZE_OPT", "PERRY_LL_RS4GC_MAX_INSTRS", + "PERRY_ROOT_SPILL_RELOCATIONS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_DISABLE_BUFFER_FAST_PATH", "PERRY_VERIFY_NATIVE_REGIONS", diff --git a/crates/perry/tests/gc_root_spill_mixed_frames_8583.rs b/crates/perry/tests/gc_root_spill_mixed_frames_8583.rs new file mode 100644 index 0000000000..2d1f89f349 --- /dev/null +++ b/crates/perry/tests/gc_root_spill_mixed_frames_8583.rs @@ -0,0 +1,185 @@ +//! #8583 root spilling — mixed statepoint / shadow-frame stacks are GC-correct. +//! +//! Root spilling lets one function keep its GC roots in a heap shadow frame +//! (the pre-#7370 lowering) while the rest of the program keeps native +//! statepoint roots, so a minified-bundle entry function whose relocation +//! fan-out would hang the optimizer stays compilable. The soundness question +//! it raises is new: a single call stack now carries BOTH kinds of frame, and +//! a moving minor must find and rewrite the live roots in each. Nothing on +//! `main` exercised that combination before this feature. +//! +//! This is a differential test with no node oracle. The same program is +//! compiled twice from identical source: +//! +//! * `PERRY_ROOT_SPILL_RELOCATIONS=0` — spilling disabled, every function on +//! native statepoints (the pre-#8583 lowering); +//! * `PERRY_ROOT_SPILL_RELOCATIONS=1` — spill anything with a root and a +//! call, so `run`/`make`/`main` take the shadow frame while the call-free +//! accessor `leaf` stays on statepoints — a genuinely mixed stack. +//! +//! Both binaries run under every moving-collector configuration and must +//! produce byte-identical output. If the spilled frame's roots were invisible +//! to the collector, a relocating minor would leave a stale pointer and the +//! checksum would diverge (or the run would crash) in the `=1` arm only. +//! +//! The `=1` compile is also checked to have actually spilled (its stderr names +//! the shadow-frame functions), so a future change that stops spilling turns +//! this test into a tautology loudly rather than silently. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// `leaf` reads a field and makes no call: at `PERRY_ROOT_SPILL_RELOCATIONS=1` +/// its estimate is `slots × 0 = 0`, so it stays on native statepoints while its +/// callers spill. `run` holds `a`/`b`/`keep` live across allocating calls, so a +/// minor that fires inside `make` must find those roots in `run`'s shadow frame +/// and the `leaf` argument in `leaf`'s statepoint frame on the same stack. +const SOURCE: &str = r#" +function leaf(o: { v: number }): number { + return o.v; +} + +function make(i: number): { v: number } { + return { v: i }; +} + +function run(): number { + let acc = 0; + const keep: { v: number }[] = []; + for (let i = 0; i < 40000; i++) { + const a = make(i); + const b = make(i * 2); + acc = (acc + leaf(a) + leaf(b)) | 0; + if (i % 7 === 0) keep.push(a); + if (keep.length > 128) keep.shift(); + } + let s = 0; + for (const k of keep) s = (s + leaf(k)) | 0; + return (acc + s) | 0; +} + +console.log("r:" + run()); +"#; + +/// Collector knobs cleared before each run so a developer's exported kill +/// switch cannot turn every arm into the never-relocates control (mirrors +/// `gc_closure_self_pointer_root_7055`). +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn compile(dir: &std::path::Path, spill_threshold: &str) -> (PathBuf, String) { + let entry = dir.join("main.ts"); + let output = dir.join(format!("bin_spill_{spill_threshold}")); + std::fs::write(&entry, SOURCE).expect("write entry"); + let out = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_ROOT_SPILL_RELOCATIONS", spill_threshold) + .output() + .expect("run perry compile"); + assert!( + out.status.success(), + "perry compile (PERRY_ROOT_SPILL_RELOCATIONS={spill_threshold}) failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + (output, String::from_utf8_lossy(&out.stderr).into_owned()) +} + +fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str) -> String { + let mut arms: Vec> = vec![vec![]]; + for mb in ["1", "2", "4", "8"] { + arms.push(vec![("PERRY_GC_SCAVENGE_NURSERY_MB", mb)]); + } + arms.push(vec![("PERRY_GEN_GC", "0")]); + + let mut first: Option = None; + for arm in &arms { + let mut cmd = Command::new(binary); + cmd.current_dir(dir); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + for (k, v) in arm { + cmd.env(k, v); + } + let run = cmd.output().expect("run compiled binary"); + let arm_label = if arm.is_empty() { + format!("{label}/default") + } else { + format!( + "{label}/{}", + arm.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ) + }; + assert!( + run.status.success(), + "[{arm_label}] compiled binary failed (exit {:?})\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stderr), + ); + let stdout = String::from_utf8_lossy(&run.stdout).into_owned(); + match &first { + None => first = Some(stdout), + Some(f) => assert_eq!( + &stdout, f, + "[{arm_label}] output differs between collector arms — a moving \ + minor left a stale root in this configuration" + ), + } + } + first.expect("at least one arm ran") +} + +#[test] +fn mixed_statepoint_and_shadow_frames_survive_a_relocating_minor() { + let dir = tempfile::tempdir().expect("tempdir"); + + // All-native reference and aggressively-spilled arm, from identical source. + let (native_bin, _native_err) = compile(dir.path(), "0"); + let (spilled_bin, spilled_err) = compile(dir.path(), "1"); + + // The spilled compile must have actually spilled, or the differential below + // proves nothing. The report names each shadow-framed function at default + // verbosity (#8421: the change is never silent). + assert!( + spilled_err.contains("keeps its") && spilled_err.contains("GC roots in a shadow frame"), + "PERRY_ROOT_SPILL_RELOCATIONS=1 was expected to spill at least one \ + function, but the compile reported none:\nstderr:\n{spilled_err}" + ); + + let native_out = run_arms(&native_bin, dir.path(), "native"); + let spilled_out = run_arms(&spilled_bin, dir.path(), "spilled"); + + assert!( + native_out.starts_with("r:"), + "unexpected program output: {native_out:?}" + ); + assert_eq!( + native_out, spilled_out, + "mixed statepoint/shadow-frame stacks (spilled) diverged from the \ + all-statepoint build (native): a live root in a spilled frame was not \ + found or not rewritten by a moving minor (#8583)" + ); +} diff --git a/test-files/test_strict_eq_string_literal_inline.ts b/test-files/test_strict_eq_string_literal_inline.ts index 71a232994f..847129f5fb 100644 --- a/test-files/test_strict_eq_string_literal_inline.ts +++ b/test-files/test_strict_eq_string_literal_inline.ts @@ -107,6 +107,38 @@ console.log(describe(tree)); const dynOp: string = ["+", "-", "*"][1]; console.log(dynOp === "-", dynOp === "+", dynOp !== "-"); +// ---- non-literal heap strings (the environment-lookup shape from #8591) --- +// Keep both operands statically `string` but construct distinct heap values so +// the no-literal equality path has to compare their contents. Lengths 0..3 are +// settled inline; longer strings retain the runtime helper. +function sameString(a: string, b: string): boolean { + return a === b; +} +const heapEmptyA: string = "xy".substring(1, 1); +const heapEmptyB: string = "ab".substring(0, 0); +const heapOneA: string = "xn".substring(1); +const heapOneB: string = "ny".substring(0, 1); +const heapTwoA: string = "g" + "o"; +const heapTwoB: string = "xgoy".substring(1, 3); +const heapThreeA: string = "f" + "ib"; +const heapThreeB: string = "xfiby".substring(1, 4); +const heapLongA: string = "long" + "name"; +const heapLongB: string = "xlongnamey".substring(1, 9); +console.log( + sameString(heapEmptyA, heapEmptyB), + sameString(heapOneA, heapOneB), + sameString(heapTwoA, heapTwoB), + sameString(heapThreeA, heapThreeB), + sameString(heapLongA, heapLongB), +); +console.log( + sameString(heapOneA, "z".substring(0, 1)), + sameString(heapTwoA, "no".substring(0, 2)), + sameString(heapThreeA, "fox".substring(0, 3)), + sameString(heapThreeA, "fig".substring(0, 3)), + sameString(heapLongA, "longnames".substring(0, 9)), +); + // ---- switch/case over the same literals (a second lowering of ===) --------- function classify(s: string): number { switch (s) {