Skip to content

perf(codegen): skip redundant async frame capture tracking - #8392

Merged
proggeramlug merged 2 commits into
mainfrom
fix/8384-async-box-frame-cost
Aug 19, 2026
Merged

perf(codegen): skip redundant async frame capture tracking#8392
proggeramlug merged 2 commits into
mainfrom
fix/8384-async-box-frame-cost

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Root cause and measured confirmation

#8303 correctly changed plain async terminal release to name the complete frame, but the generated step closure also captures that complete frame. Codegen declared all of those compiler-private captures through js_closure_set_box_capture_ptr, even though queued/running step lifetime is already covered by the async activation token.

On unmodified main, PERRY_GC_DIAG=1 confirmed the result on asyncpipe: 192,859 cells were allocated and terminal-released, but 0 published; 192,859 stayed resident and the JS/i32/bool registries retained 120,496/24,121/48,242 cells. With this change, all 192,859 publish, resident cells fall to 1,626, and all three registries finish at 0.

asyncpipe performance

Required static-wrapper build in both arms:

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static

PERRY_NO_AUTO_OPTIMIZE=1, PERRY_NO_CACHE=1, byte-exact output checked on every run. Wall is best-of-15 interleaved; instructions and RSS are best-of-9 from /usr/bin/time -l.

before after change
wall 0.1570 s 0.0963 s -38.7%
instructions retired 1.725 G 1.361 G -21.1%
peak RSS 61.3 MiB 38.8 MiB -36.7%

The after arm is close to the pre-regression reference measured in the same interleaved run (0.0934 s best).

#8213 retention protection

  • escaped_closures_defer_activation_cell_publication_until_gc_death passes
  • full_trace_treats_drained_closure_boxes_as_ephemeron_edges passes
  • completed_activation_residue_is_bounded_not_linear passes
  • new codegen test proves the generated step adds no whole-frame capture edges while a nested user closure still emits exactly one js_closure_set_box_capture_ptr edge
  • full perry-runtime --lib: 2,591 passed, 4 ignored, 0 failed

Validation

  • cargo test --release -p perry-runtime --lib
  • cargo test --release -p perry-transform — 84 passed
  • cargo test --release -p perry --bin perry — 1,003 passed
  • cargo test --release -p perry-codegen --test release_boxes_lowering — 4 passed
  • bash scripts/run_lint_gates.sh — all 50 reported gates passed (48 script gates plus compile tier)
  • all 19 sweep programs compiled and matched their Node-oracle stdout and stderr byte-for-byte

Closes #8384

Summary by CodeRabbit

  • Bug Fixes

    • Improved memory and resource cleanup for asynchronous operations.
    • Prevented unnecessary retention of temporary async execution frames.
    • Preserved correct capture behavior for user-defined nested closures.
  • Tests

    • Added coverage verifying efficient async closure cleanup and nested closure capture handling.
  • Documentation

    • Added a changelog entry describing the async frame-release improvements.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6494271d-56a1-487a-99e3-def5259d87cb

📥 Commits

Reviewing files that changed from the base of the PR and between 300f1b3 and 4780313.

📒 Files selected for processing (3)
  • changelog.d/8392-async-box-frame-cost.md
  • crates/perry-codegen/src/expr/closure.rs
  • crates/perry-codegen/tests/release_boxes_lowering.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The compiler detects plain async step closures by scanning their statement trees. It skips boxed-capture escape registration for these closures while retaining tracking for nested user closures. Tests verify both behaviors, and a changelog entry documents the change.

Changes

Async box capture selectivity

Layer / File(s) Summary
Detect async steps and suppress frame capture tracking
crates/perry-codegen/src/expr/closure.rs
Closure lowering recursively detects ReleaseBoxes in plain async step bodies and skips boxed-capture escape registration for those closures.
Validate nested closure tracking
crates/perry-codegen/tests/release_boxes_lowering.rs, changelog.d/8392-async-box-frame-cost.md
Tests verify that activation-owned step closures emit no box-capture edges and nested user closures emit one. The changelog documents the async frame release behavior.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to 47803

This change narrows async frame capture tracking while preserving nested user-closure capture behavior, with targeted tests and broad validation reported as passing; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the async codegen optimization that is the main change.
Description check ✅ Passed The description explains the cause, implementation, performance impact, retention safeguards, linked issue, and extensive validation results.
Linked Issues check ✅ Passed The changes address #8384 by removing redundant async frame capture tracking while preserving user-closure retention and benchmark correctness.
Out of Scope Changes check ✅ Passed The code, tests, changelog entry, and validation directly support the linked performance and retention objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/8384-async-box-frame-cost

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Independently validated on macOS — every number in the report reproduces. Merging.

Reproduced the A/B myself

Interleaved best-of-11 against my own reference binaries, byte-exact vs the Node oracle on every run:

main (regressed) this PR pre-regression (14468dcbc)
wall 0.1602 0.0975 (-39.1%) 0.0957
instructions 1.73 G 1.36 G 1.33 G
peak RSS 61.3 MiB 38.8 MiB 39.7 MiB

Within 1.9% of pre-regression on wall, and peak RSS lands below it. Both metrics move
the right way together, which is the bar here.

The mechanism is counted, not inferred

PERRY_GC_DIAG=1 box-stats, main -> this PR:

published        0        -> 192859
resident_cells   192859   -> 1626
registry_len     120496   -> 0
i32_registry_len 24121    -> 0
bool_registry_len 48242   -> 0
pool_reuses      0        -> 191233

Every one of the 192,859 released cells was stuck, and the cell pool was recycling
nothing. That confirms the diagnosis: #8303's terminal release was correct all along;
codegen marking the compiler-private step frame as escaped is what prevented publication.

The new gate is non-vacuous — I checked

I neutered is_plain_async_step_body to return false and re-ran
release_boxes_lowering: 2 of 4 tests go red, and usefully it fails on both sides
of the selectivity boundary — the whole-frame assertion and
escaped_user_closure_inside_step_keeps_its_box_capture_edge, which pins that a real
user closure still emits exactly one capture edge. So the gate catches both
over- and under-application. File restored, zero local diffs.

The predicate itself reads correctly: Stmt::ReleaseBoxes is compiler-synthesized, and
the walk does not descend into nested closure expressions, so a user closure cannot be
misclassified.

#8213 protection

All three named retention tests pass individually:
escaped_closures_defer_activation_cell_publication_until_gc_death,
full_trace_treats_drained_closure_boxes_as_ephemeron_edges,
completed_activation_residue_is_bounded_not_linear.

Suites and gates

  • perry-transform 84 passed · perry --bin perry 1003 passed · release_boxes_lowering 4 passed
  • scripts/run_lint_gates.sh — all 50 pass (48 script gates + compile tier)
  • 12 sweep corpus programs recompiled and byte-exact against their Node oracles

One flake, unattributed

My first perry-runtime --lib run showed 2590 passed; 1 failed. Six consecutive runs
since have been 2591 passed; 0 failed. I cannot name the test — my command filtered
output to ^test result and discarded the failure block, which was my error. Recording
it rather than dropping it: if perry-runtime --lib goes red intermittently on main,
this is a prior sighting. It is not evidence against this PR, given 6/6 clean reruns and
all three retention tests green.

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.

perf: asyncpipe regressed 55.6% (1.08x -> 1.62x Node) between 14468dcbc and 1b61941bc

1 participant