Skip to content

perf: asyncpipe regressed 55.6% (1.08x -> 1.62x Node) between 14468dcbc and 1b61941bc #8384

Closed

Description

@proggeramlug

Summary

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.

Activity

  1. proggeramlug commented on Aug 18, 2026

    @proggeramlug
    ContributorAuthor

    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.

    commit wall (s) instructions peak RSS
    14468dcbc (good endpoint, 08-17 sweep) 0.0939 1.36 G 39.7 MiB
    e5ecb4f5d (parent of culprit) 0.0935 1.31 G 39.7 MiB
    2a28bdcbd (#8303, first bad) 0.1584 — —
    1b61941bc (main at sweep time) 0.1573 1.73 G 61.3 MiB

    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.

    An agent is working the fix now.

  2. proggeramlug commented on Aug 18, 2026

    @proggeramlug
    ContributorAuthor

    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:

    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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions