Skip to content

Codegen-side state-machine fusion: next lever to close the Bun gap on promise_all_chains #691

Closed

Description

@proggeramlug

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 (__next body inlined into step closure), done-case Promise reuse (Expr::AsyncStepDone), INLINE_TRAP_NEXT + CURRENT_STEP_CLOSURE TLS packing, profile-atomic gating, macOS setjmp(3) → _setjmp(3) swap (the dominant ~50% kernel win — avoided ~400k sigprocmask/sigaltstack syscalls per run).
  • v0.5.820 — trace-phase mark specialization: mark_field_into_worklist helper skips MARK_SEEDS + interior-pointer fallback, dropped try_mark_value* from 32% → ~3% self-time.
  • v0.5.828 — build_valid_pointer_set dropped 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
  • callback time (user-code execution inside js_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_resolve on the in-flight next for 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::Closure path
  • 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

  1. added 5 commits that reference this issue on May 11, 2026
  2. proggeramlug commented on May 12, 2026

    @proggeramlug
    ContributorAuthor

    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

    • v0.5.843 (Phase 1) — __async_throw closure alloc skipped when the async fn has no awaiting try/catch (the common case). Step body's is-error arm emits Stmt::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 per promise_all_chains run.
    • v0.5.848 + v0.5.850 (Phase 2) — Self-reference primitive: new Expr::CurrentStepClosure (reads INLINE_TRAP.current_step TLS) replaces the captured step_id self-reference; new Expr::AsyncFirstCall wraps the wrapper's initial invocation with TLS setup. Step closure shrinks by one capture slot and the per-invocation js_box_alloc for 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 in Array.fromAsync / pending-Promise paths by adding TLS setup to async_step_fulfill_thunk / async_step_reject_thunk (those are the runtime callbacks for the pending-inner fallback in js_async_step_chain and don't run under Task::AsyncStep dispatch, so they didn't inherit its TLS write).
    • v0.5.855 (Phase 3) — transform_async_to_generator pre-pass strips await Promise.resolve(<provably-non-Promise>) to await <arg>. Conservative LocalId → is_non_promise analyzer seeded from param types (Number | Int32 | Boolean | String | Void | Null | BigInt | Symbol), propagated through let inits (including let 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_chain fast-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_*.ts probes 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 for Call exprs and any local bound to a Call).

    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 i param so it never hits js_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.

  3. added a commit that references this issue on May 12, 2026
  4. added a commit that references this issue on Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions