Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions benchmarks/repsel_census/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ report *no consumption data* rather than a zero
(`CONSUMPTION_INSTRUMENTED` in the script), because "uninstrumented" and "never
applied" are exactly the pair this census exists to keep apart.

## The one check that catches an EXTRA promotion

Every number above is a promotion count and every gate on it is a **floor**, so
the census can only ever go red when a representation stops firing. #7128 is the
opposite failure: `benchmarks/suite/15_mandelbrot.ts` promoted three counters it
should not have — all three provably i32-bounded, none ever used as an integer —
and paid **+14.87% instructions retired** for them, measured on a quiet
Raspberry Pi 5 at a 0.02% noise floor. No floor in this file can go red for
that; more promotions always reads as an improvement.

So the deliberate refusal gets a minimum of its own. `REFUSAL_FLOORS` (in
`scripts/compiler_output_harness/repsel_census.py`, in code and not in the
baseline, for the same reason as `LIVENESS_FLOORS`) says how many times the
`no_i32_consuming_use` rule must fire per workload. Deleting
`crates/perry-codegen/src/collectors/repsel_benefit.rs` takes `15_mandelbrot`
from three refusals to zero and the census red.

`fixture_loop_bounded_i32.ts` carries the paired case: `iterate()`'s counter and
`mixedWithFloat()`'s counter are admitted by the identical #7110 interval proof
and differ only in what consumes them. One must promote (its `canonical-i32`
liveness floor) and one must be refused (its refusal floor), so neither an
always-yes nor an always-no rule can satisfy the file.

## How it cannot quietly pass

Read CLAUDE.md, "★ Four ways a gate can be unable to fail". The fourth applies
Expand Down
20 changes: 10 additions & 10 deletions benchmarks/repsel_census/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,15 @@
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
"spec-abi-entry": 1,
"spec-abi-entry": 2,
"spec-abi-taptr-slot": 0
},
"candidates": {
"ptr-shape": 0,
"ptr-numarray": 0,
"canonical-slot": 5,
"canonical-slot": 7,
"int-valued-ta": 0,
"spec-abi": 4
"spec-abi": 5
},
"unconsumed_mechanisms": {},
"consumption_sites": {}
Expand Down Expand Up @@ -375,7 +375,7 @@
"ptr-shape": 0,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 2,
"canonical-i32": 1,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand All @@ -400,7 +400,7 @@
"ptr-shape": 1,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 2,
"canonical-i32": 1,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand Down Expand Up @@ -533,7 +533,7 @@
"ptr-shape": 1,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 2,
"canonical-i32": 1,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand All @@ -560,7 +560,7 @@
"ptr-shape": 0,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 2,
"canonical-i32": 1,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand All @@ -585,7 +585,7 @@
"ptr-shape": 0,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 2,
"canonical-i32": 1,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand All @@ -610,7 +610,7 @@
"ptr-shape": 0,
"ptr-shape-consumed": 0,
"ptr-numarray": 0,
"canonical-i32": 6,
"canonical-i32": 3,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
Expand Down Expand Up @@ -678,5 +678,5 @@
"consumption_sites": {}
}
],
"generated_at": "2026-07-31T06:45:01.246943Z"
"generated_at": "2026-07-31T09:26:30.260742Z"
}
46 changes: 40 additions & 6 deletions benchmarks/repsel_census/fixtures/fixture_loop_bounded_i32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
// `collect_loop_bounded_i32_locals` returns the empty set this file's
// canonical-i32 count is zero and the census goes red.
//
// The two locals it must NOT promote are here on purpose: an unadmitted
// counter and an unbounded accumulator keep the fixture from being satisfied by
// any rule that simply says yes to proven-integer locals.
// The three locals it must NOT promote are here on purpose: an unadmitted
// counter, an unbounded accumulator, and (since #7128) a counter that is
// perfectly provable and not worth promoting. Together they keep the fixture
// from being satisfied by any rule that simply says yes to proven-integer
// locals.
//
// Requirements shared with the other canonical-slot fixtures: plain synchronous
// function bodies (async/generator bodies are context-excluded), and no closure
Expand All @@ -31,8 +33,11 @@ function countUp(): number {
}

// PROMOTES. A `while` whose guard is a CONJUNCTION and whose step is a
// `LocalSet` Add rather than `++` — the 15_mandelbrot `iter` shape.
// Interval [0, 100].
// `LocalSet` Add rather than `++`. Interval [0, 100]. Nothing inside the loop
// reads `iter` at all, so the representation converts nowhere and the counter
// is free to take the i32 slot; the one `return iter` runs once, outside the
// loop. Compare `mixedWithFloat` below, which is the same proof and the
// opposite verdict.
function iterate(seed: number): number {
let iter = 0;
let x = seed;
Expand Down Expand Up @@ -67,6 +72,33 @@ function accumulate(): number {
return sum;
}

// DOES NOT PROMOTE — and this is the only entry here whose PROOF succeeds.
// `hit` is admitted by exactly the same interval argument as `iterate`'s
// counter above: single literal init, one guarded step, interval [0, 100].
// What differs is the consumer. `weight` is an unbounded accumulator, so it
// keeps a boxed double slot, and `weight = weight + hit` reads `hit` back as a
// double once per iteration. An i32 slot for `hit` would emit a `sitofp` per
// iteration and buy nothing — `hit` is never an array index, never a bitwise
// operand, never a `Math.imul` argument.
//
// `benchmarks/suite/15_mandelbrot.ts` is exactly this shape (`totalIter =
// totalIter + iter` around a `while (fp && iter < MAX_ITER)`), and promoting
// it cost **+14.87% instructions retired** — measured on a quiet Raspberry Pi
// 5 at a 0.02% noise floor (#7128). The refusal is gated by REFUSAL_FLOORS in
// scripts/compiler_output_harness/repsel_census.py; deleting
// collectors/repsel_benefit.rs takes that check red.
function mixedWithFloat(seed: number): number {
let weight = 0.0;
let hit = 0;
let x = seed;
while (x < 1000.0 && hit < 100) {
x = x * 1.5;
hit = hit + 1;
weight = weight + hit;
}
return weight;
}

console.log(
"loopBounded:" +
countUp() +
Expand All @@ -75,5 +107,7 @@ console.log(
":" +
overshoot() +
":" +
accumulate(),
accumulate() +
":" +
mixedWithFloat(1.0),
);
65 changes: 65 additions & 0 deletions changelog.d/7132-repsel-profitability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
### Fixed

- **repsel: canonical i32 is now chosen on benefit, not only on provability
(#7128).** `benchmarks/suite/15_mandelbrot.ts` regressed **+14.87%
instructions retired** at #7121, measured on a quiet Raspberry Pi 5 with
`perf stat` at a 0.02% noise floor and bisected by binary hash. Wall time did
not move (48 ms vs 49 ms) because the workload is FP-latency-bound, which is
why nothing caught it.

**Root cause, read out of the emitted AArch64 rather than inferred.** The
innermost loop is `while (x*x + y*y <= 4.0 && iter < MAX_ITER)`. With `iter`
a boxed double, both exit tests are FP and LLVM fuses them into `fcmp` +
`fccmp`: **one basic block, 12 instructions, one branch**. #7122's monotone
loop-induction interval proves `iter ∈ [0, 100]` and #7121 let that proof
reach the module-init body, so `iter` took a canonical i32 slot — and an
integer compare cannot fuse with an FP compare. The loop splits into **two
blocks totalling 14 instructions**, plus a `ucvtf` where the accumulator
joins. 2 instructions × 8,011,148 innermost iterations ≈ 16.0M, against a
measured +15.63M. `px` and `py` are the same shape one level out.

**The defect was not the proof.** The proof is correct. The Let-site
eligibility gate in `stmt/let_stmt.rs` was a conjunction of "may we?" terms
with no "should we?" term anywhere in it, so widening the proof
automatically widened the emission.

**The fix is a profitability model** (`collectors/repsel_benefit.rs`),
consulted by the selection gate as one more conjunct. For an i32-range value
a `double` is a lossless, equal-cost representation of `+`, `-` and
comparison; canonical i32 only *buys* something where the consumer cannot
take a double without a conversion — array/typed-array indexing, bitwise
operands, `Math.imul` — and becomes a *cost* the moment a hot consumer needs
the double back. So a local that is written after its declaration, has no
i32-consuming read anywhere, and has at least one double-consuming read
inside a loop stays boxed. The model only ever refuses, so every uncertainty
resolves toward "not a cost" (comparison is neutral on both sides —
`for (let i = 0; i < n; i++)` with a `number` parameter must keep
promoting).

Measured on the Pi, 11 repeats, `perf stat -e instructions:u`:

| workload | before | after | Δ |
|---|---|---|---|
| `15_mandelbrot` | 120,738,701 | 105,110,087 | **−12.94%** |
| `11_prime_sieve` (the #7121 win) | 2,597,182,143 | 2,597,177,676 | −0.00% |
| `08_string_concat` (the #7121 `Str` win) | 30,281,798 | 30,281,711 | −0.00% |

Both #7121 wins re-measured against the `at7122` arm with this compiler:
canonical `Str` **−4.12%**, canonical i32 **−1.05%**. They hold by
construction — the linked binary for each is byte-identical to `main`'s.

Over the whole 26-workload census corpus the emitted object changes on
**exactly one** benchmark, and there its disassembly is byte-identical to the
pre-#7121 compiler's. The other refused promotions were already emitting
byte-identical code, so the census counts fall (canonical-i32 64 → 55)
without a single emitted byte moving. Every lowered floor is paired with a
`no_i32_consuming_use` minimum, so a floor that fell because a promotion was
refused cannot silently accommodate a different promotion going missing.

**Gated.** Every other number in the promotion census is a floor, and a floor
cannot go red when a compiler promotes *more*. `REFUSAL_FLOORS` in
`scripts/compiler_output_harness/repsel_census.py` gives the refusal its own
minimum; `benchmarks/repsel_census/fixtures/fixture_loop_bounded_i32.ts` now
carries `iterate()` and `mixedWithFloat()` side by side — same #7110 interval
proof, opposite verdict, differing only in what consumes the counter — so
neither an always-yes nor an always-no rule can satisfy the file.
34 changes: 34 additions & 0 deletions crates/perry-codegen/src/collectors/hir_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ pub(crate) struct RepresentationFacts {
/// — it never widens the parallel-shadow `needs_i32_slot` gate. See
/// `collectors/loop_bounded_i32.rs`.
pub loop_bounded_i32_locals: HashSet<u32>,
/// Locals whose canonical-i32 promotion is PROVABLE but not PROFITABLE
/// (#7128): written after declaration, no i32-consuming read anywhere in
/// the body, and at least one double-consuming read inside a loop — so the
/// i32 slot only ever converts back, emitting strictly more work than the
/// boxed representation. A refusal set, subtracted from the canonical-i32
/// eligibility conjunction and from nothing else. See
/// `collectors/repsel_benefit.rs` for the +14.87% `15_mandelbrot`
/// measurement that motivates it.
pub unprofitable_canonical_i32_locals: HashSet<u32>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -167,6 +176,10 @@ impl TypeFacts {
&self.representation.loop_bounded_i32_locals
}

pub(crate) fn unprofitable_canonical_i32_locals(&self) -> &HashSet<u32> {
&self.representation.unprofitable_canonical_i32_locals
}

pub(crate) fn not_bigint_locals(&self) -> &HashSet<u32> {
&self.representation.not_bigint_locals
}
Expand Down Expand Up @@ -456,6 +469,26 @@ pub(crate) fn collect_type_facts(
clamp_fn_ids,
strict_int_ta_views,
);
// #7128: the profitability half of canonical-i32 selection. Every term
// above answers "may we?"; this one answers "should we?", and it is
// computed here — after the storage facts it consults — rather than folded
// into any of them, so a future widening of a range proof cannot silently
// move the benefit verdict (and vice versa). Skipped entirely when
// canonical selection is off: that arm selects nothing, so a refusal set
// for it would be dead work.
let unprofitable_canonical_i32_locals = if crate::expr::canonical_i32_locals_enabled() {
super::repsel_benefit::collect_unprofitable_canonical_i32_locals(
stmts,
&super::repsel_benefit::I32StorageFacts {
index_used: &index_used_locals,
strictly_bounded: &strictly_i32_bounded_locals,
unsigned: &unsigned_i32_locals,
int_valued_ta: &int_valued_ta_locals,
},
)
} else {
HashSet::new()
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let known_noalias_buffer_locals = collect_known_noalias_buffer_locals(stmts);
let non_escaping_news = super::escape_news::collect_non_escaping_news(
stmts,
Expand Down Expand Up @@ -525,6 +558,7 @@ pub(crate) fn collect_type_facts(
not_bigint_locals,
int_valued_ta_locals,
loop_bounded_i32_locals,
unprofitable_canonical_i32_locals,
},
arrays: array_facts,
effect: effect_facts,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod ptr_shape;
mod ptr_shape_report;
mod ptr_shape_returns;
mod refs;
mod repsel_benefit;
mod scalar_method_dispatch;
mod scalar_methods;
mod shadow_slots;
Expand Down
Loading
Loading