perf(codegen): replace the try/catch optnone contagion with targeted setjmp volatile slots (#6385) - #6393
Conversation
📝 WalkthroughWalkthroughThe codegen now tracks stores within setjmp-protected regions, promotes accesses to affected allocas to volatile, applies the transformation after return rewriting, removes function-wide ChangesSetjmp volatile promotion
Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry-codegen/src/volatile_setjmp.rs (1)
127-139: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winPointer-provenance resolution only follows
getelementptr, notphi/select.
derived_ptrresolves a derived pointer back to its base alloca only forgetelementptr. If codegen ever produces aphi/selectmerging two alloca pointers (e.g. a pointer chosen depending on a branch) and only the load side goes through that merged pointer, this pass wouldn't recognize the connection to mark it volatile. In the common case this is masked because the directstoreto the alloca (which the try region does capture) already blocks promotion of the whole alloca regardless of other unmarked accesses — but if a write itself ever reaches an alloca only through aphi/selectpointer (never a directstore <ty> ..., ptr %alloca),try_region_storeswould record the merged register instead of the alloca, and this pass would silently fail to protect it.I don't see current codegen constructing
phi/selectof two alloca pointers for JS locals (locals are always accessed via a fixed slot or agetelementptr), so this is likely dormant risk rather than an active bug. Worth a defensive extension ofderived_ptrto also resolve throughphi/selectoperands, or a code comment flagging the limitation explicitly, so a future codegen change doesn't quietly reintroduce the exact class of bug this module fixes.🛡️ Sketch of extending pointer resolution to phi/select
fn derived_ptr_operands(t: &str) -> Option<(&str, Vec<&str>)> { // existing getelementptr handling … if let Some(at) = t.find(" = phi ptr ") { let res = &t[..at]; if !res.starts_with('%') { return None; } let rest = &t[at + " = phi ptr ".len()..]; // parse "[ %a, %bb1 ], [ %b, %bb2 ]" and collect %a, %b … } None }Also applies to: 173-193
🤖 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/volatile_setjmp.rs` around lines 127 - 139, Extend pointer-provenance resolution around derived_ptr and its callers to recognize phi/select pointer results, recursively resolving each incoming pointer to the underlying alloca before recording volatile stores or loads. Preserve the existing getelementptr handling and ensure merged operands are parsed without treating basic-block labels as pointers; if the current return shape cannot represent multiple operands, update the surrounding resolution logic accordingly.
🤖 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.
Nitpick comments:
In `@crates/perry-codegen/src/volatile_setjmp.rs`:
- Around line 127-139: Extend pointer-provenance resolution around derived_ptr
and its callers to recognize phi/select pointer results, recursively resolving
each incoming pointer to the underlying alloca before recording volatile stores
or loads. Preserve the existing getelementptr handling and ensure merged
operands are parsed without treating basic-block labels as pointers; if the
current return shape cannot represent multiple operands, update the surrounding
resolution logic accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 0576722c-9ad2-461e-98dd-731b66b89005
📒 Files selected for processing (8)
crates/perry-codegen/src/block.rscrates/perry-codegen/src/function.rscrates/perry-codegen/src/lib.rscrates/perry-codegen/src/module.rscrates/perry-codegen/src/stmt/mod.rscrates/perry-codegen/src/stmt/try_stmt.rscrates/perry-codegen/src/volatile_setjmp.rstest-files/test_gap_try_setjmp_volatile.ts
Gap-suite A/B vs pristine
|
| not-skip-listed failure | baseline origin/main |
this PR | verdict |
|---|---|---|---|
test_gap_events_import_4995 |
fails vs node | byte-identical to baseline | pre-existing, untriaged on main |
test_gap_handle_band_object_ops |
fails vs node | byte-identical to baseline | pre-existing, untriaged on main |
test_gap_http2_settings |
fails vs node | byte-identical to baseline | pre-existing, untriaged on main |
test_gap_http_overloads_3226plus |
times out (exit 124) | times out (exit 124), byte-identical | pre-existing, untriaged on main |
test_gap_node_fs |
passes standalone | passes standalone, byte-identical | known flake under the suite's parallel/timing load; not a parity failure |
So every gap failure on this branch either is already triaged, or reproduces identically on origin/main. Nothing this PR touches regressed.
try/catch-specific coverage
The tests most exposed to this change all pass: test_gap_try_finally_no_catch_rethrow, the async/generator families (the async transform shares the setjmp boundary this PR re-brackets), and the error-handling tests. The new test_gap_try_setjmp_volatile (20 adversarial cases) passes and — crucially — goes 13/20 red under PERRY_SETJMP_VOLATILE=0, which is what makes it evidence rather than decoration.
Fixes the
optnonecontagion behind #6385: merely having atryin a function deoptimized the entire function, even when nothing ever threw.The defect
Perry lowers
try/catchtosetjmp/longjmp(stmt/try_stmt.rs), not to LLVM unwind edges.longjmprestores the callee-saved registers and stack pointer thatsetjmpsnapshotted, so any local LLVM parked in a register across the setjmp reverts to its setjmp-time value when the exception fires — the try body's mutations vanish in thecatch. This is exactly C'ssetjmphazard (C11 7.13.2.1p3).codegendefended against it by stampingoptnoneon the whole function containing thetry. That is correct (at-O0every value is frame-resident, and the frame surviveslongjmp) but it is a sledgehammer: the loop counters, the arithmetic, the compares and the branches around thetryall stopped being optimized too. Everytry-containing function — and every directasyncfunction, which gets the same setjmp-based rejection boundary — was compiled unoptimized.The fix
Apply C's
volatilerule precisely instead of disabling optimization wholesale.mem2reg/SROArefuse to promote an alloca that has any volatile load/store (isAllocaPromotablebails onisVolatile()), and volatile accesses can be neither elided nor reordered against each other — so a volatile-accessed slot provably lives in the frame across the setjmp, while everything else in the function optimizes normally.RegCounter(block.rs) gains a try-region depth counter.LlBlock::emit— the single choke point every emitter funnels through, includingemit_raw— records the destination pointer of everystoreemitted while the depth is non-zero.lower_trybrackets the try body and the finally-protected catch body withenter_try_region/exit_try_region; the async rejection boundary brackets the async body.LlFunction::to_irthen runsvolatile_setjmp::apply_setjmp_volatile: it resolves those recorded pointers back to their base allocas (throughgetelementptrderivation, to a fixpoint) and rewrites every load/store of those allocas — function-wide — tovolatile.attributes #1dropsoptnone, keeping onlynoinline.noinlinestays: LLVM'sisInlineViablealready refuses to inline a function containing areturns_twicecall, so it is belt-and-braces rather than load-bearing — but it keeps the setjmp frame's identity from depending on an internal inliner policy, at zero cost.The volatile set, and why it is sound
The set is "every alloca this function stores into while inside a setjmp-protected region". One rule, no exceptions — that is what makes it auditable.
setjmpand thelongjmpand read afterwards is indeterminate unlessvolatile. We drop the "and read afterwards" half. Marking a slot that the try writes but nothing reads afterwards is merely conservative, never wrong.trys, and the duplicatedfinallybodies — whatever is lowered while the region is open belongs to it, regardless of which basic block the instruction lands in. Nesting composes for free.catchcould otherwise be GVN-forwarded from a plain store that dominates the setjmp.setjmpand thelongjmpby this frame. The only other way to modify it is for its address to escape to a callee — and an alloca with a non-load/store user already defeatsmem2reg/SROA, so those stay in memory anyway.longjmppreserves) or parks it in a callee-saved register (whichlongjmprestores to the same, unmodified value).js_try_push,_setjmp,js_try_end,js_throw) that LLVM must assume clobber them, so they are never cached in a register across the boundary.js_shadow_slot_set, a runtime call, not an alloca).On the #6385 benchmark this yields the intended set exactly.
throughput()lowers to%r3=acc,%r5=i:accstays frame-resident; the loop counter, thei & 1, the compare, the branch — and three redundant reloads ofithatoptnoneused to preserve — all optimize normally.Proving the volatile set is load-bearing
A correctness test that passes both with and without the fix proves nothing.
PERRY_SETJMP_VOLATILE=0(new, documented bisection-only switch, same spirit asPERRY_WRITE_BARRIERS=0) disables the promotion while still droppingoptnone— i.e. it reproduces the miscompile the promotion exists to prevent.test-files/test_gap_try_setjmp_volatile.tsis a 20-case adversarial suite (written before the optimization): written-in-try/read-in-catch, read-in-finally, read-after-the-try,letvsvar, nestedtry, written-in-a-loop-in-the-try, written-in-a-closure, written-in-try-and-catch, heap object/array mutation, throw-before-vs-after the write, destructuring,try/finallywith no catch, catch-param reassignment, async, generator.Built at release (
-O2/-O3;perry-devat opt-level=1 hides the bug entirely):node --experimental-strip-typesPERRY_SETJMP_VOLATILE=0The failures are exactly the predicted stale-value pattern — the try's write evaporates and the catch reads the pre-
setjmpvalue:Two of those earn their keep specifically: c12 (
try/finallywith nocatch, whose finally re-runs on the exception path) and c13 (a write in thecatchbody of atrythat has afinally— the catch body is itself setjmp-protected so the finally can re-run on a catch-body throw). Both regions are ones this PR deliberately brackets; both go red without them.Before / after (release, macOS arm64, min-of-5×5, interleaved A/B)
tryat alltry/catchpresent, never throwstryaround a 20M-iteration hot loopCorrectness is unchanged everywhere; the throwing benchmark does not regress (Perry already beats V8 there — the remaining gap to Hermes is structural, see below).
Honest reporting: this does NOT close most of the never-throws gap
The issue's framing (and my initial hypothesis) was that the 3 ms → 16 ms penalty for having a
trywas mostlyoptnone. It is not. Removingoptnonebuys ~3 of the 13 ms. The other ~10 ms is thejs_try_push+_setjmp+js_try_endcall sequence executed once per loop iteration — ~10 ns × 1M. That is structural to setjmp-based EH and is not addressable by this PR; closing it means a zero-cost-until-thrown table-driven scheme, which is a much larger change. I'd rather say so than dress up a 1.25x as a 5x.What this PR does buy is the removal of a silent, general tax: every function containing a
try— and every directasyncfunction — was previously compiled with optimization off, whether or not thetrywas hot. That penalty scales with the size of the function, not with how often it throws, and it is now gone.One measured regression, and why it is pre-existing
A synthetic "small
tryguard, then a hot loop" shape (bench_guard) went 79 ms → 93 ms. It is not the volatile set — the IR shows onlyflagmarked volatile, with the loop counter free. The cause: for that exact function shape, Perry's-O3output is slower than its-O0output, andoptnonewas accidentally protecting it. The proof is that the identical loop with notryat all — same code path in both compilers, no volatile, no setjmp — runs at 123 ms, i.e. worse than either. So the-O3cliff is pre-existing and reproducible without anytry; this PR merely stops masking it fortry-containing functions, and in fact lands better than the try-less baseline (93 ms vs 123 ms). Worth a separate issue; not a reason to keepoptnone.Validation
cargo test -p perry-codegen --lib— 183 passed, including 7 newvolatile_setjmpunit tests (store-destination parsing vs the stored pointer, GEP-derived stores marking the whole alloca, globals/heap left alone, already-volatile left alone,!invariant.loaddropped on upgrade, no-op when there are no try stores).(The 2 pre-existing integration-test compile errors in
perry-codegen—CompileOptions has no field named namespace_reexport_named_imports— are onmaintoo and unrelated.)origin/maintoolchain: see comment below.cargo fmt --all -- --checkclean;bash scripts/check_file_size.shclean.Files
crates/perry-codegen/src/volatile_setjmp.rs(new) — the promotion pass + soundness argument + unit testscrates/perry-codegen/src/block.rs—RegCountertry-region depth + store recording inemitcrates/perry-codegen/src/function.rs—enter/exit_try_region,to_irruns the pass lastcrates/perry-codegen/src/stmt/try_stmt.rs— bracket the try body + finally-protected catch bodycrates/perry-codegen/src/stmt/mod.rs— bracket the async rejection boundarycrates/perry-codegen/src/module.rs—attributes #1 = { noinline }(was{ noinline optnone })test-files/test_gap_try_setjmp_volatile.ts(new) — the 20-case adversarial suiteSummary by CodeRabbit
Bug Fixes
try/catch/finallybehavior across non-local control flow.Tests