Skip to content

perf: async lowering is 85% of asyncpipe — Perry pays 6.8x what Node pays for the same async/sync difference #8008

Description

@proggeramlug

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-out570 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 as Any" weakness is real but is not where this program's time is — main loops 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.ts is 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

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

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions