Skip to content

codegen: byte-array reads reach the numeric proofs (bench_int_arithmetic 475 → 395 ms) - #9146

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/int-arith-typedarray-global
Aug 30, 2026
Merged

codegen: byte-array reads reach the numeric proofs (bench_int_arithmetic 475 → 395 ms)#9146
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/int-arith-typedarray-global

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Two numeric-lowering fixes for byte-array reads, both found working benchmarks/suite/bench_int_arithmetic.ts — the worst entry on a fresh perry-vs-node sweep (7.7× Node before this; 6.2× after, with the remaining gap identified below).

1. acc += px[i] on a Uint8Array took the dynamic add

The HIR lowers a read on a binding it already knows is a Uint8Array/Buffer to the dedicated Uint8ArrayGet node. The number-by-construction fixpoint's number-or-undefined view-read rule — and the not-BigInt predicate beside it — only recognised the IndexGet spelling, so the accumulator was never admitted and every += lowered through js_dynamic_string_or_number_add, with acc pinned in a GC-rooted shadow slot.

This fix has a correctness half. Once the add is a raw fadd, an out-of-bounds read's NaN-boxed undefined survives it — IEEE arithmetic preserves the NaN payload — and the accumulator reads back as undefined where the spec says NaN. Number-context lowering now canonicalizes the value first: one compare plus a select, never a call. The undefined box is the only non-double this node can produce, so the test is exact.

2. Compound buffer indices could not prove their bounds

bounds_for_buffer_access_width proved only a single index local carrying a bounded-pair fact, or a constant. An index like i * 2 + 1 or y * 30 + x therefore fell back to a per-element js_uint8array_index_get_value call. The interval analysis can bound those: every leaf is a counter with a range fact or a compile-time constant, and int_range_expr composes them with checked arithmetic, answering None as soon as a leaf is unknown or a step overflows. When the whole interval fits inside a constant buffer length, the access is in bounds on every iteration. A non-constant length still declines — there is nothing to compare the interval against.

Measurements

Idle Mac mini, min of three, self-timed (the benchmark's own printed elapsed, not wall clock — node's ~76 ms startup otherwise flatters perry):

probe before after node
bench_int_arithmetic 475 ms 395 ms 64 ms
pixels[y * SIZE + x] variant 636 ms 356 ms 35 ms
all-qualifying-reads probe 54 helper calls/iter 0

Correctness

Byte-identical to node on two differentials that belong to neither suite:

  • eight cases around the byte read — in-bounds accumulate, an out-of-bounds read inside +=, an out-of-bounds read as a value, string concatenation, negative and fractional indices, a Buffer receiver, and a mixed numeric/string accumulator;
  • seven cases around the new bounds proof — the exact kernel shape, an interval touching the last element, a counter mutated inside the loop body, a deliberately out-of-range interval, a one-past-the-end read, a negative composite index, and a buffer whose length is not a compile-time constant.

perry-codegen: 10 collector tests and all 284 native_proof_regressions pass.

Two things left on the table, deliberately

  • The bench's own (y + ky) * SIZE + (x + kx) still declines the interval proof (54 reads per pixel remain calls), so this is a step, not the finish. It is not the counters' negative start — a non-negative rewrite still declines.
  • Landmine, now documented at the call site: f64_kind_from_class maps "Uint8Array" and "Uint8ClampedArray" to a typed-array kind, but the checked load it feeds reads the length at handle + 0 and elements at handle + 16. Perry's new Uint8Array(n) is buffer-backed (length at data - 8), so handing that path one of those receivers makes every index compare out of bounds and silently yields undefined — I hit exactly that (checksum:0 instead of 5760000) while writing this, and backed it out. Nothing reaches it today; Int32Array/Float64Array and Uint8Array parameters are unaffected.

An earlier attempt to also make the kernel read K[ky+1][kx+1] a native multiply is not in this PR: the flat-const rodata lowering is not what actually runs for that shape, so claiming the value is a canonical raw double would have been unsound, and the residual coercions it added measured net-negative (475 → 518 ms).

https://claude.ai/code/session_012Ys25ni6VwDKE71o1NTYAT

Summary by CodeRabbit

  • Bug Fixes
    • Improved numeric handling for Uint8Array and Buffer byte reads, including arithmetic and compound assignments.
    • Out-of-bounds byte reads now consistently produce NaN when used in numeric operations.
    • Improved safety checks for calculated indices when buffer lengths are known.
  • Tests
    • Added coverage for numeric byte accumulation and dynamic values introduced through string writes.
  • Documentation
    • Documented supported numeric-lowering scenarios, verification results, and remaining index-proof limitations.

@coderabbitai

coderabbitai Bot commented Aug 30, 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: 338fb3f6-961e-4fa1-8396-d07246b3c66f

📥 Commits

Reviewing files that changed from the base of the PR and between 4d26663 and 9895d96.

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

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


📝 Walkthrough

Walkthrough

Typed-array and buffer byte reads now participate in numeric analysis. Arithmetic lowering converts out-of-bounds undefined to canonical NaN. Range analysis proves compound indices when their intervals fit within constant buffer lengths.

Changes

Typed-array numeric lowering

Layer / File(s) Summary
Numeric admission for byte reads
crates/perry-codegen/src/collectors/ptr_shape_numeric.rs, crates/perry-codegen/src/type_analysis/pod.rs, crates/perry-codegen/src/collectors/number_by_construction.rs
Numeric analysis recognizes Uint8ArrayGet and BufferIndexGet reads. Tests cover numeric accumulators and rejection after string writes.
Arithmetic byte-read lowering
crates/perry-codegen/src/expr/binary.rs, changelog.d/typed-array-byte-read-numeric-proofs.md
Arithmetic lowering uses the correct byte-read path and converts out-of-bounds undefined to canonical NaN.
Compound index bounds proofs
crates/perry-codegen/src/expr/range_facts.rs, changelog.d/typed-array-byte-read-numeric-proofs.md
Range analysis proves compound indices when their complete interval fits within a constant buffer length. The changelog records coverage and remaining limitations.

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

Merge Risk: ⚪ Minimal · up to 9895d

The PR improves numeric byte-array reads and compound-index bounds proofs while preserving out-of-bounds numeric behavior; no actionable merge-blocking risk remains at the current head after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant lower_arithmetic_operand
  participant lower_expr
  participant NaNCanonicalization
  lower_arithmetic_operand->>lower_expr: lower Uint8ArrayGet with numeric index
  lower_expr-->>lower_arithmetic_operand: boxed byte value or undefined
  lower_arithmetic_operand->>NaNCanonicalization: compare TAG_UNDEFINED_I64
  NaNCanonicalization-->>lower_arithmetic_operand: byte value or canonical NaN
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 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 change: byte-array reads now participate in numeric proofs. The benchmark result provides useful supporting context.
Description check ✅ Passed The description provides a detailed summary, concrete changes, performance measurements, correctness coverage, test results, and known limitations. 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, concrete changes, performance measurements, correctness coverage, test results, and known limitations. It does not use the template headings or include an explicit related-issue declaration and checklist, but the substantive information 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
proggeramlug force-pushed the perf/int-arith-typedarray-global branch from 4d26663 to 9895d96 Compare August 30, 2026 08:31
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Local gate on the Linux box (perrymaster), commit 9895d9620: clean.

Suites: 2801 passed / 0 failed (runtime), 1351 passed / 0 failed, 284 passed / 0 failed (native-proof regressions), 121 passed / 0 failed. No FAILED or panicked anywhere in the run.

Lint: Check formatting PASS, CI plan policy self-test + docs table freshness PASS, Gap snapshot checker self-test PASS, Platform-aware parity allowlist self-test PASS. Ratchets recorded no ceiling raises — unrooted-local debt 576 → 576, raw-handle baseline 967 → 967 with 113 module ceilings unchanged, both against merge base da56c4ada.

The three FAIL steps are the environment, not the change: Require a changelog.d/ fragment dies on repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files: bad subst, and both ratchet-vs-merge-base steps on fatal: invalid refspec '${{ github.event.pull_request.base.sha }}' — the workflow expressions only expand inside GitHub Actions. (A fragment is included: changelog.d/typed-array-byte-read-numeric-proofs.md.)

For the record, the first gate run caught a real one that the truncated output nearly hid — a rustfmt violation (a stray blank line in the appended collector tests). Fixed and re-gated; the run above is the fixed commit.

Ralph Küpper added 2 commits August 30, 2026 10:43
…tic 475 -> 395 ms)

Two fixes, both found working benchmarks/suite/bench_int_arithmetic.ts.

1. `acc += px[i]` on a Uint8Array took the dynamic add. The HIR lowers a read
   on a known Uint8Array/Buffer binding to `Uint8ArrayGet`, but the
   number-by-construction fixpoint's number-or-undefined view rule and the
   not-BigInt predicate beside it only matched the `IndexGet` spelling, so the
   accumulator was never admitted and every += went through
   js_dynamic_string_or_number_add with acc in a rooted shadow slot.

   Correctness half: with a raw fadd, an out-of-bounds read's NaN-boxed
   `undefined` survives the add (IEEE keeps the payload) and acc reads back as
   `undefined` where the spec says NaN. Number-context lowering canonicalizes
   first -- one compare and a select, never a call; the undefined box is the
   only non-double this node can yield, so the test is exact.

2. A compound index (`i * 2 + 1`, `y * 30 + x`) could not prove its bounds:
   bounds_for_buffer_access_width proved only a single index local with a
   bounded-pair fact, or a constant, so each element paid a
   js_uint8array_index_get_value call. int_range_expr composes the leaves'
   range facts with checked arithmetic; when the whole interval fits inside a
   CONSTANT buffer length the access is in bounds every iteration. A
   non-constant length still declines.

Mac mini, min of 3, self-timed: bench_int_arithmetic 475 -> 395 ms (node 64);
a pixels[y * SIZE + x] variant 636 -> 356; a probe whose reads all qualify goes
from 54 per-element helper calls to none.

Byte-identical to node on two differentials (8 cases around the byte read:
in-bounds accumulate, OOB inside +=, OOB as a value, string concat, negative and
fractional indices, Buffer receiver, mixed accumulator; 7 around the bounds
proof: exact kernel shape, interval touching the last element, counter mutated
in the body, out-of-range interval, one-past-the-end, negative composite index,
non-constant length). perry-codegen: 10 collector tests and all 284
native_proof_regressions pass.

Not fixed here: the bench's own (y + ky) * SIZE + (x + kx) still declines the
interval proof. Landmine documented at the call site: f64_kind_from_class maps
Uint8Array/Uint8ClampedArray to a kind whose checked load assumes the
typed-array header (length at handle+0, elements at handle+16), while perry's
new Uint8Array(n) is buffer-backed (length at data-8) -- routing one there makes
every index read out of bounds and silently yields undefined.

Claude-Session: https://claude.ai/code/session_012Ys25ni6VwDKE71o1NTYAT
@proggeramlug
proggeramlug force-pushed the perf/int-arith-typedarray-global branch from 9895d96 to 722d3a1 Compare August 30, 2026 08:43
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged via a merge train — cherry-picked with two other PRs onto one branch and validated together in a single build. Final validation: hir 365 passed, codegen 1356, runtime 2831 passed (exit 0, 0 abort markers), perry --bins 1066, run_lint_gates.sh all 60 gates passed, git diff origin/main --diff-filter=D empty.

The train initially also carried #9140 (tombstone reuse for small-object churn), which failed four delete/shape-transition tests on their own premise (test premise: the delete did not compact the slots). Dropping it made the rest green, so those failures are its alone — handed back separately. Mentioning it because if any of these three later look implicated in a delete-path regression, #9140 is the change to look at first.

@proggeramlug
proggeramlug merged commit 46535fb into PerryTS:main Aug 30, 2026
20 checks passed
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up pushed (4c9dc456a), on top of your rebase — and it closes the gap this PR had listed as left for later.

The leftover I described ((y + ky) * SIZE + (x + kx) declining the interval proof) turned out not to be about the index at all. is_fresh_uint8array_length_expr accepts a literal or a known-length local but nothing built from them, so new Uint8Array(SIZE * SIZE) was never classified as a freshly allocated owned buffer. With no classification the receiver gets no buffer view, and with no view every element read falls back to js_uint8array_index_get_value no matter how well its index is proven — which is why the interval proof looked inert here.

What isolated it: changing only that benchmark's allocation to a literal length, nothing else, took it from 395 → 168 ms with the calls gone.

The predicate asks whether the allocation's size is fixed, never what it is — length_source_from_expr resolves the value later with a FnCtx in hand and records no constant length when it cannot — so a sum, difference or product of the same leaves qualifies on exactly the argument that already admits either leaf alone.

Re-measured against this branch's exact merge-base (bd7e5f71c), both binaries built in one run, interleaved on an idle Mac mini, min of five, self-timed:

base branch node
bench_int_arithmetic 462 ms 149 ms 62 ms

7.5× Node → 2.4×. Checksums identical on both binaries.

The three changes only compound together: the fixpoint fix makes the accumulation native, the allocation fix gives the receiver a view, and the interval proof is what lets a compound index use it. Take any one away and the reads go back to calls.

Still verified on this base: both differentials byte-identical to Node (eight cases around the byte read, seven around the bounds proof), Int32Array/Float64Array/Uint8Array-parameter probes unchanged, and the probe whose interval genuinely exceeds its buffer still declines to the checked call rather than reading out of bounds. perry-codegen: 1834 tests across 30 suites, none failing; cargo fmt --check clean.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gate re-run on the new tip 4c9dc456a (not just the first commit): 7 suites green, 0 failures, formatting clean, no ratchet ceilings raised. The 3 FAIL steps are the same environmental ones as before — unexpanded ${{ github.* }} expressions outside Actions (a changelog fragment is present).

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