Skip to content

fix(codegen): i32 fast path must prove a copied local is i32-RANGED, not merely integer (#6339)#6365

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6339-i32-arith-chain
Jul 13, 2026
Merged

fix(codegen): i32 fast path must prove a copied local is i32-RANGED, not merely integer (#6339)#6365
proggeramlug merged 1 commit into
mainfrom
fix/6339-i32-arith-chain

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Closes #6339 — the fourth and last known face of #6072 (i32 fast-path locals silently wrap at 2^31), after #6258 (bare x++ accumulator), #6318 (dynamic-bound loop counter) and #6319 (out-of-range integer literal).

let big1 = 2000000000;
let big2 = big1 + big1;   // 4e9 — correct in its own slot
let t = 0;
t = big2;
console.log(t);           // node: 4000000000   before: -294967296

let sum = 0;
for (let i = 0; i < 5; i++) sum = sum + 1000000000;
let sumCopy = 0;
sumCopy = sum;
console.log(sumCopy);     // node: 5000000000   before: 705032704

Relationship to #6340

#6340 (the literal-magnitude fix) merged into main while this was in verification; this branch is rebased on top of it. The two are the same predicate and are inseparable: is_strictly_i32_bounded_expr must reject both an out-of-range literal (#6340's integer_literal_fits_i32) and an out-of-range arithmetic result (this PR's fixpoint). With only one of them, either let x = 3000000000; y = x; or let x = 2e9 + 2e9; y = x; still wraps.

Root cause

is_strictly_i32_bounded_expr's LocalGet arm answered from integer_locals:

Expr::LocalGet(id) => known_int_locals.contains(id),

That conflates two different properties. integer_locals means integer-valued (no fraction, no NaN) and, by its own doc comment, deliberately admits overflowing arithmetic:

Add / Sub / Mul when both operands are int-producing. The sum/product may overflow i32, but the existing i32-slot machinery already accepts this risk […]

big2 itself stayed correct — an Add write is not strictly-i32-bounded, so it never got a shadow — yet the oracle still vouched for it. t = big2 was therefore judged a strictly-i32-bounded write, t got an i32 shadow at its Let site, and the store truncated. Reads prefer the shadow (#48), so the wrapped value is what console.log saw.

The fix: greatest fixpoint

The set is defined by mutual recursion — a copy t = big2 is strict iff big2 is i32-ranged, which is this very set. So seed the oracle optimistically with integer_locals and take the greatest fixpoint of "every write is i32-bounded". is_strictly_i32_bounded_expr is monotone in its oracle, so starting at the top and only ever removing is exact and terminating.

It is computed in one walk, not by re-walking per removal (that would be super-linear on big functions — cf. #6219/#6253). The judgment is a shallow match and only two of its arms consult the oracle, so each strict write records the exact local it leaned on (copy_deps), and a worklist propagates every disqualification backwards along those edges. Same provenance/worklist shape collect_integer_locals already uses one module over.

Judgment and provenance come from one match arm-set, so they cannot drift apart (the #6221 lesson): a future oracle-consulting arm is automatically both judged and tracked.

Why the FNV / imul32 recovery is untouched

strictly_i32_bounded_locals is what recovers image_convolution's FNV-1a h accumulator, and shrinking it is exactly what the compiler-output-regression gate would punish. It doesn't shrink there, and the reason is structural:

Only the LocalGet and Update arms consult the oracle. Every other arm is a self-contained proof — an in-range literal, an explicit | 0 / >>> 0 coercion, a bitwise op, Math.imul, a clamp call, a byte load. Intentionally-i32 code proves itself and never asks the oracle anything, so no amount of oracle-shrinking can cost it its slot:

let h = 0x811c9dc5 | 0;      // Binary{BitOr, .., Integer(0)} — self-proving
h = (h ^ dst[i]) | 0;        // Binary{BitOr, .., Integer(0)} — self-proving
h = imul32(h, 0x01000193);   // Call{clamp_fn}               — self-proving

A value that merely happens to overflow (big1 + big1, sum += …, x++) has no such proof and reaches its copies only through the oracle — which is precisely the edge this PR cuts.

That is the intended-vs-accidental separation, and it is load-bearing rather than incidental: it is the same structure #6340 relied on to leave 0x9E3779B9 >>> 0 and 0x811c9dc5 | 0 alone while narrowing the bare-literal arm.

compiler-output-regression — run locally, every CI step

Ran with the exact CI invocations (.github/workflows/test.yml), and A/B'd every structural report against a pristine baseline build:

step result
tests.test_compiler_output_regression 66 tests OK
tests.test_native_abi_evidence_report 19 tests OK
suite native-region-proof (incl. image_convolution) 11/11 pass, failed_workloads: []
suite native-abi-proof 12/12 pass, failed_workloads: []
tests.test_typed_feedback_runtime_evidence OK
capture vectorized_buffer_transform pass
capture hir_fact_rewrite pass
capture fma_contract pass (22/22 checks)

Baseline-vs-this-PR, all 11 native-region-proof workloads: 393 checks, identical statuses, and byte-identical named-region counters. image_convolution in particular:

region fptosi sitofp inttoptr load_i8 store_i8 xor_i32 mul_i32
blur 0 → 0 0 → 0 0 → 0 75 → 75 3 → 3 0 → 0 0 → 0
fnv_hash 0 → 0 0 → 0 0 → 0 1 → 1 0 → 0 1 → 1 1 → 1
input_generation 0 → 0 0 → 0 0 → 0 1 → 1 1 → 1 3 → 3 1 → 1

fnv_imul_stays_i32, assembly_contains_imul_constant and named_region_fnv_i32_hash_shape all still pass — the FNV chain still lowers to xor i32 / mul i32 …, 16777619 and the blur region still takes zero ToInt32 conversions.

(fma_contract's CI invocation passes --clang-arg=-march=haswell, which an arm64 host cannot accept; it errors identically on the baseline binary. Re-run without the x86-only flag it passes 22/22.)

Verification

test-files/test_gap_6339_i32_arith_chain_copy.ts — byte-identical to node --experimental-strip-types; fails on baseline with -294967296 / 705032704 / 1410065408 / 294967296 / -2147483647.

Covers: copy of Add/Sub/Mul results past 2^31, a sum += … accumulator, a transitive copy chain (c2 = c1; c3 = c2; c4 = c3), copy-of-x++, the magnitude matrix (2^31-1 stays i32; 2^31, 2^31+1, 2^32, -2^31, -2^31-1, MAX_SAFE_INTEGER all exact), and the intentional-i32 shapes that must keep wrapping — FNV-1a, Math.imul, a hand-rolled imul32, a bit-mixing chain, an explicit (big1 + big1) | 0, a >>> 0 xorshift recurrence, the array-index fast path (#6299/#6312) and image_convolution's clamp/blur index shape.

  • Full gap suite (298 tests) A/B'd baseline vs this PR: zero regressions. The only behavioural change is the new fixture. (Two other DIFFs were noise, confirmed by serial re-run: test_gap_2308_unrolled_loop_var was a shared-object-cache race between the two parallel compilers, test_gap_console_methods is console.time jitter.)
  • test_gap_6072_i32_update_overflow, test_gap_6072_dynamic_bound_counter and fix(codegen): an integer literal past 2^31 must not seed the i32 fast path (#6319) #6340's test_gap_6319_i32_literal_magnitude: unchanged, byte-identical to node.
  • cargo test -p perry-codegen, cargo fmt --all -- --check, scripts/check_file_size.sh: clean.

Follow-up found while verifying

#6359 — a fifth, unrelated face: a >>> 0 uint32 assigned into a local whose other write is a signed literal takes the signed i32 slot and reads back negative (let d = 0; d = 4000000000 >>> 0-294967296). It reproduces identically on main, on #6340 and on this branch, so it is not part of this family and is not touched here. It is a signedness bug, not a magnitude bug.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of integer arithmetic chains to prevent incorrect truncation when values exceed the 32-bit range.
    • Corrected propagation of range safety across copied and multi-step values, including loops and increment operations.
    • Preserved expected 32-bit wrapping for explicitly integer-oriented operations.
  • Tests

    • Added regression coverage for overflow, copy chains, indexing arithmetic, multiplication, subtraction, and bitwise operations.

…not merely integer (#6339)

The strictly-i32-bounded oracle answered its LocalGet/Update arms from
integer_locals, which means integer-VALUED and deliberately admits
overflowing Add/Sub/Mul. A local holding an overflowed arithmetic result
therefore still vouched for its copies, so 't = big2' (big2 = 2e9 + 2e9)
took an i32 shadow and truncated to -294967296.

Turn the set into the greatest fixpoint of 'every write is i32-bounded':
seed the oracle optimistically with integer_locals, record which strict
verdicts merely leaned on the oracle, and propagate every disqualification
backwards along those copy edges with a worklist (one walk, linear).

Intentionally-i32 code is untouched: | 0, >>> 0, bitwise ops, Math.imul,
clamp calls and byte loads all prove themselves without consulting the
oracle, so image_convolution's FNV-1a accumulator keeps its i32 slot.
@coderabbitai

coderabbitai Bot commented Jul 13, 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: 1bd26e45-bdef-4593-9a87-c7f5ebab9d70

📥 Commits

Reviewing files that changed from the base of the PR and between dd49fc3 and 7d9e4de.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/collectors/i32_locals.rs
  • test-files/test_gap_6339_i32_arith_chain_copy.ts

📝 Walkthrough

Walkthrough

Changes

i32 boundedness provenance

Layer / File(s) Summary
Oracle contract and strict-write facts
crates/perry-codegen/src/collectors/i32_locals.rs
The strictness predicate now uses an i32-ranged-local oracle and dependency callback; StrictWriteFacts records observed, disqualified, and copy-dependent writes.
Fixpoint collection and AST traversal
crates/perry-codegen/src/collectors/i32_locals.rs
Strict-local collection seeds integer locals, traverses statements and expressions with shared facts, and propagates disqualifications backward through copy dependencies.
Arithmetic-chain and indexing regression coverage
test-files/test_gap_6339_i32_arith_chain_copy.ts
Regression cases cover overflowing arithmetic copies, transitive chains, intentional i32 operations, boundary values, array indexing, and clamped index arithmetic.

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

Sequence Diagram(s)

sequenceDiagram
  participant Compiler
  participant i32LocalCollector
  participant StrictnessOracle
  participant RegressionTest
  Compiler->>i32LocalCollector: collect strictly bounded locals
  i32LocalCollector->>StrictnessOracle: evaluate writes with oracle dependencies
  StrictnessOracle-->>i32LocalCollector: strict results and copy edges
  i32LocalCollector->>i32LocalCollector: propagate disqualifications to a fixpoint
  i32LocalCollector-->>Compiler: remaining bounded locals
  RegressionTest->>Compiler: execute arithmetic and indexing cases
  Compiler-->>RegressionTest: produce expected values
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6258 — Both changes update strict handling of increment/decrement-updated locals in i32_locals.rs.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the codegen fix and matches the copied-local i32 range bug addressed by the PR.
Description check ✅ Passed The description covers summary, root cause, fix, verification, and linked issue context, even if it doesn't mirror the template headings.
Linked Issues check ✅ Passed The implementation matches #6339 by distinguishing integer-valued from i32-ranged locals, propagating copy dependencies, and adding regression coverage.
Out of Scope Changes check ✅ Passed The changes stay focused on i32 fast-path analysis and its regression test, with no obvious unrelated code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6339-i32-arith-chain

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 9fa065d into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6339-i32-arith-chain branch July 13, 2026 13:47
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.

i32 fast path: copying a local whose value came from an arithmetic chain past 2^31 wraps (fourth face of #6072)

1 participant