Repository navigation
Codegen-side state-machine fusion: next lever to close the Bun gap on promise_all_chains #691
Description
Context
The v0.5.816 → v0.5.833 perf arc closed benchmarks/app-patterns/kernels/promise_all_chains.ts from 8.1× Bun → ~1.68× Bun via runtime + GC optimizations:
- v0.5.816 — P1–P5: async-step closure fusion (
__nextbody inlined into step closure), done-case Promise reuse (Expr::AsyncStepDone),INLINE_TRAP_NEXT+CURRENT_STEP_CLOSURETLS packing, profile-atomic gating, macOSsetjmp(3)→_setjmp(3)swap (the dominant ~50% kernel win — avoided ~400ksigprocmask/sigaltstacksyscalls per run). - v0.5.820 — trace-phase mark specialization:
mark_field_into_worklisthelper skipsMARK_SEEDS+ interior-pointer fallback, droppedtry_mark_value*from 32% → ~3% self-time. - v0.5.828 —
build_valid_pointer_setdropped malloc sort+merge entirely (malloc-tracked types never carry interior pointers, so they don't need the floor-search structure).
After all that, the remaining ~1.68× gap is structural, not addressable by runtime-only changes. This issue tracks what would.
What was tried and rejected
Last session attempted the inline-execution structural change — when js_async_step_chain sees a primitive value or already-Fulfilled/Rejected inner Promise, run the step closure inline instead of enqueuing Task::AsyncStep (Bun's trick). Two gate designs were tested against the 7-probe ordering invariant suite at test-files/test_microtask_inv_*.ts:
Conservative (inline only when queue is empty + depth ≤ 64): preserves all 7 probes byte-identical to Node. But the gate never fires on promise_all_chains — 50 concurrent unit-of-work tasks keep step closures of other activations in flight continuously, so the queue is never empty at decision time. [mt-profile] inline_step: hit=0 depth_cap=0 queue_busy=100000. Zero wins.
Aggressive (depth-only gate): fires 100k times (hit=100000), breaks probe 01 (two_fn_interleave) — concurrent async fns stop alternating. Kernel improvement: only ~2% even with the probe broken.
Inline path is correct — verified on a single-chain linear await Promise.resolve() × 100k probe: 35.7 ms → 20.2 ms (-43% mean, -4% min). The implementation works; it's a workload-shape mismatch with promise_all_chains.
Why runtime-only inlining hit a ceiling
Profile reframing (PERRY_MT_PROFILE=1 /tmp/pac):
- Total kernel: ~47 ms
callbacktime (user-code execution insidejs_closure_call2): 39.5 ms (~84%) — the actual state-machine body running- Queue + resolve overhead: ~7–8 ms (17–20% of total)
- At most ~50% of the queue+resolve bucket is reclaimable via inlining (the inlined path still does
js_promise_resolveon the in-flightnextfor the final result) - Realistic ceiling for runtime-only inlining on this workload: 3–5%, requires breaking probe 01.
The remaining ~1.68× gap lives in the user-code execution path itself, not the queueing infrastructure.
What would actually close it: codegen-side state-machine fusion
The async-to-generator transform splits each await into a separate state-machine state. For await Promise.resolve(<primitive>) (the steady-state shape of promise_all_chains), each state does a small amount of user code, suspends, and resumes — with closure-call overhead per state transition.
Bun's secret is largely that consecutive synchronous awaits don't actually generate separate state transitions — they fuse into straight-line code when the awaited value is known statically (or proven Fulfilled at the call site). Two angles:
(1) HIR/transform-side fusion. Recognize sequences of await Promise.resolve(<primitive>) in crates/perry-transform/src/generator.rs and the async-to-generator pass. Collapse consecutive such awaits into a single state transition that does N steps of user code before yielding. Trades a microtask boundary per await for one boundary per sequence. Spec-observable for the same reasons the runtime-only inline change is — see this issue's probe suite for the invariants any fusion must preserve.
(2) State-machine representation change. Move away from per-state closure-call dispatch entirely. Possible representations: switch on state inside one body with goto-style transitions (LLVM's indirectbr or computed jumps); stackful coroutines (makecontext/swapcontext family or hand-rolled equivalent); or per-await-target inlining where the transform proves the continuation is small enough to inline at every callsite. Estimated 10–20% but with substantially more code-touched surface area than any of v0.5.816–v0.5.833 — wide blast radius across HIR + transform + codegen.
Either approach is its own deliberate session with the same probe suite as the safety net.
Pointers
- Runtime entrypoints:
crates/perry-runtime/src/promise.rs::js_async_step_chain,js_promise_run_microtasks - Transform:
crates/perry-transform/src/generator.rs::build_async_step_driver_direct,crates/perry-transform/src/async_to_generator.rs - Codegen for step closures: standard
Expr::Closurepath - Ordering invariant probe suite:
test-files/test_microtask_inv_*.ts(7 probes covering FIFO, interleaving, nested unwrap, finally, Promise.all) - Canary:
test-files/test_issue_256_microtask_ordering.ts - Benchmark:
benchmarks/app-patterns/kernels/promise_all_chains.ts+./benchmarks/app-patterns/run.sh promise_all_chains - Profile:
samply record /tmp/pac_big(10× kernel for sample density)
Risk profile
High. State machine transform changes have wide blast radius — every async function in the codebase compiles through this path. The probe suite + 13-test sanity sweep + the existing test_issue_256_microtask_ordering are the correctness budget; any session attempting this should treat probe failures as immediate revert signals.
Estimated win
10–20% on promise_all_chains for HIR fusion alone. State-machine representation change is open-ended but could close substantially more (Bun's own dispatch is the existence proof). Either way the win is bounded by how much of the 39.5 ms callback bucket can be re-shaped into something cheaper to dispatch.
Activity
perf(transform): #691 skip __async_throw alloc when no awaiting try/c…
fix(runtime,codegen): isNaN/isFinite coercion + padStart OOM (v0.5.844)
fix(runtime): #691 Phase 2 follow-up — TLS setup in async_step thunks…
perf(transform): #691 Phase 3 strip await Promise.resolve(<non-Promis…
fix(ci): cargo fmt drift in async_to_generator.rs (v0.5.857)
proggeramlug commentedon May 12, 2026
Closing — landed via three commits on main, but the lever that actually moved the needle wasn't either of the two predicted in this issue body.
What shipped
__async_throwclosure alloc skipped when the async fn has no awaiting try/catch (the common case). Step body's is-error arm emitsStmt::Throw(value)directly; outer try/catch re-enters via the existing__step(e, true)path. 41.7 → 37.1 ms (−11%), 1.77× → 1.69× bun. ~50k closure allocs saved perpromise_all_chainsrun.Expr::CurrentStepClosure(readsINLINE_TRAP.current_stepTLS) replaces the capturedstep_idself-reference; newExpr::AsyncFirstCallwraps the wrapper's initial invocation with TLS setup. Step closure shrinks by one capture slot and the per-invocationjs_box_allocfor the step-id box is gone (boxes were never freed — also fixes a leak). Perf-neutral on the kernel min but structurally cleaner. v0.5.850 fixed a hang inArray.fromAsync/ pending-Promise paths by adding TLS setup toasync_step_fulfill_thunk/async_step_reject_thunk(those are the runtime callbacks for the pending-inner fallback injs_async_step_chainand don't run underTask::AsyncStepdispatch, so they didn't inherit its TLS write).transform_async_to_generatorpre-pass stripsawait Promise.resolve(<provably-non-Promise>)toawait <arg>. ConservativeLocalId → is_non_promiseanalyzer seeded from param types (Number | Int32 | Boolean | String | Void | Null | BigInt | Symbol), propagated through let inits (includinglet x = await Promise.resolve(y)where y is non-Promise → x is non-Promise after strip). 41.7 → 28.5 ms min (−30% from baseline), perry now on par with bun on the kernel (within system noise). Callback bucket 63.5 → 23.8 ms, 150k Promise allocations eliminated,js_async_step_chainfast-path goes from 0% → 100% hits.Why the original framing missed
The issue body said: "the remaining ~1.68× gap lives in the user-code execution path itself, not the queueing infrastructure." That was true but misread the cost center. The 39.5 ms callback bucket wasn't dominated by per-state-transition dispatch overhead — it was dominated by per-await Promise allocation that was about to be unwrapped on the very next runtime call. Both proposed levers (HIR-side fusion of consecutive awaits, and state-machine representation change via indirectbr/coroutines) targeted dispatch shape, not Promise allocations. They would have moved 5–10% at most; the Promise-strip moved 20%.
The rejected aggressive inline gate (depth-only, broke probe 01) attempted to skip the microtask queue for resolved inners. Phase 3 does something different — it keeps the microtask hop intact (still one yield per await) but skips the Promise wrapper around the awaited primitive. Spec-equivalent for non-Promise args (same hop count, same resolved value, no thenable resolution), so all 7
test_microtask_inv_*.tsprobes stay byte-equal to Node including probe 01 (two-fn interleave) and probe 05 (await-of-Promise — explicitly excluded from the strip because the analyzer returns false forCallexprs and any local bound to aCall).What's left
A small residual gap on stickier workloads (1-await mean still a few ms behind bun) coming from per-state-transition runtime overhead — closure dispatch, captures-cache miss on the step closure (the step closure's captures include the per-invocation-unique
iparam so it never hitsjs_closure_alloc_with_captures_singleton's LRU), and microtask queue churn. Smaller and harder; diminishing returns. Filing as a follow-up if anyone wants to push further.fix(stable_hash): cover 4 new Expr variants from upstream merges
feat(cache): closes #686 — HIR-addressable per-module object cache (P…
fix(runtime): #5437 — async-step result-promise identity + reaction p…