You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
asyncpipe runs at 1.12x Node with an instruction ratio of 1.71, but it is not CPU-bound — a sampling profiler cannot see it. Aggregating sample over 14 runs of
an ~88 ms program yields 1 usable self-time sample. The process is almost always parked,
not running.
What that means
asyncpipe is the async/microtask-heaviest row in the corpus: async/await, Promise.all
fan-out per batch, and a setTimeout(…, 0) yield at every batch boundary
(sweep-artifacts-0819b/sources/asyncpipe.ts, function tick). The wall-clock gap is
therefore in event-loop and timer scheduling latency, not in executing user code.
Perry retires 1.36 G instructions against Node's 0.79 G on this row, so there is extra work
— it is simply spread across the scheduling machinery rather than concentrated anywhere a
sampler will catch.
Suggested approach
Sampling is the wrong instrument here. Instead:
Count, don't sample. Instrument the timer/microtask path: how many timer registrations,
wakeups, and microtask-queue drains happen per batch, and what does each cost? Compare that
count against what the program logically requires (one setTimeout(0) per batch).
Measure the floor. Write a micro-benchmark that does nothing but await new Promise(r => setTimeout(r, 0)) N times, and compare Perry against Node. That isolates per-yield latency
from the benchmark's real work. If Perry's per-yield cost is materially higher, that alone
explains the row.
Then look at js_promise_run_microtasks, the timer wheel, and the setTimeout(0) path
specifically — a zero-delay timer should not need a full timer-wheel round trip.
Prior work on this row, so it is not re-derived
asyncpipe regressed +55.6% in the 2026-08-18 sweep and was fixed by perf(codegen): skip redundant async frame capture tracking #8392 (codegen was
marking the compiler-private async step closure's whole frame as escaped, so released box
cells could never publish — published=0 -> 192859). It is now back to its pre-regression
standing. This ticket is about the remaining 1.12x, which predates that regression.
fix(async_hooks): track scheduler resource lifecycles #8328 (async_hooks scheduler lifecycles) was measured against this row and is not a
factor: +0.2%, overlapping distributions. queue_microtask_with_type only serves queueMicrotask()/process.nextTick(), neither of which asyncpipe uses. Do not revisit it.
Acceptance
asyncpipe beats Node (< 1.00x), or a written conclusion with counter evidence that the
remaining gap is inherent to the event-loop design. No other row regressed, all 19 byte-exact.
How to build and measure (shared setup)
Build with the static wrappers or you link a stale runtime and both A/B arms behave
identically — a vacuous result:
The benchmark corpus and Node-oracle expected output are staged at /Users/amlug/projects/perry/sweep-artifacts-0819b/ (sources/*.ts, expected/*.stdout).
Profiling: Perry strips its output binaries, so sample attributes everything to ???. Set PERRY_KEEP_SYMBOLS=1 PERRY_DEBUG_SYMBOLS=1 at compile time — the binary keeps
~1900 symbols and stays byte-exact — then read sample's "Sort by top of stack" section.
These programs are short; aggregate across ~14 runs for a usable sample count.
Constraints that apply to any fix here
Correctness first: all 19 corpus programs must stay byte-exact against their Node oracle.
Report wall time AND instructions retired AND peak RSS together. The project rule is
minimize RSS and keep best compute — never trade one for the other silently.
cargo test --release -p perry-runtime --lib, -p perry --bin perry, and bash scripts/run_lint_gates.sh (all 50) must pass.
Summary
asyncpiperuns at 1.12x Node with an instruction ratio of 1.71, but it isnot CPU-bound — a sampling profiler cannot see it. Aggregating
sampleover 14 runs ofan ~88 ms program yields 1 usable self-time sample. The process is almost always parked,
not running.
What that means
asyncpipeis the async/microtask-heaviest row in the corpus:async/await,Promise.allfan-out per batch, and a
setTimeout(…, 0)yield at every batch boundary(
sweep-artifacts-0819b/sources/asyncpipe.ts, functiontick). The wall-clock gap istherefore in event-loop and timer scheduling latency, not in executing user code.
Perry retires 1.36 G instructions against Node's 0.79 G on this row, so there is extra work
— it is simply spread across the scheduling machinery rather than concentrated anywhere a
sampler will catch.
Suggested approach
Sampling is the wrong instrument here. Instead:
wakeups, and microtask-queue drains happen per batch, and what does each cost? Compare that
count against what the program logically requires (one
setTimeout(0)per batch).await new Promise(r => setTimeout(r, 0))N times, and compare Perry against Node. That isolates per-yield latencyfrom the benchmark's real work. If Perry's per-yield cost is materially higher, that alone
explains the row.
js_promise_run_microtasks, the timer wheel, and thesetTimeout(0)pathspecifically — a zero-delay timer should not need a full timer-wheel round trip.
Prior work on this row, so it is not re-derived
asyncpiperegressed +55.6% in the 2026-08-18 sweep and was fixed by perf(codegen): skip redundant async frame capture tracking #8392 (codegen wasmarking the compiler-private async step closure's whole frame as escaped, so released box
cells could never publish —
published=0 -> 192859). It is now back to its pre-regressionstanding. This ticket is about the remaining 1.12x, which predates that regression.
factor: +0.2%, overlapping distributions.
queue_microtask_with_typeonly servesqueueMicrotask()/process.nextTick(), neither of whichasyncpipeuses. Do not revisit it.Acceptance
asyncpipebeats Node (< 1.00x), or a written conclusion with counter evidence that theremaining gap is inherent to the event-loop design. No other row regressed, all 19 byte-exact.
How to build and measure (shared setup)
Build with the static wrappers or you link a stale runtime and both A/B arms behave
identically — a vacuous result:
The benchmark corpus and Node-oracle expected output are staged at
/Users/amlug/projects/perry/sweep-artifacts-0819b/(sources/*.ts,expected/*.stdout).Profiling: Perry strips its output binaries, so
sampleattributes everything to???. SetPERRY_KEEP_SYMBOLS=1 PERRY_DEBUG_SYMBOLS=1at compile time — the binary keeps~1900 symbols and stays byte-exact — then read
sample's "Sort by top of stack" section.These programs are short; aggregate across ~14 runs for a usable sample count.
Constraints that apply to any fix here
minimize RSS and keep best compute — never trade one for the other silently.
cargo test --release -p perry-runtime --lib,-p perry --bin perry, andbash scripts/run_lint_gates.sh(all 50) must pass.obviously guilty on this exact corpus and measured +0.2%.)
box::release_tests::the_intrusive_free_list_round_trips_a_whole_cohort(flaky: the_intrusive_free_list_round_trips_a_whole_cohort asserts a delta on a process-global counter under parallel tests #8401). If it fails once and reruns clean, it is not yours.