Repository navigation
perf: asyncpipe is 1.12x Node but NOT CPU-bound — needs event-loop/timer counters, not a sampler #8409
Description
Summary
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 (onesetTimeout(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 thesetTimeout(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
asyncpiperegressed +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_typeonly serves
queueMicrotask()/process.nextTick(), neither of whichasyncpipeuses. 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:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR=<your target>/releaseThe benchmark corpus and Node-oracle expected output are staged at
/Users/amlug/projects/perry/sweep-artifacts-0819b/ (sources/*.ts, expected/*.stdout).
export PERRY_NO_AUTO_OPTIMIZE=1 PERRY_NO_CACHE=1
perry sources/<row>.ts -o /tmp/x --no-cache
cmp <(/tmp/x) expected/<row>.stdout # verify BEFORE timing
/usr/bin/time -l /tmp/x # instructions retired + peak RSSProfiling: 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.- A diff-read is a hypothesis, not a result — measure before proposing. (fix(async_hooks): track scheduler resource lifecycles #8328 looked
obviously guilty on this exact corpus and measured +0.2%.) - Known unrelated flake:
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.
Activity