Skip to content

tracking: close the remaining 7 losing rows vs Node (12/19 won, geomean 0.7775x) #8410

Open

Description

@proggeramlug

Purpose

Tracking issue for closing the remaining gap to Node on the 19-row benchmark corpus. Perry
currently beats Node on 12 of 19 rows with a geomean of 0.7775x. This enumerates the
seven rows that still lose, each with its own ticket, evidence and owner-ready repro.

Measured on 8bfdac447 (2026-08-19 sweep, quiet M1 mini, verdict CLEAN, load 1.42 -> 2.08,
best-of-five interleaved, byte-exact output re-checked on every run).

Current standing

value
Perry beats Node 12/19
geomean Perry/Node 0.7775x
geomean Perry/scriptc 0.359x
geomean Perry/Porffor 0.271x
peak RSS vs Node lower on all 19 rows

The seven remaining rows

row P/Node instr ratio dominant cost ticket
cycles 1.01 2.51 init_typed_shape_layout 20.1% #8405
shapes 1.06 1.25 try_read_tracked_gc_header + shape_descriptor_by_id = 100% #8406
asyncpipe 1.12 1.71 not CPU-bound — event-loop/timer latency #8409
churn 1.16 0.99 memory stalls; lowest IPC in corpus (13.35) #8408
pipeline 1.26 1.88 libm fmod 7.3%, plus user-code method dispatch #8407
interp 1.55 1.60 layout bookkeeping ~20.8% #8405
iso_miss 2.02 1.97 string concat 17.9% (#8394), layout bookkeeping #8394, #8405

The structural finding

Splitting all 19 rows by instruction ratio is unambiguous:

  • every row Perry wins has instructions/Node <= 1.07, mostly far below
    (fib40 0.22, churn_read 0.27, tree_wide 0.37)
  • every row Perry loses has >= 1.25, except churn

Perry's codegen quality is not the problem. Where it emits comparable work it wins
comfortably. It loses precisely where it executes more work. That makes each remaining row a
question of "what work can be deleted", which is why every ticket above names a specific
frame and a percentage rather than a vague direction.

churn is the sole exception and therefore the most interesting: 0.99 instruction ratio and
still 1.16x slower. That one is locality, not work.

Ordering suggestion

Method notes for anyone picking these up

Every ticket carries the same build/measure/profile setup and the same constraints. Two that
have cost real time on this corpus and are worth repeating here:

  • Build the -static wrapper crates or you link a stale runtime and both A/B arms behave
    identically — a vacuous "no change".
  • A compelling diff read is a hypothesis, not a result. fix(async_hooks): track scheduler resource lifecycles #8328 looked obviously guilty on
    this exact corpus and measured +0.2%.

Activity

  1. proggeramlug commented on Aug 20, 2026

    @proggeramlug
    ContributorAuthor

    Fresh four-engine sweep on the quiet mini, Perry @ 415f4e22e (includes #8482, #8483, #8484, #8487, #8488). Harness verdict CLEAN, load 1.46 → 2.12, 5 shuffled interleaved repeats, every run's output re-checked. Node oracle v26.5.1.

    12/19 → 15/19 rows won. Geomean 0.7083x wall.

    bench wall vs node instr vs node cycles vs node perry RSS node RSS RSS ratio
    churn_read 0.26x 0.15x 0.25x 5.5 M 82.0 M 0.07x
    fib40 0.33x 0.22x 0.33x 4.5 M 80.2 M 0.06x
    tree_wide 0.34x 0.27x 0.13x 60.7 M 641.7 M 0.09x
    deeplist 0.47x 0.67x 0.45x 53.3 M 145.0 M 0.37x
    tree 0.48x 0.30x 0.20x 27.8 M 312.1 M 0.09x
    retain1 0.57x 0.81x 0.47x 74.4 M 166.5 M 0.45x
    push_num 0.60x 0.70x 0.59x 24.4 M 85.0 M 0.29x
    retain_wide1 0.61x 0.84x 0.53x 117.5 M 212.1 M 0.55x
    churn 0.80x 0.89x 0.77x 20.8 M 85.6 M 0.24x
    churn_alloc 0.80x 1.03x 0.79x 21.0 M 85.3 M 0.25x
    cycles 0.81x 1.91x 0.78x 16.6 M 83.5 M 0.20x
    push_cls 0.82x 1.05x 0.79x 21.0 M 85.4 M 0.25x
    retain 0.82x 1.02x 0.69x 207.9 M 304.0 M 0.68x
    retain_wide 0.84x 1.02x 0.74x 352.8 M 446.4 M 0.79x
    shapes 1.00x 1.21x 0.88x 32.1 M 88.5 M 0.36x
    asyncpipe 1.10x 1.70x 1.07x 38.7 M 89.0 M 0.44x
    pipeline 1.12x 1.75x 1.05x 21.1 M 85.0 M 0.25x
    interp 1.50x 1.51x 1.44x 31.4 M 94.6 M 0.33x
    iso_miss 1.88x 1.99x 2.01x 31.2 M 89.4 M 0.35x

    Geomean instructions 0.8086x, geomean peak RSS 0.2565x — about a quarter of Node's memory across the corpus, with the compute win intact.

    Against the previous mini sweep (14468dcbc), same harness:

    then now
    geomean wall 1.0665x 0.7083x
    rows won 8/19 15/19

    Biggest movers: tree_wide −75.9%, tree −59.8%, churn −59.2%, fib40 −55.1%, pipeline −46.0%, cycles −39.2%, retain_wide −37.8%, interp −33.2%, deeplist −24.5%, retain −23.3%, iso_miss −21.5%.

    The four remaining losses split into two different problems

    interp (1.50x) and iso_miss (1.88x) are genuinely CPU-bound. Instruction ratio tracks the wall ratio almost exactly (1.51x / 1.99x), and cycles track too. These need fewer instructions, not better scheduling — the dynamic numeric envelope work on #8485 is aimed here.

    asyncpipe (1.10x) and pipeline (1.12x) are not. Both execute ~70–75% more instructions than Node yet spend only 5–7% more cycles, so they retire at a much better IPC and the wall gap is not coming from the instruction count. Confirms #8409's read that asyncpipe needs event-loop/timer work rather than codegen. pipeline deserves the same treatment and is separately tracked by #8243.

    shapes sits at exactly 1.00x with a 1.21x instruction ratio — one good pass on #8406's try_read_tracked_gc_header hot spot should flip it.

  2. proggeramlug commented on Aug 20, 2026

    @proggeramlug
    ContributorAuthor

    Re-swept on the quiet mini after #8491 landed. Perry 8d1cca158, verdict CLEAN, load 1.49 → 2.16, 5 shuffled interleaved repeats. Now a 20-row corpus — see below.

    The 19 original rows are unchanged. Every instruction ratio is flat to within 0.02x of the previous sweep, confirming #8491 costs the corpus nothing. Two wall numbers moved more than noise would suggest — churn_alloc 0.80x → 0.92x and shapes 1.00x → 1.04x — but both have flat instruction ratios (1.03→1.04 and 1.21→1.22), so that is host contention, not a regression.

    bench wall vs node prev instr vs node RSS vs node
    churn_read 0.27x 0.26x 0.16x 0.07x
    fib40 0.33x 0.33x 0.22x 0.06x
    tree_wide 0.35x 0.34x 0.27x 0.10x
    deeplist 0.47x 0.47x 0.67x 0.37x
    tree 0.50x 0.48x 0.30x 0.09x
    retain1 0.54x 0.57x 0.82x 0.45x
    push_num 0.62x 0.60x 0.71x 0.29x
    retain_wide1 0.62x 0.61x 0.83x 0.55x
    churn 0.80x 0.80x 0.89x 0.24x
    push_cls 0.82x 0.82x 1.05x 0.25x
    retain 0.82x 0.82x 1.04x 0.68x
    cycles 0.85x 0.81x 1.93x 0.20x
    retain_wide 0.86x 0.84x 1.03x 0.79x
    churn_alloc 0.92x 0.80x 1.04x 0.25x
    shapes 1.04x 1.00x 1.22x 0.36x
    asyncpipe 1.13x 1.10x 1.70x 0.44x
    pipeline 1.14x 1.12x 1.77x 0.25x
    interp 1.51x 1.50x 1.51x 0.35x
    iso_miss 1.94x 1.88x 1.99x 0.35x
    typed_array 5.25x (new) 8.66x 0.06x

    Geomean 0.7999x over 20 rows, 14/20 won. On the unchanged 19-row basis it is 0.7083x, 15/19 — identical to the previous sweep.

    The corpus was missing its worst row

    typed_array enters at 5.25x Node, 8.66x instructions — roughly three times worse than anything else Perry runs, and it has been invisible to every sweep ever taken.

    The reason is structural, not an oversight: benchmarks/suite/bench_typed_array_untyped_access.ts prints its own wall-clock timings, and the sweep's correctness gate is a byte-exact stdout compare against the Node oracle, so a self-timing program can never be admitted. I added gc-handoff/apps/typed_array.ts — identical workload, emitting only checksum:-821955270, verified deterministic across runs — and registered it as row 20. The suite version stays as the #5525 ratio metric; the corpus one is the regression tripwire.

    Worth stating plainly: #8491 improved that workload by −44.1% wall and −34.7% instructions, and no sweep would have noticed either the win or a later regression. Any benchmark whose own output encodes timing is structurally excluded from the gate that protects it.

    Tracked as #8496, with an agent on it.

    Where the remaining six losses stand

  3. proggeramlug commented on Aug 21, 2026

    @proggeramlug
    ContributorAuthor

    Summary

    • recognize s = s + a + b + ... for proven string accumulators in local, captured, and module-global slots
    • lower the accumulator and suffix operands left-to-right into one rooted runtime call
    • reuse a unique accumulator's existing capacity, or allocate the complete result exactly once
    • preserve the existing dynamic-add fallback when the string proof is incomplete

    Refs #8410.

    Profile

    The macOS sample utility hung before collecting a sample on this host, including when reproduced against yes. I used the same symbol-preserving build (PERRY_KEEP_SYMBOLS=1 PERRY_DEBUG_SYMBOLS=1, about 17.7k symbols in the generated fixture) with Instruments Time Profiler over a private 10x iso_miss fixture instead.

    Baseline sampled CPU was 8,422 ms. The largest leaves were evalNode (38.90%), js_string_concat_chain (14.15%), lookup (12.19%), memmove (4.19%), arena allocation (3.72%), and the separate accumulator append/storage/rooting envelope (6.80% combined).

    Candidate sampled CPU was 7,716 ms (-8.38%). The suffix concat plus separate append envelope becomes one js_string_append_chain leaf (15.15%); evalNode remained dominant (42.69%) and lookup remained essentially unchanged (12.08%).

    Performance

    Five shuffled, interleaved repeats on macOS, comparing a locally built 8d1cca158 baseline with this branch. Both arms used the identical -p perry -p perry-runtime-static -p perry-stdlib-static release build set, and archive mtimes were verified after each build. Medians are shown.

    iso_miss before after delta
    wall 3.36 s 2.39 s -28.87%
    instructions 12,484,326,208 11,497,214,518 -7.91%
    cycles 2,501,750,408 2,267,980,449 -9.34%
    peak RSS 32,817,152 B 32,866,304 B +49,152 B (+0.15%)

    The wall ranges overlap widely (1.41-5.00 s before, 1.24-7.07 s after), so the wall median is not treated as a real 28.9% result. Instructions retired are the primary signal. The observed RSS cost is 48 KiB; the implementation does not add a cache or retained reserve and allocates the complete result once, but the measured peak is reported rather than rounded away.

    Whole-corpus medians:

    program wall instructions cycles peak RSS
    asyncpipe -27.78% -0.13% -4.31% +0.04%
    churn +0.00% -0.05% +2.56% +0.07%
    churn_alloc -27.27% -0.08% -2.87% +0.00%
    churn_read +0.00% -0.13% -1.21% +0.28%
    cycles +0.00% +0.10% +3.90% +0.00%
    deeplist +0.00% -0.06% -0.69% +0.03%
    fib40 +1.59% -0.03% -0.89% +0.34%
    interp +22.22% -0.01% -1.02% +0.05%
    iso_miss -28.87% -7.91% -9.34% +0.15%
    pipeline +0.00% +0.08% +0.79% -0.07%
    push_cls +9.09% -0.02% -2.75% +0.07%
    push_num -11.11% -0.15% -5.42% -0.06%
    retain -22.22% -0.09% +1.84% +0.01%
    retain1 -11.11% -0.05% -2.04% +0.00%
    retain_wide +13.51% +0.41% -0.50% +0.00%
    retain_wide1 -11.11% -0.20% -1.85% +0.00%
    shapes +33.33% -0.03% -2.24% +0.15%
    tree -22.73% -0.11% -4.88% -0.06%
    tree_wide +21.54% +0.02% -0.41% +0.00%

    No non-target row moved more than 0.41% in instructions. All non-target wall ranges overlapped; the large wall/cycle swings track host contention rather than instruction count.

    Validation

    • all 19 corpus programs byte-exact against expected output
    • cargo test --release -p perry-runtime --lib: 2,608 passed, 4 ignored
    • cargo test --release -p perry-codegen --lib: 1,121 passed
    • cargo test --release -p perry --bin perry: 1,008 passed
    • lint: initial full run passed 50/52; the two branch-specific ratchets were fixed and pass individually, but the final full 52-gate rerun is blocked by the host disk floor
    • gap suite: interrupted after 25/575 when the documented skip-build path reached a nested HTTP-extension Cargo build; a complete retry is blocked by the host disk floor

    Host status at handoff: 6 GiB free after deleting this worktree's generated target/ and reducing the reserved external target to about 832 MiB. The task requires stopping below 15 GiB, so this PR remains draft until those two acceptance runs can complete.

  4. proggeramlug commented on Aug 21, 2026

    @proggeramlug
    ContributorAuthor

    interp follow-up shipped in #8539.

    The profile did not support the dynamic-numeric-envelope hypothesis: across 30 sampled runs, evalNode held 61.1% of self samples. The actionable cost was tiny pointer-bearing environment arrays keeping per-object GC layout masks alive. Raising the minimum mask-bearing payload from 2 to 4 slots makes two- and three-slot mixed objects use the exact tag scan instead.

    Measured result: -4.61% retired instructions on interp (9,013,545,066 -> 8,598,251,738), 19/19 byte-exact, no other corpus row moved by more than 1%, and no peak-RSS regression. The cutoff is 4 rather than 8 because 4 captured the full instruction win; 8 retired no fewer instructions and would broaden the tracing-policy change to larger objects.

    Post-rebase validation is green: serialized release runtime tests passed (2,608 passed / 4 ignored / 0 failed), and the full 575-test gap suite matched its snapshot (570 pass / 5 known mismatches / 0 compile failures / 0 crashes).

  5. proggeramlug commented on Aug 22, 2026

    @proggeramlug
    ContributorAuthor

    Status update — the row count in the title is stale, and the measurement behind it is too

    49 commits have landed on main since this issue was last updated, including nine perf changes that bear directly on the losing rows. The most recent sweep predates almost all of them.

    Where the last CLEAN measurement left us

    Quiet mini, 5 shuffled interleaved repeats, perry @ 4ee1d04b5, Node 26.5.1 — geomean 0.7773x, 15/20 rows won, peak RSS 0.2395x:

    bench vs node instr RSS
    churn_read 0.25x 0.15x 0.07x
    fib40 0.33x 0.22x 0.06x
    tree_wide 0.34x 0.27x 0.09x
    deeplist · tree · retain1 0.45–0.57x — —
    retain_wide1 · push_num 0.60x — —
    churn · churn_alloc · cycles · push_cls · retain · retain_wide 0.77–0.84x — —
    shapes 0.89x ✅ (was 1.04x — flipped by #8505) 1.11x 0.36x
    asyncpipe 1.07x 1.71x 0.43x
    pipeline 1.10x 1.75x 0.25x
    interp 1.45x 1.44x 0.33x
    iso_miss 2.09x 1.98x 0.35x
    typed_array 6.23x 8.49x 0.06x

    Two regressions from that sweep are still unattributed

    • typed_array 5.25x → 6.23x (+18.6%) — the corpus's worst row got worse
    • iso_miss 1.94x → 2.09x (+7.9%)

    Both appeared between two sweeps whose merged work I had audited for correctness but not measured. They are the strongest argument in this issue for why perf PRs need a corpus timing before merge, not just a compile check.

    The corpus was missing its worst row until recently

    typed_array only entered the corpus on 2026-08-21. It could not join earlier because benchmarks/suite/bench_typed_array_untyped_access.ts prints its own wall-clock timings, and the sweep's correctness gate is a byte-exact stdout compare against the Node oracle. gc-handoff/apps/typed_array.ts is the corpus variant — same workload, emits only checksum:-821955270.

    Worth stating plainly: #8491 improved that workload by −44.1% wall and −34.7% instructions, and no sweep would have noticed either the win or a later regression. Any benchmark whose own output encodes timing is structurally excluded from the gate meant to protect it.

    Landed since, unmeasured on the corpus

    #8581 (join + repeat + pad/replace, closes #8434), #8580 (proven-nonnegative index specialization), #8573 (inline monomorphic shape guards), #8570 (asyncpipe microtask suppression, fixes #8409), #8568/#8571 (superseded by #8581), #8560 (PIC descriptor gate, 6.49x on an ECS query), #8539 (layout-mask cutoff, interp −4.61%), #8540 (number-only local roots).

    #8570 targets asyncpipe and #8581 targets the string-heavy rows, so asyncpipe, interp and iso_miss are the rows most likely to have moved. None of it is measured yet.

    What the instruction ratio still says about the remaining losses

    Next

    A fresh sweep at current main (fefdc367b) is the blocking item, and it should be run before any further perf work — both to capture nine landed changes and to attribute the two open regressions. The 20-row corpus, the Node-oracle byte gate and the harness are all staged; the rebuild is the only cost.

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