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
{{ message }}
Repository navigation
perf: asyncpipe regressed 55.6% (1.08x -> 1.62x Node) between 14468dcbc and 1b61941bc #8384
The asyncpipe benchmark regressed +55.6% between the 2026-08-17 and 2026-08-18
four-engine sweeps, moving from 1.08x Node to 1.62x Node. It is the only material
loss in a sweep where every other cell improved and the Perry/Node geomean went
1.076x -> 0.804x.
Evidence that it is real, not noise
This sweep was noisier than the last one on sub-100 ms cells (median Perry-cell spread
15.9%), so the best-of-five delta alone would not carry the claim. The distributions
are disjoint, which noise cannot produce:
sweep
five samples (sorted, seconds)
2026-08-17 @ 14468dcbc
0.0833 0.0837 0.0838 0.0840 0.0843
2026-08-18 @ 1b61941bc
0.1296 0.1313 0.1343 0.1512 0.1527
The slowest 08-17 run is still 35% faster than the fastest 08-18 run.
It is also work, not scheduling: asyncpipe retires 1.72 G instructions against
Node's 0.79 G. Peak RSS is 61.2 MiB (Node 88.0 MiB), so this is not a memory-pressure
story either.
Scope
Perry range: 14468dcbc .. 1b61941bc, 102 commits.
Corpus source: gc-handoff/apps/asyncpipe.ts, unchanged between the two sweeps.
Correctness unaffected — Perry is 19/19 byte-exact against the Node oracle in both sweeps.
Porffor cannot compile this benchmark, so there is no third-engine cross-check.
Suggested starting point
asyncpipe is the async/microtask-heaviest cell in the corpus, and it was the row that
gained the most in the 2026-08-14 -> 2026-08-17 window (-92.6%). A bisect over the 102
commits is cheap here: the benchmark runs in ~130 ms and the signal is 55%, far outside
the 1-2% spread the long cells show.
Protocol
Quiet M1 mini under ~/bench.lock, verdict CLEAN (load 1.38 -> 2.08, ceiling 2.5,
foreign processes 0/0). Five deterministic shuffled interleaved repeats after a warm
pass, with exit status and byte-exact output re-checked on every run.
Full sweep: gc-handoff/current-sweep-2026-08-18-RESULTS.md.
Bisected: #8303 (2a28bdcbd) is the whole regression
Automated bisect over 14468dcbc..1b61941bc, seven hops. Each hop built with the full -p perry -p perry-runtime-static -p perry-stdlib-static set, compiled asyncpipe.ts, verified byte-exact output against the Node oracle before timing it, then classified
on best-of-7. Good cells landed at 0.093-0.094 and bad cells at 0.158-0.162, so no call
was anywhere near the 0.125 threshold.
First bad commit: 2a28bdcbd — "fix(runtime): release closure-visible async boxes" (#8303), which closes #8213.
The parent is indistinguishable from the good endpoint on all three metrics, and the
culprit is indistinguishable from main. #8303 alone accounts for essentially the entire
regression — nothing else in the 102-commit range contributes:
wall +69%
instructions +32%
peak RSS +54%
GC cycle count is flat (26 vs 25), so this is not "collecting more often". It is more
work and more residency per async activation frame.
The RSS direction is worth flagging: #8303 was itself a retention fix, and on this
workload it moves peak RSS the wrong way by 54%.
Reproduces locally
Contrary to what I wrote when filing, this is not mini-only — it reproduces on the dev
box at +67.5% with fully disjoint distributions, which is what made a 7-hop bisect
cheap.
One hypothesis already killed
#8328 (async_hooks scheduler lifecycles) looked obviously guilty in the diff — queue_microtask_with_type gained a per-microtask object allocation and passes force_allocate: true, defeating the existing !hooks_active() fast-out. Measured: +0.2%,
overlapping distributions. It is not the cause; queue_microtask_with_type only serves queueMicrotask()/process.nextTick() and asyncpipe uses neither. Recording it so nobody
re-derives it.
Not a revert
#8303 fixes a real leak (#8213: ~1100 box cells retained per warm App Route request). The
resolution needs to restore selectivity about how much of the activation frame gets boxed
and tracked, without reopening that retention. Its changelog notes that plain async
functions now hand their complete boxed activation frame to the release/reuse path,
replacing the ~287-line static analysis in generator/box_release.rs, plus per-cell
closure capture counts — that is the leading hypothesis for gaining both instructions and
residency, but it is a hypothesis, not a measurement.
Using the required static-wrapper build in both arms, with PERRY_NO_AUTO_OPTIMIZE=1, PERRY_NO_CACHE=1, and byte-exact output checked on every run:
before
after
change
wall (best-of-15 interleaved)
0.1570 s
0.0963 s
-38.7%
instructions retired (best-of-9)
1.725 G
1.361 G
-21.1%
peak RSS (best-of-9)
61.3 MiB
38.8 MiB
-36.7%
The hypothesis was confirmed by runtime counters: before, asyncpipe terminal-released 192,859 cells but published 0, leaving all 192,859 resident. After excluding only the compiler-private step closure's redundant whole-frame edges, 192,859/192,859 publish, resident cells are 1,626, and all three box registries finish at zero.
#8213 remains protected: exact capture tracking is still emitted for nested/user closures; escaped_closures_defer_activation_cell_publication_until_gc_death, full_trace_treats_drained_closure_boxes_as_ephemeron_edges, and completed_activation_residue_is_bounded_not_linear all pass. The full runtime/transform/perry-bin suites and all lint gates pass, and all 19 sweep programs remain byte-exact on stdout and stderr.
Activity
proggeramlug commentedon Aug 18, 2026
Bisected: #8303 (
2a28bdcbd) is the whole regressionAutomated bisect over
14468dcbc..1b61941bc, seven hops. Each hop built with the full-p perry -p perry-runtime-static -p perry-stdlib-staticset, compiledasyncpipe.ts,verified byte-exact output against the Node oracle before timing it, then classified
on best-of-7. Good cells landed at 0.093-0.094 and bad cells at 0.158-0.162, so no call
was anywhere near the 0.125 threshold.
First bad commit:
2a28bdcbd— "fix(runtime): release closure-visible async boxes" (#8303), which closes #8213.14468dcbc(good endpoint, 08-17 sweep)e5ecb4f5d(parent of culprit)2a28bdcbd(#8303, first bad)1b61941bc(main at sweep time)The parent is indistinguishable from the good endpoint on all three metrics, and the
culprit is indistinguishable from main. #8303 alone accounts for essentially the entire
regression — nothing else in the 102-commit range contributes:
GC cycle count is flat (26 vs 25), so this is not "collecting more often". It is more
work and more residency per async activation frame.
The RSS direction is worth flagging: #8303 was itself a retention fix, and on this
workload it moves peak RSS the wrong way by 54%.
Reproduces locally
Contrary to what I wrote when filing, this is not mini-only — it reproduces on the dev
box at +67.5% with fully disjoint distributions, which is what made a 7-hop bisect
cheap.
One hypothesis already killed
#8328(async_hooks scheduler lifecycles) looked obviously guilty in the diff —queue_microtask_with_typegained a per-microtask object allocation and passesforce_allocate: true, defeating the existing!hooks_active()fast-out. Measured: +0.2%,overlapping distributions. It is not the cause;
queue_microtask_with_typeonly servesqueueMicrotask()/process.nextTick()and asyncpipe uses neither. Recording it so nobodyre-derives it.
Not a revert
#8303 fixes a real leak (#8213: ~1100 box cells retained per warm App Route request). The
resolution needs to restore selectivity about how much of the activation frame gets boxed
and tracked, without reopening that retention. Its changelog notes that plain async
functions now hand their complete boxed activation frame to the release/reuse path,
replacing the ~287-line static analysis in
generator/box_release.rs, plus per-cellclosure capture counts — that is the leading hypothesis for gaining both instructions and
residency, but it is a hypothesis, not a measurement.
An agent is working the fix now.
proggeramlug commentedon Aug 18, 2026
Measured fix is in #8392.
Using the required static-wrapper build in both arms, with
PERRY_NO_AUTO_OPTIMIZE=1,PERRY_NO_CACHE=1, and byte-exact output checked on every run:The hypothesis was confirmed by runtime counters: before, asyncpipe terminal-released 192,859 cells but published 0, leaving all 192,859 resident. After excluding only the compiler-private step closure's redundant whole-frame edges, 192,859/192,859 publish, resident cells are 1,626, and all three box registries finish at zero.
#8213 remains protected: exact capture tracking is still emitted for nested/user closures;
escaped_closures_defer_activation_cell_publication_until_gc_death,full_trace_treats_drained_closure_boxes_as_ephemeron_edges, andcompleted_activation_residue_is_bounded_not_linearall pass. The full runtime/transform/perry-bin suites and all lint gates pass, and all 19 sweep programs remain byte-exact on stdout and stderr.