Skip to content

fix(codegen): #6219 shadow-frame codegen must not blow up on deeply-nested closures#6253

Open
proggeramlug wants to merge 1 commit into
mainfrom
fix/6219-shadow-frame-codegen-perf
Open

fix(codegen): #6219 shadow-frame codegen must not blow up on deeply-nested closures#6253
proggeramlug wants to merge 1 commit into
mainfrom
fix/6219-shadow-frame-codegen-perf

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #6219. That PR extended shadow-frame emission (GC exact-root spilling) from top-level functions to closures and methods, calling collect_pointer_typed_locals once per closure/method. The collector recurses into nested closure bodies, so on deeply-nested bundles codegen never reached LLVM emission — the Next.js standalone server (700+ modules, whole webpack chunks lowered as single closures thousands of locals deep, 105 MB bundle) sat in the collectors for 25 min+ and climbing. CI missed it because its inputs are small.

Three independent super-linear costs, all in crates/perry-codegen/src/collectors/pointer_locals.rs:

# Cost Fix
1 Re-walk — each enclosing frame re-descended every nested closure subtree, O(nesting²) walks Memoize each closure body's collected writes by body identity (CLOSURE_WRITES_MEMO) — a subtree is walked once total
2 Clone — the memo merged a closure's entire subtree writes into every ancestor frame, then pruned, O(nesting²) Expr clones Store only free writes (targets not declared within the subtree) — ancestors clone just their own captured-local writes
3 Fixpoint — the type-refinement loop ran unbounded (while changed), O(locals × iterations); a long def-use chain needs one pass per link and a thousands-of-locals factory never converged Cap iterations (16) and hard-gate refinement off above 8192 locals

Why it's safe (no under-rooting)

Every slot decision gates on is_ptr_typed(declared_ty) && !non_pointer_locals.contains(id), and the fixpoint only ever grows non_pointer_locals (to drop slots). So curtailing any of the three only ever leaves an extra safe GC root — it can never remove a needed one. No use-after-free is possible.

  • Free-writes filter (fix 2): HIR local ids are globally unique, so an ancestor only cares about writes to its own captured locals. A write to frame F's local L stays free relative to every intermediate closure and propagates up to F unchanged; writes to a closure's own locals — which every ancestor discarded at the prune anyway — simply never get cloned. Slot maps are therefore identical to before for every frame.
  • Fixpoint cap (fix 3): stopping early just falls back to declared types, which is strictly more conservative.

Validation

  • Next.js standalone server compiles in ~7 min with shadow frames ON (was: never / 25 min+ timeout short of LLVM emission).
  • 10/10 closure/GC-heavy gap tests pass under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (stresses every marked nursery object through evacuation + panics on any stale slot) — confirms no under-rooting from the curtailed analysis:
    closures, 5437_class_ref_shadows_captured_local, 5437_class_ref_static_closure_method, in_operator_closure, array_callbacks_2797_2798_2799, map_set_extended, async_advanced, weakref_finalization, mapset_iter_2831_2830_2856, json_map_set.
  • cargo fmt clean.

Code-only; no version/changelog bump (maintainer folds metadata at merge).

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of local variables captured by nested closures.
    • Increased reliability when analyzing pointer-related local values across complex control flows.
  • Performance

    • Reduced repeated processing during closure analysis.
    • Added safeguards to prevent excessively long local-variable refinement operations.
  • Stability

    • Improved compiler behavior for programs with deeply nested closures and large numbers of local variables.

…ested closures

#6219 extended shadow-frame emission (GC exact-root spilling) from top-level
functions to closures and methods, calling collect_pointer_typed_locals once
per closure/method. That collector recurses into nested closure bodies, so on
deeply-nested bundles — the Next.js standalone server: 700+ modules, whole
webpack chunks lowered as single closures thousands of locals deep — codegen
never reached LLVM emission (25 min+ and still climbing on a 105 MB bundle;
CI missed it because its inputs are small).

Three independent super-linear costs, all in collectors/pointer_locals.rs:

1. Re-walk. Each enclosing frame re-descended every nested closure subtree —
   O(nesting^2) walks. Memoize each closure body's collected writes by body
   identity (CLOSURE_WRITES_MEMO), so a subtree is walked once total.

2. Clone. The memo merged a closure's ENTIRE subtree writes into every ancestor
   frame, then pruned — O(nesting^2) Expr clones. Store only FREE writes
   (targets not declared within the subtree). HIR local ids are globally unique,
   so an ancestor only ever cares about writes to ITS OWN captured locals: a
   write to frame F's local L stays free relative to every intermediate closure
   and propagates up to F unchanged, while writes to a closure's own locals —
   which every ancestor discarded at the prune anyway — never get cloned.

3. Fixpoint. The type-refinement loop ran unbounded (while changed), O(locals x
   iterations): a long def-use chain needs one pass per link, and a
   thousands-of-locals webpack factory never converged. Cap iterations (16) and
   hard-gate refinement off above 8192 locals.

All three are strictly SAFE. The fixpoint only ever GROWS non_pointer_locals to
DROP slots, and every slot decision gates on `is_ptr_typed(declared_ty) &&
!non_pointer_locals.contains(id)` — so curtailing any of it only ever leaves an
extra safe GC root, never removes a needed one. No under-rooting / use-after-free
is possible; slot maps are identical to the uncapped analysis for all
normal-sized frames.

Result: the Next.js standalone server compiles in ~7 min with shadow frames ON
(was: never). Validated 10/10 on closure/GC-heavy gap tests under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (no under-rooting).
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pointer-local collector now memoizes closure write analysis, distinguishes free writes from closure-owned locals, restricts refinement to current-frame locals, and caps the type-refinement fixpoint.

Changes

Pointer-local analysis

Layer / File(s) Summary
Memoized closure write collection
crates/perry-codegen/src/collectors/pointer_locals.rs
Closure body writes are cached by body identity, and direct let identifiers plus parameters are excluded when propagating free writes to the enclosing frame.
Frame-scoped write refinement
crates/perry-codegen/src/collectors/pointer_locals.rs
Write targets are filtered to current-frame locals, flat_row_alias_ids is no longer populated from integer locals, and refinement is bounded by iteration and local-count limits.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the shadow-frame codegen fix for deeply nested closures.
Description check ✅ Passed The description covers the problem, fixes, safety rationale, and validation, though it doesn't follow the template headings exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6219-shadow-frame-codegen-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/collectors/pointer_locals.rs`:
- Around line 11-32: Reset CLOSURE_WRITES_MEMO for every compile run to prevent
stale pointer/length entries from being reused after HIR lifetimes end. Add a
clear operation at the compile entry or exit, using the existing compilation
orchestration function as the integration point, and ensure cleanup also occurs
on failure paths or use a per-compile generation key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63fb9085-e7fa-43e2-9402-30c37b40be59

📥 Commits

Reviewing files that changed from the base of the PR and between 6465491 and 4661a62.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/collectors/pointer_locals.rs

Comment on lines +11 to +32
thread_local! {
/// #6219 perf: cache each closure body's collected local-writes, keyed by
/// the body slice's identity `(ptr, len)`, so a closure subtree is walked
/// ONCE total instead of once per enclosing frame. Writes are keyed by
/// GLOBAL local id, so the same cached set is correct for every ancestor
/// frame AND the closure's own frame — the collection is compositional.
///
/// Without this, emitting a shadow frame per closure/method (#6219) made
/// each frame's `collect_pointer_typed_locals` re-descend the full nested
/// closure subtree, so a body nested `D` deep was walked `O(D)` times —
/// `O(nesting²)` overall, which never finished on deeply-nested bundles
/// (the Next.js standalone server, thousands of closures 5–10 deep).
///
/// Safe because within a single `perry compile` the HIR is alive for the
/// whole (parallel) codegen — no body is freed, so no `(ptr, len)` is ever
/// reused for a different body; and each closure body is reachable from
/// exactly one enclosing tree, so its writes don't depend on the caller.
static CLOSURE_WRITES_MEMO: std::cell::RefCell<
HashMap<(usize, usize), std::rc::Rc<HashMap<u32, Vec<LocalWrite>>>>,
> = std::cell::RefCell::new(HashMap::new());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Is codegen thread pool / collect_pointer_typed_locals reused across multiple compiles in one process?
rg -nP '\bcollect_pointer_typed_locals\b' -C3
# Look for a compile-driver / worker-pool loop that could reuse threads across compiles
rg -nP 'fn\s+compile\b|rayon|thread(pool|::spawn)|par_iter|scope' crates/perry-codegen -C2
# Any existing per-compile thread_local reset patterns to mirror?
rg -nP 'with\(\|.*\|\s*.*\.borrow_mut\(\)\.clear\(\)' crates

Repository: PerryTS/perry

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant symbols =="
rg -n "collect_pointer_typed_locals|CLOSURE_WRITES_MEMO|pointer_locals" crates/perry-codegen -S

echo
echo "== inspect pointer_locals.rs outline =="
ast-grep outline crates/perry-codegen/src/collectors/pointer_locals.rs --view expanded || true

echo
echo "== inspect nearby lines in pointer_locals.rs =="
sed -n '1,120p' crates/perry-codegen/src/collectors/pointer_locals.rs

echo
echo "== find compile entrypoints / thread-pool use =="
rg -n "fn\s+compile\b|rayon|thread_pool|ThreadPool|par_iter|spawn|scope" crates -S

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant symbols =="
rg -n "collect_pointer_typed_locals|CLOSURE_WRITES_MEMO|pointer_locals" crates/perry-codegen -S

echo
echo "== inspect pointer_locals.rs outline =="
ast-grep outline crates/perry-codegen/src/collectors/pointer_locals.rs --view expanded || true

echo
echo "== inspect nearby lines in pointer_locals.rs =="
sed -n '1,140p' crates/perry-codegen/src/collectors/pointer_locals.rs

echo
echo "== find compile entrypoints / thread-pool use =="
rg -n "fn\s+compile\b|rayon|thread_pool|ThreadPool|par_iter|spawn|scope" crates -S

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pointer_locals memo usage =="
rg -n "CLOSURE_WRITES_MEMO|collect_pointer_typed_locals|collect_expr_writes_in_closure" crates/perry-codegen/src/collectors/pointer_locals.rs -n -S

echo
echo "== pointer_locals.rs around memo + closure arm =="
sed -n '1,120p' crates/perry-codegen/src/collectors/pointer_locals.rs
printf '\n---\n'
sed -n '500,585p' crates/perry-codegen/src/collectors/pointer_locals.rs

echo
echo "== codegen entry outline =="
ast-grep outline crates/perry-codegen/src/codegen/entry.rs --view expanded || true

echo
echo "== codegen entry around compile entry / initialization =="
sed -n '1,220p' crates/perry-codegen/src/codegen/entry.rs
printf '\n---\n'
sed -n '220,420p' crates/perry-codegen/src/codegen/entry.rs
printf '\n---\n'
sed -n '420,620p' crates/perry-codegen/src/codegen/entry.rs

echo
echo "== search for cache resets or thread_local resets =="
rg -n "CLOSURE_WRITES_MEMO|thread_local!|borrow_mut\(\)\.clear\(|clear\(\);" crates/perry-codegen/src crates/perry-hir/src -S

Repository: PerryTS/perry

Length of output: 45152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== compile command / entrypoint =="
rg -n "pub\s+fn\s+compile\b|fn\s+compile\b|compile_module_entry|compile_ir|codegen::|perry-codegen" crates/perry/src crates/perry-codegen/src -S

echo
echo "== inspect perry compile command =="
sed -n '1,260p' crates/perry/src/commands/compile.rs

echo
echo "== inspect codegen mod around any thread-local cleanup =="
sed -n '1780,1865p' crates/perry-codegen/src/codegen/mod.rs

echo
echo "== search for any pointer_locals memo reset or compile-boundary cleanup =="
rg -n "CLOSURE_WRITES_MEMO|pointer_locals|clear\(\).*thread_local|thread_local!.*clear" crates/perry-codegen crates/perry-hir crates/perry -S

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== CLI / long-lived process entrypoints =="
rg -n "\bwatch\b|\bdaemon\b|\bserve\b|\blsp\b|\brepl\b|repeat|loop.*compile|run_with_parse_cache\(|commands::compile::run\(" crates/perry/src -S

echo
echo "== main binaries / command dispatch =="
fd -a 'main.rs' crates/perry/src crates/perry -t f
fd -a 'lib.rs' crates/perry/src crates/perry -t f

echo
echo "== compile command callers =="
rg -n "run_with_parse_cache\(|commands::compile::run\(" crates/perry/src -S

Repository: PerryTS/perry

Length of output: 11490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' crates/perry/src/commands/dev.rs
printf '\n---\n'
sed -n '220,420p' crates/perry/src/commands/dev.rs

Repository: PerryTS/perry

Length of output: 13868


Reset CLOSURE_WRITES_MEMO between compile runs
This thread_local survives multiple compile runs in the same process, so a reused worker thread can hit a stale (ptr, len) from a dropped HIR and skip a needed shadow slot. Clear it at compile entry/exit, or key it by a per-compile generation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/collectors/pointer_locals.rs` around lines 11 - 32,
Reset CLOSURE_WRITES_MEMO for every compile run to prevent stale pointer/length
entries from being reused after HIR lifetimes end. Add a clear operation at the
compile entry or exit, using the existing compilation orchestration function as
the integration point, and ensure cleanup also occurs on failure paths or use a
per-compile generation key.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant