Repository navigation
perf: async lowering is 85% of asyncpipe — Perry pays 6.8x what Node pays for the same async/sync difference #8008
Description
Summary
Async lowering is 85% of asyncpipe, and Perry pays 6.8× what Node pays for the same async/sync difference.
| probe | instructions | vs full |
|---|---|---|
asyncpipe verbatim |
1997 M | — |
| same program, all async mechanically removed | 295 M | −85.2% |
Node pays ~7% for that same difference (0.096 → 0.090 s CPU). Perry pays 6.8×.
This issue exists because the components were filed and closed individually while the headline never had a ticket — so nothing tracks whether the gap as a whole is closing.
Decomposition (measured, gc-handoff/ASYNC2-NOTES.md)
| probe | what it isolates | instr |
|---|---|---|
a2_sync1x |
data plane only — objects, template literals, Map/Set, the whole pipeline | 295 M |
a2_pure1x |
same call/await topology, numbers through the promises | 661 M |
a2_pureobj1x |
identical, but carrying a plain object | 1180 M |
a2_pall1x |
Promise.all fan-out only (1200×200) |
573 M |
Reading it: async topology (24,000 activations × 3 async fns × 2 awaits) ≈ 366 M; carrying an object rather than a number ≈ +519 M; Promise.all fan-out ≈ 570 M.
Leaf profile of the 1× program (14 aggregated xctrace runs, 2034 samples):
| bucket | share |
|---|---|
| GC write barriers + layout side tables | 22.6% |
| object property lookup (name-keyed, string compare) | ~14.5% |
GC rooting (RuntimeHandleScope in runtime helpers) |
9.8% |
| promise / microtask runner | 9.1% |
boxes (is_registered_*_box_ptr, registry inserts) |
~6.5% |
| compiled user TypeScript | 0.9% |
0.9% of this program is the user's code.
⚠️ The headline predates three merged fixes — re-measure before acting
Since that decomposition was taken, #7927 (thenable probe), #7924 (Promise.all fast arm) and #7939 (async activation boxes released) all landed, and asyncpipe went 1.63× → 1.05× node on the quiet mini. Two of the four decomposition buckets have been attacked directly.
So the first task is to re-run the probe set on current main and republish the table. The 85% figure is the reason this issue exists; it is not necessarily still 85%. Do not open a fix against the old numbers.
What is known to be not the answer
- Lifting
main's aggregation loop out of the async function changes nothing (−0.4%). CLAUDE.md's "the transform boxes every body local asAny" weakness is real but is not where this program's time is —mainloops 24,000 times against 72,000 promise activations. - Removing Map/Set from the hot path: −4.0%. Not it either.
- ★
a2_nostr1x(template literal replaced by a constant) is a discarded probe — it came out +85% slower because replacing the literal flips the program into a different GC regime. Do not reuse it. - ★
asyncpipe_big.tsis not a valid scaled proxy (bench: asyncpipe@120 sits exactly on a nursery trigger boundary and is not a usable A/B target #7926): 120 batches → 0 GC cycles, 240 → 2 copying minors, 1200 → GC-dominated. Any scaling must be shown to preserve the mechanism under study.
Acceptance
- A republished decomposition table on current
main. - If a gap remains, a named mechanism with a sized lever — not "async is slow".
- Report instructions retired (load-independent) alongside any wall clock; the dev box has run at load 30–200 and cannot resolve 5%.
Related
#7909, #7910, #7911, #7933 (all closed — the components), #7926 (the benchmark's trigger cliff).
Activity