Skip to content

perf(codegen): offset array reads earn the packed clone's numeric proof (41 → 16 ms) - #9279

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:perf/accumulator-offset-reads
Aug 31, 2026
Merged

perf(codegen): offset array reads earn the packed clone's numeric proof (41 → 16 ms)#9279
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:perf/accumulator-offset-reads

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The second of the two defects behind #9259. @ECS1's #9274 fixes the bound gap; this fixes the cap that sits under it. Independent changes, different files, additive.

The defect

accumulator_rhs_is_numeric's IndexGet arm (stable_packed_accumulator.rs:45) required a bare Expr::LocalGet index:

Expr::IndexGet { object, index } => matches!(
    (object.as_ref(), index.as_ref()),
    (Expr::LocalGet(a), Expr::LocalGet(i)) if *a == array_id && *i == counter_id
),

So a[k - 1] is not numeric → the accumulator never earns its number proof → every + in the enclosing expression lowers to a tag-test diamond over js_dynamic_string_or_number_add. The hot loop is 47 instructions where the plain form is 11, and the reads are already hoisted — it is the add, not the array access:

and  x8, x19, #0xffff000000000000   ; three is_number tag tests
cset w8, hi
...
fadd d0, d1, d0                      ; hot arm
b    <merge>
bl   _js_dynamic_string_or_number_add ; cold arm, twice

This is the cost #9060 and #9091 already removed for the bare-counter form.

Numbers

Quiet host, k < 4096, s = s + a[k] + a[k-1], 4096-element number[], 2000 reps:

before after node
s += a[k] + a[k-1] 41 ms 16 ms 8

Nothing else moves — plain-index rows stay at 7–8 ms, and the arr.length-bounded row stays at ~100 ms because it falls off both tiers for the separate reason #9274 fixes. Every timing was taken with guard-call counts from the full function disassembly beside it, so "the code under test ran" is evidence rather than assumption.

Threaded per tier, not widened

collect_numeric_accumulators takes offset_reads_inlined from each admission site:

  • range tier → true. Publishes window_validated, so its guard proved the whole window; its hole-tolerant loads side-exit before producing a value. An offset read is lowered inline and yields a Number.
  • versioned and stable-packed → false. Their offset reads take the generic path, which can produce undefined. Admitting that as numeric is a wrong answer, not a missed optimisation.

@ECS1 corrected my original reasoning here — I had the two tiers' guarantees inverted, and on my own analysis I would have set the flag on the tier that cannot inline offset reads.

What the tests guard, and what they do not

They cover the correctness of the shape this admits: an index running off either end, a hole inside the window, a non-numeric element, and a leading string that must concatenate rather than add — which is precisely what a wrongly granted numeric proof turns into a native fadd. All six node-identical.

They do not guard the per-tier flag, and I checked rather than assumed. I flipped the versioned tier to true deliberately; all six still passed. No loop reaches that tier with an offset read today — such a loop falls off both tiers (#9259) — so the flag is currently unobservable in either position. It becomes live when #9274 merges, and flipping it then requires an arr.length-bounded fixture to be testable at all. I have left that in a comment at the call site rather than as folklore.

Validation

  • perry-codegen 1843/0, perry-hir 596/0, -D warnings 0, local-binding-type audit OK
  • 34 packed-loop integration tests across 5 files (packed_loop_abrupt_statements, loop_property_array_hoist, issue_8690_loop_versioned_arraylike, issue_8897_field_push_writeback, plus the 6 added)
  • 7 adversarial differentials against node before writing any test

Refs #9259, #9274, #9060, #9091.

Summary by CodeRabbit

  • Bug Fixes

    • Improved numeric handling for offset array reads in packed loops.
    • Preserved correct behavior for out-of-range indexes, sparse arrays, non-numeric values, and string concatenation.
    • Prevented invalid fast-path numeric assumptions that could produce incorrect results.
  • Tests

    • Added coverage for offset reads, numeric accumulation, and edge cases producing NaN or string results.

…of (41 -> 16 ms)

`accumulator_rhs_is_numeric`'s `IndexGet` arm required a bare
`Expr::LocalGet` index, so `a[k - 1]` was not numeric, the accumulator
never earned its number proof, and every `+` in the enclosing expression
lowered to a tag-test diamond over `js_dynamic_string_or_number_add` —
three `is_number` tests and two helper calls on the cold arm, per
iteration. That is the cost PerryTS#9060 and PerryTS#9091 already removed for the
bare-counter form.

Measured on the quiet host, `k < 4096`, `s = s + a[k] + a[k-1]` over a
4096-element `number[]`, 2000 reps:

    before  41 ms      after  16 ms      node  8 ms

Nothing else moves: the plain-index rows stay at 7-8 ms and the
length-bounded row stays at ~100 ms, since that one falls off both tiers
for a different reason (PerryTS#9259, @ECS1's PerryTS#9274).

Threaded per tier rather than widened. `collect_numeric_accumulators`
takes `offset_reads_inlined` from each admission site:

  * range tier: `true`. It publishes `window_validated`, so its guard
    proved the whole window, and its hole-tolerant loads side-exit before
    producing a value — an offset read is lowered inline and yields a
    Number.
  * versioned and stable-packed tiers: `false`. Their offset reads take
    the generic path, which can produce `undefined`; admitting that as
    numeric would be a wrong answer rather than a missed optimisation.

What the added tests do and do not guard, stated plainly because I
checked: they cover the correctness of the shape this admits — an index
that runs off either end, a hole inside the window, a non-numeric
element, and a leading string that must concatenate rather than add,
which is what a wrongly granted numeric proof would turn into a native
`fadd`. They do NOT guard the per-tier flag. I flipped the versioned
tier to `true` deliberately and all six still passed, because no loop
reaches that tier with an offset read today — such a loop falls off both
tiers (PerryTS#9259). The flag becomes observable when PerryTS#9274 lands, and flipping
it then needs an `arr.length`-bounded fixture to be tested at all.

perry-codegen 1843/0, perry-hir 596/0, `-D warnings` 0, local-binding-type
audit OK, 34 packed-loop integration tests across 5 files including the 6
added.
@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: 1e583ce8-83fb-4198-98ce-193353d82fcf

📥 Commits

Reviewing files that changed from the base of the PR and between 6c880be and 35d29de.

📒 Files selected for processing (4)
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-codegen/src/stmt/stable_packed_accumulator.rs
  • crates/perry-codegen/src/stmt/stable_packed_loop.rs
  • crates/perry/tests/packed_loop_offset_read_accumulator.rs

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


📝 Walkthrough

Walkthrough

The compiler now passes an offset_reads_inlined flag through packed accumulator analysis. Range loops enable offset-read numeric admission; versioned and stable-packed loops keep it disabled. New tests validate sums and non-numeric offset-read behavior.

Changes

Packed offset-read accumulator admission

Layer / File(s) Summary
Offset-read numeric analysis
crates/perry-codegen/src/stmt/stable_packed_accumulator.rs
Accumulator analysis admits array[counter ± constant] only when inline lowering is guaranteed. Recursive expression checks preserve this condition.
Loop-tier admission wiring
crates/perry-codegen/src/stmt/loops.rs, crates/perry-codegen/src/stmt/stable_packed_loop.rs
The range tier passes true. Versioned and stable-packed tiers pass false.
Offset-read runtime validation
crates/perry/tests/packed_loop_offset_read_accumulator.rs
Tests cover valid sums, bounds errors, holes, non-numeric elements, and string concatenation.

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

Merge Risk: 🔵 Low · up to 35d29

This optimization admits offset array reads into the numeric fast path only for validated range loops, improving performance while retaining fallback behavior elsewhere. It is mergeable with owner awareness that future loop-tier changes must preserve the same bounds and hole-handling guarantees, since breaking that contract could produce incorrect numeric results.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 4 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 main code-generation optimization for offset array reads and includes the measured performance improvement.
Description check ✅ Passed The description provides a detailed summary, technical rationale, related issue references, test coverage, validation results, performance data, and test limitations. It does not use the template head…
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, technical rationale, related issue references, test coverage, validation results, performance data, and test limitations. It does not use the template headings or checklist, but it includes the required substantive information and is mostly complete.

  • 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

Copy link
Copy Markdown
Contributor Author

Merged.

Validated on a shared branch with #9228, #9257, #9263, #9271, #9272, #9274, #9277, #9279 and #9280 — one build, one validation pass, then split back out and merged individually.

Results across the batch:

  • perry-runtime 2885 passed / 0 failed at RUST_TEST_THREADS=1
  • perry-codegen 30 suites green (the one failure was a doc-test reporting a missing libperry_codegen-*.rlib — an artifact of my own cleanup of stale build dirs, confirmed by a clean re-run at 31/31, not a code defect)
  • all 60 lint gates plus the check_thread_locals / tls_budget checkers
  • a nine-row differential probe byte-identical to node 26.5.1, covering every changed area: RegExp \w/\b/. ASCII and LineTerminator semantics, offset (a[i±1]) and length-bounded array reads, the assert RegExp matcher, and closure identity across a 40k-allocation GC churn
  • seven earlier regression probes re-run at zero diff lines: tagged and scalar array stores, pointer↔scalar transition churn, growth-forwarding receivers, BigInt negation, iterator protocols, field shadowing

One probe (protorepl) moved from 2 to 4 diff lines and I ran it down rather than waving it through: both divergences are accepted trades already on main — the fresh-instance case (#9239) and #9247's deliberate change of a custom-chain miss from Some(undefined) to None, which it made because swallowing the miss left everything Perry synthesizes unreachable (Object(true).valueOf(), plain-function .prototype, iterator helpers). Neither is anything in this batch.

@proggeramlug
proggeramlug merged commit 737b977 into PerryTS:main Aug 31, 2026
42 of 43 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
They are fmt-dirty on pristine main (42d0f45) from PerryTS#9274/PerryTS#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
They are fmt-dirty on pristine main (42d0f45) from PerryTS#9274/PerryTS#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.
proggeramlug added a commit that referenced this pull request Aug 31, 2026
… on cargo fmt --check) (#9293)

`cargo fmt --all -- --check` fails on pristine main (953a8bd): 6 hunks
across `perry-codegen/src/stmt/loops.rs` and
`perry-codegen/src/stmt/stable_packed_accumulator.rs`, from #9274/#9279.
Reproduced on two machines with the pinned nightly toolchain.

That gate is part of `lint`, so it is red on every open PR until this lands,
and a check that is red on arrival teaches reviewers to ignore it — CLAUDE.md
hazard 2.

Pure `cargo fmt --all` output, no hand edits, no behaviour change.

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

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
They are fmt-dirty on pristine main (42d0f45) from PerryTS#9274/PerryTS#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.
proggeramlug added a commit that referenced this pull request Aug 31, 2026
…9225's linear scan gated — cc --help −1.25% instructions, −2.37% cycles (#9291)

* wip(runtime): address windows for the symbol and Uint8Array probes

Hoist #9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.

* perf(runtime): a monotone address FILTER in front of the symbol and class-prototype probes

Round 2 (#9272) put an inline [lo, hi] address window in front of the buffer
and typed-array probes. Measured against the four probes it named as follow-up,
a window is the wrong shape for two of them and the right shape for one:

  is_registered_symbol                378,163 calls, window rejects 38.3%
  is_registered_class_prototype_object 26,290 calls, window rejects 54.0%
  is_uint8array_buffer                537,921 calls, window rejects 100%

Symbols and class prototypes are ordinary GC-heap objects, so [lo, hi] grows to
cover most of the heap. RegistryAddrFilter is the same monotone contract over a
1024-bit Bloom filter instead of a range; replaying each probe's real argument
stream from a cc --help run, it rejects 99.58% and 99.05%.

is_uint8array_buffer keeps the cheaper window (100% rejection, 0 true answers).

Every rejection is re-derived from the authoritative table under
debug_assertions, so a registration route added without admitting panics in the
first test that touches it.

* changelog: registry-probe address filter (symbol, class prototype) + Uint8Array window

* test(runtime): keep TEST_SYMBOL_REGISTRY_PROBES meaning 'entry past the latch'

Two sabotage checks in other suites defeat a cheaper upstream screen and require
this counter to move; counting filter admissions instead made them fail.
Filter admissions get their own counter, mirroring
typedarray::TEST_TA_WINDOW_ADMITTED_PROBES.

* test(runtime): the unregistered-scratch probe sweep covers the class-prototype probe too

* docs(runtime): the descriptor-target scan comment's premise is false for every bundle (#9225)

* docs(runtime): name the filter's saturation regime and the knob for it

* fix(runtime): the debug audits use try_lock/try_read, not lock/read

The rejection path never took either lock, so a blocking audit could hang on a
caller the audited code would not have. Sabotage-checked: removing the admit
from either registration funnel fails 1 test (symbol) and 3 tests (class
prototype), so the audits demonstrably run.

* docs(runtime): the symbol side's comments and the funnel's name say 'filter', not 'range'

* docs(runtime): bits accrue per admission (the collector re-keys both tables); record the end-of-run false-positive rate

* revert: unrelated cargo fmt reformat of two perry-codegen files

They are fmt-dirty on pristine main (42d0f45) from #9274/#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.

* test: split the #8067 shape-authority tests out of parent_static.rs

parent_static.rs was at 1992 lines on main and this PR adds 52, crossing the
2000-line cap. Extracts the inline shape_authority_tests_8067 module to a
sibling under parent_static/; body unchanged.

---------

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