Skip to content

perf(codegen): unboxed reduce accumulators in packed fast clones - #9091

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/packed-unboxed-accumulators
Aug 29, 2026
Merged

perf(codegen): unboxed reduce accumulators in packed fast clones#9091
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/packed-unboxed-accumulators

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Stacked on #9084 (merge that first) — the follow-on mechanism its PR text scoped: the reduce rows' remaining gap was not calls but the accumulator's GC-root slot.

What

After #9084, a packed fast clone's s += a[i] is a bare guarded fadd — but s still lived in its GC-root nanbox slot, so every iteration paid a store-to-load-forward + fadd dependency chain (~15 cycles/element; node keeps s in a register). Profiling showed the whole loop in 4 PCs with the slot chain as the floor.

This moves each admitted accumulator into a plain addrspace-0 F64 alloca for the clone's duration:

  • The fast preheader's existing admission (one emit_js_value_is_number tag test per accumulator — which IS the strict genuine-double window: the 0x7FF9..0x7FFF band covers every boxed tag, so INT32-boxed numbers correctly fail to the slow clone) now also stores the tested value into the alloca and registers it in ctx.numeric_accumulator_f64_slots.
  • In-clone LocalGet/LocalSet of the accumulator redirect to the alloca — no shadow bookkeeping (the real slot holds a stale number for the clone's duration, consistent with any prior shadow state; scanning a number nanbox is harmless), no barrier (numbers carry no heap edge). mem2reg promotes the alloca to a register.
  • Every clone exit writes the value back: the fall-through exit, and a per-clone side-exit trampoline that the scope's packed facts carry as their store_side_exit_label — so a mid-iteration hole-check or masked-store value-check side exit restores correct slot state before the slow clone re-executes the iteration.
  • v1 restricts unboxing to LocalSet-only accumulators (collect_local_writes check); Update-written ones (c++) keep the slot — the Update lowering doesn't consult the redirect, and integer counters are served by the i32-slot machinery anyway.

Admission stays collect_numeric_accumulators (#9060) — single source shared with the stable clone, per coordination with its author, who also verified the slot-canonicalization invariant the mechanism inherits (every admitted producer emits canonical raw doubles).

Numbers (isolated, dev box; node 26.5 in parens)

shape #9084 this PR
literal-bound reduce i<8192, s+=a[i] 4.16 ns/el 0.98 (1.01) — ahead of node
i < a.length reduce (local array) 4.14 1.29 (0.99)
module-global reduce (direct-timed probe) 4.20 2.70 (2.85) — ahead of node

The a.length variant's residual is the per-iteration length IC; #9070's hoist removes it at merge. IR census of the fast clone after this PR: one receiver root re-derive, one fadd, the loop poll — nothing else.

Correctness

  • Nine-probe reduce/count differential vs node byte-identical (string accumulator → tag-fail → slow clone; mid-loop string reassignment through a branch; literal bound past the array length; holey/mixed arrays).
  • The any-seeded accumulator repro from the correctness: class reference + number treats the INT32-boxed class ref as a number (should be string concat) #9087 investigation behaves identically before/after (the class-ref + divergence is that pre-existing runtime bug, unchanged by this machinery; the boolean-seeded arm is correct both sides).
  • Full perry-codegen suites green; host gate result to follow as a comment.

Summary by CodeRabbit

  • Performance Improvements
    • Improved performance for packed numeric reduction loops by using optimized floating-point accumulation.
    • Added fast paths for numeric accumulators in versioned and range-based loops.
    • Expanded loop optimization support for arithmetic expressions and eligible read-only loop bodies.
    • Numeric values are safely restored when optimized loops exit early.

@proggeramlug
proggeramlug force-pushed the perf/packed-unboxed-accumulators branch from 26bb564 to fc541a3 Compare August 29, 2026 20:14
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d78c25b1-989a-47ca-b045-676d664af082

📥 Commits

Reviewing files that changed from the base of the PR and between a5d2b1a and 3747c56.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/stmt/loops.rs

📝 Walkthrough

Walkthrough

Packed loop codegen now admits numeric reduce accumulators into unboxed F64 slots, restores them on exits, and supports additional call-free read loops and float arithmetic in dense masked stores. All FnCtx construction paths initialize the new accumulator-slot map.

Changes

Packed loop optimizations

Layer / File(s) Summary
Numeric read and fact analysis
crates/perry-codegen/src/stmt/stable_packed_accumulator.rs, crates/perry-codegen/src/expr/mod.rs
The loop facts record numeric accumulators. The accumulator-write collector and masked_window module are available to the required codegen scopes.
Unboxed accumulator admission
crates/perry-codegen/src/stmt/loops.rs, crates/perry-codegen/src/expr/literals_vars.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/codegen/...
Packed loop preheaders test accumulator values, move admitted values into F64 slots, lower reads and writes through those slots, and write values back on exits. All FnCtx constructors initialize the slot map.
Call-free read-loop versioning
crates/perry-codegen/src/stmt/loops.rs, changelog.d/9091-unboxed-clone-accumulators.md
Safe read bodies qualify for packed F64 versioning. Dense masked-store RHS validation accepts +, -, *, /, and unary -. The changelog records the accumulator behavior and benchmark result.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to a5d2b

This change keeps eligible numeric accumulators in temporary floating-point storage during packed-loop execution and restores their canonical values on exits. No actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant LoopMatcher
  participant FastClone
  participant AccumulatorSlots
  participant SlowClone
  LoopMatcher->>FastClone: emit guarded packed loop
  FastClone->>AccumulatorSlots: admit numeric accumulators
  FastClone->>AccumulatorSlots: perform unboxed F64 updates
  FastClone->>SlowClone: write back values on side exit
  FastClone->>SlowClone: write back values before fallback
Loading

Suggested reviewers: jdalton, thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: unboxed reduce accumulators in packed fast clones. It is concise and specific.
Description check ✅ Passed The description provides a detailed summary, concrete implementation changes, performance results, correctness notes, and test status. It does not use all template headings and omits the related-issue…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides a detailed summary, concrete implementation changes, performance results, correctness notes, and test status. It does not use all template headings and omits the related-issue section, explicit test commands, and checklist, but the required information is mostly present.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits August 29, 2026 22:20
After the packed-clone accumulator proofs, `s += a[i]` in a fast clone
is a bare guarded fadd — but `s` still lived in its GC-root nanbox
slot, so every iteration paid a store-to-load-forward + fadd dependency
chain (~15 cycles/element; node keeps `s` in a register; profiling
showed the whole loop in four PCs with the slot chain as the floor).

Move each admitted accumulator into a plain addrspace-0 F64 alloca for
the clone's duration:

- The fast preheader's existing admission (one emit_js_value_is_number
  tag test per accumulator — which IS the strict genuine-double window:
  0x7FF9..0x7FFF covers every boxed tag, so an INT32-boxed number
  correctly fails to the slow clone) also stores the tested value into
  the alloca and registers it in ctx.numeric_accumulator_f64_slots.
- In-clone LocalGet/LocalSet of the accumulator redirect to the alloca:
  no shadow bookkeeping (the real slot holds a stale NUMBER for the
  clone's duration — consistent with any prior shadow state, and
  scanning a number nanbox is harmless), no barrier (numbers carry no
  heap edge). mem2reg promotes the alloca to a register.
- EVERY clone exit writes the value back: the fall-through exit, and a
  per-clone side-exit trampoline the scope's packed facts carry as
  their store_side_exit_label — a mid-iteration hole-check or
  masked-store value-check side exit restores correct slot state
  before the slow clone re-executes the iteration.
- v1 unboxes LocalSet-only accumulators (collect_local_writes check);
  Update-written ones (c++) keep the slot — the Update lowering does
  not consult the redirect, and integer counters are served by the
  i32-slot machinery anyway.

Admission stays collect_numeric_accumulators — the single source shared
with the stable clone, whose author verified the slot-canonicalization
invariant this inherits (every admitted producer emits canonical raw
doubles).

Isolated (dev box; node 26.5 in parens):
literal-bound reduce   4.16 -> 0.98 ns/el (1.01) — ahead of node
len-bound reduce       4.14 -> 1.29       (0.99; residual = length IC,
                                           removed by the PerryTS#9070 hoist)
module-global reduce   4.20 -> 2.70       (2.85) — ahead of node

IR census of the fast clone: one receiver root re-derive, one fadd, the
loop poll — nothing else. Nine-probe differential byte-identical; the
any-seeded accumulator repro from the PerryTS#9087 investigation is unchanged
(that divergence is the pre-existing runtime bug, untouched here).
perry-codegen suites 1829/0.
cargo fmt --all -- --check is a lint gate; three hunks in entry.rs and
loops.rs were mis-indented.
@proggeramlug
proggeramlug force-pushed the perf/packed-unboxed-accumulators branch from fc541a3 to a5d2b1a Compare August 29, 2026 20:25
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Head is a5d2b1aa0c (rustfmt fix folded in; re-gate running). Combined integration numbers — this branch merged with current main (which now has #9070's length hoist) — the full isolated-operation matrix, node 26.5 in parens:

shape combined node
literal-bound reduce s += a[i] 0.97 ns/el 1.01
i < a.length reduce 0.97 0.99
i < a.length store loop 0.67 0.59
masked arith store a[i&K]=b[i&K]+c 0.48 1.33
plain stores (param/global/local) 0.40 0.70
if (a[i] < 0) c++ count loop 1.63 0.62

Both reduce rows are now ahead of node#9070's hoist removed the length IC exactly as scoped, and this PR's register-promoted accumulator was the last serial chain. The count loop's residual is the Update-written (c++) accumulator, deliberately excluded from v1 unboxing.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-gate on a5d2b1aa0c: fully green — every real lint step passes (formatting ✓), ratchets clean, all suites green with zero flakes this run. Ready for review alongside #9084 (merge #9084 first; this stacks on it).

@proggeramlug
proggeramlug force-pushed the perf/packed-unboxed-accumulators branch from a5d2b1a to 3747c56 Compare August 29, 2026 20:41
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged, after rebasing off the merged #9084 and adding a rustfmt commit (three hunks in entry.rs/loops.rs were mis-indented; cargo fmt --all -- --check is a lint gate).

This moves a value out of its GC-root slot into a plain addrspace-0 alloca, which is the shape CLAUDE.md singles out as a known-weak area, so I went at the "every clone exit writes the value back" claim rather than around it.

First, a correction to my own method, because it nearly produced a wrong review. I benchmarked three fixtures and measured 1.00x / 1.01x / 1.00x. Before reporting "no improvement" I diffed the emitted IR between arms — identical. The optimization never fired on my fixture, so the timing measured nothing. Worse, my original 31-case correctness probe had zero acc.writeback_exit blocks too: that validation, GC stress included, was equally vacuous. The admission needs the array to arrive as a parameter; a locally-built new Array(n).fill() buffer does not qualify (s3 in my shapes file gets acc.ok but no writeback). Anyone re-measuring this should grep --trace llvm output for acc.writeback_exit before trusting a number.

Re-done against a fixture where the subject is live — 6 acc.writeback_exit blocks, in sum, sumSeed, sumTwo:

  • 31 cases byte-identical to node v26.5.1. The load-bearing ones are the side exits with a partial accumulator: a non-number element at index 10 of 32 must deopt carrying 45, and "45x111213…" is exactly what both node and this produce. Same for null, {}, undefined, true elements, and for non-number seeds ("p", null, undefined, true, BigInt) which must take the slow clone from the preheader test. Plus IEEE edges (1e308, NaN, ±Infinity, -0 with Object.is, 0.1 accumulation), holes, and repeated calls where a previous writeback must leave the slot correct for the next.
  • GC stress, since an unrooted value's failure mode is a collection landing in its window. Three seeds at PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1, each moving 16,343 objects, all node-identical; plus a run under PERRY_GC_VERIFY_EVACUATION=1 with the from-space quarantine at depth 800, clean. The unrooted-alloca detector reports 0 violations (6 gc-capable allocas seen, so it had subjects), and its --self-test passes.

Worth recording that gc_root_dominance_check.py reported 0 root stores on my fixtures and said so explicitly rather than returning a green — a pure-numeric loop creates nothing to root. That is the check behaving correctly, not evidence about this PR.

Performance, on a shape that fires (4096 elements × 4000 reps, interleaved best-of-5, main confirmed at 0 writeback_exit):

main this PR node
s += a[i] 23 ms 16 ms 58 ms 1.44x
s += a[i]; p += a[i]*2 24 ms 16 ms 156 ms 1.50x

So the slot store-to-load-forward chain was real, and this clears it — comfortably ahead of node on both.

One gap, not blocking. The rebase dropped tests/native_proof_regressions/invalidation.rs from this PR — those additions belonged to #9084 and were already upstream — so as merged this adds no test of its own. The mechanism most worth pinning is the side-exit writeback carrying a partial accumulator (my case 12 above), since a missed exit would show up as a stale total rather than a crash, and only in the deopt path. Worth adding when you next touch this file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant