Skip to content

runtime: single-pass numeric-window add kernel — bench_numeric_array_downgrade beats node (16→3ms) - #9307

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/numeric-range-add-kernel
Aug 31, 2026
Merged

runtime: single-pass numeric-window add kernel — bench_numeric_array_downgrade beats node (16→3ms)#9307
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/numeric-range-add-kernel

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

bench_numeric_array_downgrade, the worst remaining benchmark loser at 3.60×, now beats node: 16 ms → 3 ms against node's 4 ms (idle Mac mini, min of 7, flat spreads, identical checksums).

Diagnosis

Two findings on the way to the fix, each narrowing it:

  1. The 3.6× was almost entirely the declared-type effect, not the heterogeneity. any[] vs number[] on an identical packed runtime layout: 9 ms vs 3 ms; planting the actual object+string cost ~0 more.
  2. The any[] loop was already claimed by a purpose-built tiermatch_numeric_range_add_loop, the mixed-layout numeric-window matcher. The entire cost sat in its runtime kernel: two full passes over a 1 MB window (validate every slot, then mutate every slot) with a branchy NaN-box decode per slot per pass — ~2.9 ns/element against node's 0.64.

The diagnosis was trace-blind for a while because the versioned matcher's bound gate is silent; this PR also names it (no_length_hoist), so the next person gets it from PERRY_PACKED_LOOP_TRACE=1 in one run instead of binary instrumentation.

The fix

The all-or-nothing contract the two passes bought is stronger than the source semantics require: each element receives exactly one + delta whether the kernel or the ordinary loop applies it. So the kernel now mutates in a single fused pass and, at the first non-number, returns the resume index:

return meaning
>= 0 window done; counter on exit
-1 receiver-level decline (type / frozen / descriptors / window) — nothing mutated, still fully transactional
<= -2 slots [start, k) updated, k = -ret - 2; the ordinary loop resumes at k

The lowering seeds the counter with the resume index before entering the fallback — safe because lower_for lowers the init before any matcher runs, so the fallback cannot re-run it and double-apply the prefix. The double lane decodes first (after call one, every slot is a boxed double); the int lane keeps the class-ref exclusion in the shared decoder.

The old test failing was the system working

numeric_range_add_failure_is_transactional failed exactly as designed — a documentation test for the contract this change deliberately replaces. It is rewritten, not deleted, to pin the new contract: prefix mutated, marker and suffix untouched, resume encoding (-3 for index 1, -2 for the index-0 boundary), and receiver-level -1 still writing nothing.

Semantics pinned where the benchmark can't see them

The benchmark's windows are entirely numeric, so its checksum can't distinguish resume from rollback. The new integration tests pin the part it never exercises: a non-number mid-window gets node's exact answer — one increment per element, concatenation where + concatenates ("[object Object]1", "mid1"), NaN slots staying NaN, never corrupting into tag space — under normal and PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 runs, with expected values taken from node.

Gates

perry-runtime lib 2894/0 (single-threaded), perry-codegen lib 1378/0, packed-loop integration suite 6 files green, GC store-site inventory unchanged (the audited POINTER_FREE store keeps its marker), rustfmt clean.

https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187

Summary by CodeRabbit

  • Performance

    • Improved numeric array range updates, reducing benchmark runtime from 16 ms to 3 ms while preserving results.
  • Bug Fixes

    • Numeric updates now correctly resume after encountering a non-number value.
    • Prevented repeated updates and preserved expected addition or concatenation behavior.
    • Preserved transactional behavior when the target is not an array.
  • Tests

    • Added coverage for partial updates, NaN values, garbage collection, and forced evacuation scenarios.

bench_numeric_array_downgrade: 16ms -> 3ms against node's 4ms on an idle Mac
mini -- perry now BEATS node on the benchmark (was 3.60x). Checksums
identical; spreads flat.

The diagnosis first established that the benchmark's 3.6x was almost
entirely the DECLARED-TYPE effect (any[] vs number[] on an identical packed
layout: 9ms vs 3ms) and not the planted heterogeneity (~0ms extra) -- and
then that the any[] loop was already claimed by the purpose-built
mixed-layout tier (`match_numeric_range_add_loop`). The entire cost sat in
its runtime kernel: TWO full passes over a 1MB window (validate every slot,
then mutate every slot), a branchy NaN-box decode per slot per pass, ~2.9ns
per element against node's 0.64.

The all-or-nothing contract those two passes bought is stronger than the
source semantics require. Each element receives exactly one `+ delta`
whether the kernel or the ordinary loop applies it, so mutating up to the
first non-number and letting the ordinary loop RESUME there is observably
identical -- and halves the memory traffic, which was the whole cost.

New contract:
  ret >= 0   window done; ret is the counter on exit.
  ret == -1  receiver-level decline (type/frozen/descriptors/window);
             nothing mutated -- this half stays fully transactional.
  ret <= -2  slots [start, k) updated, k = -ret - 2; the caller resumes
             the ordinary loop at k.

The lowering seeds the counter with the resume index before entering the
fallback loop. Safe because `lower_for` lowers the init before any matcher
runs, so the fallback cannot re-run it and double-apply the prefix. The
double lane is decoded first (after the first call every slot holds a boxed
double); the int lane keeps the class-ref exclusion in the shared decoder.

The old `numeric_range_add_failure_is_transactional` test failed exactly as
designed -- a documentation test for the contract this change deliberately
replaces -- and is rewritten to pin the new one: prefix mutated, marker and
suffix untouched, resume index encoded (-3 for index 1, -2 for index 0),
and receiver-level -1 still writing nothing.

Also names the packed-f64 versioned matcher's one silent gate
(`no_length_hoist`): a loop whose bound is not `arr.length` exited before
any named reject could fire, making every literal- or parameter-bounded
loop invisible to PERRY_PACKED_LOOP_TRACE -- the gap that forced this
diagnosis through binary instrumentation instead of one trace run.

The resume path is pinned by integration tests the benchmark never
exercises (its windows are entirely numeric): a non-number mid-window gets
node's exact semantics -- one increment per element, concatenation where
`+` concatenates ("[object Object]1", "mid1"), NaN slots staying NaN --
under normal and forced-evacuation runs, with expected values taken from
node.

perry-runtime lib 2894/0 (single-threaded); perry-codegen lib 1378/0;
packed-loop integration 6 files green; GC store-site inventory unchanged;
rustfmt clean.

Claude-Session: https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 96dc0e29-a105-43d4-88de-0a70771fbd49

📥 Commits

Reviewing files that changed from the base of the PR and between 54f7b6f and e04421c.

📒 Files selected for processing (6)
  • changelog.d/numeric-range-add-single-pass.md
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/numeric_range.rs
  • crates/perry-runtime/src/array/tests.rs
  • crates/perry/tests/numeric_range_add_resume.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The numeric range-add kernel now uses one fused pass with an encoded resume contract. Codegen resumes the generic loop after partial progress. Runtime and integration tests cover receiver declines, mid-window non-numbers, NaN values, tier selection, and moving GC.

Changes

Numeric range-add execution

Layer / File(s) Summary
Fused runtime kernel contract
crates/perry-runtime/src/array/numeric_range.rs, crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/array/tests.rs
The runtime helper updates numeric prefixes in one pass and returns completion, receiver decline, or an encoded resume index. Runtime tests verify each result and mutation state.
Lowering resume and trace wiring
crates/perry-codegen/src/stmt/loops.rs
The lowering decodes partial progress, synchronizes loop counters, and resumes the ordinary fallback loop. The packed-loop matcher reports no_length_hoist when length-hoist classification fails.
Observable behavior validation
crates/perry/tests/numeric_range_add_resume.rs, changelog.d/numeric-range-add-single-pass.md
Integration tests verify tier selection, exact results, single application per element, NaN preservation, and normal or moving GC execution. The changelog records the new contract and benchmark results.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to e0442

The numeric range-add runtime now updates a numeric prefix before returning a resume status for mixed-value windows, while repository-local callers preserve exactly-once behavior. Merge is reasonable with owner awareness that external FFI consumers relying on the former all-or-nothing failure behavior must be confirmed or updated to avoid duplicate prefix updates.

Sequence Diagram(s)

sequenceDiagram
  participant NumericRangeAddLowering
  participant array_numeric_range_add_impl
  participant OrdinaryFallbackLoop
  NumericRangeAddLowering->>array_numeric_range_add_impl: call numeric range-add helper
  array_numeric_range_add_impl-->>NumericRangeAddLowering: return completion or resume index
  NumericRangeAddLowering->>OrdinaryFallbackLoop: continue from resume index
Loading

Suggested reviewers: 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 16 functions across 5 files. (1 skipped: … 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 single-pass numeric-window add kernel and its benchmark improvement. It is specific and related to the main change, although it is somewhat long.
Description check ✅ Passed The description provides a detailed summary, diagnosis, implementation changes, semantic rationale, test coverage, and reported validation results. It does not use the template headings or include an …
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, diagnosis, implementation changes, semantic rationale, test coverage, and reported validation results. It does not use the template headings or include an explicit related-issue value, command list, or checklist, but the core information is complete.

Full details: Docstring Coverage

Explanation

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 16 functions across 5 files. (1 skipped: 1 unsupported.)

  • 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.

@proggeramlug
proggeramlug merged commit 8f253df into PerryTS:main Aug 31, 2026
28 of 29 checks passed
proggeramlug added a commit that referenced this pull request Aug 31, 2026
* codegen: a float accumulator over masked reads earns the dense range clone

17_loop_data_dependent: 475 ms -> 219 ms against node's 220 ms on an idle
Mac mini -- parity, from 2.16x. Sums bit-identical across 100M data-dependent
float recurrence steps.

    sum = sum * x[i & 63] + x[(i * 7) & 63]   // rejected
    sum = sum * x[i & 63]                     // admitted

The discriminator was the accumulator's static numeric proof. `+` can be
concatenation, so the dense tier's per-statement proof demands both operands
numeric; a reassigned accumulator has no such proof, because its own writes
read the guarded array, whose element proof only exists once the guard has
run. A chicken-and-egg that `*` never faces -- multiplication needs only the
weaker inert fact. Confirmed by instrumenting the two conjuncts of the dense
LocalSet arm: the failing one is the proof, on exactly the fixtures whose
accumulator writes contain a plain-array read.

The matcher now peels the accumulator: when the proof fails on the LocalSet
target of a self-accumulating write, it retries with the target treated as
numeric BY CONTRACT, records it pending, and then verifies every pending
local with the same collector the lowering runs
(`collect_numeric_accumulators`), rejecting the whole dense match with its
own named trace reasons (`accumulator_needs_single_array`,
`accumulator_not_provable`) if the two disagree -- so the clone can never
contain a dynamic `+` under facts that forbid one.

The contract is enforced at run time twice over: the clone's entry emits a
genuine-double tag check on the accumulator, and the dense entry guard
validates the whole masked window hole-free. A string-seeded accumulator and
a string element both route to the slow copy and produce node's
concatenation, verified under PERRY_GC_FORCE_EVACUATE.

Supporting changes:

* `accumulator_rhs_is_numeric` accepts masked static-window reads of the
  tracked array (`masked_reads_validated`), sound because the dense guard
  validated the window union hole-free. Fixing that exposed a match-arm
  reachability bug: `_ if offset_reads_inlined` was a guarded catch-all, so
  ANY arm placed after it was unreachable whenever the flag was set -- the
  first version of this change sat exactly there and verified as a no-op.
  The two tests are now one combined catch-all.
* `emit_range_loop_accumulator_admission` admits a masked-only single array
  (counter-bearing arrays keep priority; multiple arrays still decline).
* `MaskedWindowArrayFact` carries `numeric_accumulators` so `is_numeric_expr`
  sees admitted accumulators while the clone lowers -- without this the add
  inside the clone would stay dynamic, which is a collecting call under facts
  that assume none (the #9259 cascade shape). Mirrors the string-window
  fact's field (#9160).

perry-codegen lib 1378/0; packed-loop integration suite 59/0 across 11 files;
3 new regression tests (admission + node-identical result, string-seeded
accumulator, string element), each under forced evacuation.

Claude-Session: https://claude.ai/code/session_01Pcq6j6y57TdKSR2Zx2D187

* refactor(runtime): split ic_miss.rs and array/tests.rs under the file cap

#9302 and #9307 each tipped a file that was already within ~35 lines of the
2000-line gate. Extracts the C3C PIC test module and the Array.prototype
method-discriminator tests into sibling files; no behaviour change.

* fix(codegen): drop the now-dead catch-all after the combined accumulator arm

#9303's combined `_ =>` arm made the trailing `_ => false` unreachable, which
is a `-D warnings` failure. Removing it is #9308's fix, which the combined arm
needs to be complete.

* style: rustfmt

* chore: changelog fragment for the train13 follow-up

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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