perf(repsel): refuse canonical i32 when every hot consumer wants a double (#7128) - #7132
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe change adds canonical-i32 profitability analysis, excludes unprofitable locals, reports ChangesCanonical i32 profitability
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant HIRFacts
participant RepselBenefit
participant LetStmt
participant SlotRep
participant Census
HIRFacts->>RepselBenefit: collect unprofitable canonical-i32 locals
RepselBenefit-->>HIRFacts: return refusal local IDs
HIRFacts->>LetStmt: expose profitability facts
LetStmt->>SlotRep: evaluate canonical-i32 eligibility
SlotRep-->>LetStmt: report no_i32_consuming_use when applicable
Census->>SlotRep: collect denial-rule reports
Census-->>Census: enforce configured refusal floors
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-codegen/src/collectors/repsel_benefit/tests.rs (1)
1-467: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAdd a regression test for a self-referencing forced-conversion (
x = x / 2orx = f(x)).Every other conjunct of the rule has a boundary test (see the module doc: "a refusal rule that over-fires is a silent coverage loss with no symptom"), but no test covers a self-referencing divide or self-referencing call argument inside a loop. Given the
self_targetgap flagged inrepsel_benefit.rs(Lines 235-311), add a case like:#[test] fn self_divide_is_still_a_cost() { let stmts = vec![ let_mut(1, Some(Expr::Integer(64))), Stmt::While { condition: cmp(get(1), Expr::Integer(1)), body: vec![set(1, bin(BinaryOp::Div, get(1), Expr::Number(2.0)))], }, ]; let out = run(&stmts, &HashSet::new()); assert!(out.contains(&1), "self-divided counter must still be refused: {out:?}"); }This test would currently fail against the code as written, which is exactly the signal this file's other tests are designed to give.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/collectors/repsel_benefit/tests.rs` around lines 1 - 467, Add a regression test alongside the existing self-target coverage, using the `run` helper and a loop whose body assigns a local to itself through floating-point division (for example, `x = x / 2.0`). Assert that the local appears in the refused set, ensuring self-target suppression does not hide a forced-conversion cost; preserve the existing `self_step_is_not_a_cost` behavior for integer-preserving updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-codegen/src/collectors/hir_facts.rs`:
- Around line 472-491: Extend I32StorageFacts and its holds_i32() method in
crates/perry-codegen/src/collectors/repsel_benefit.rs:104-139 to include
loop_bounded_i32 as an eligible source, then pass &loop_bounded_i32_locals when
constructing I32StorageFacts in
crates/perry-codegen/src/collectors/hir_facts.rs:472-491.
In `@crates/perry-codegen/src/collectors/repsel_benefit.rs`:
- Around line 200-311: Clear self_target while evaluating expressions at
forced-conversion boundaries so genuine double-conversion costs are counted. In
expr, update BinaryOp::Div and BinaryOp::Pow handling and each argument
evaluation in Expr::Call and Expr::New to suppress the self-write exemption;
preserve self_target for inherited contexts such as Add, Sub, Mul, and Mod.
---
Outside diff comments:
In `@crates/perry-codegen/src/collectors/repsel_benefit/tests.rs`:
- Around line 1-467: Add a regression test alongside the existing self-target
coverage, using the `run` helper and a loop whose body assigns a local to itself
through floating-point division (for example, `x = x / 2.0`). Assert that the
local appears in the refused set, ensuring self-target suppression does not hide
a forced-conversion cost; preserve the existing `self_step_is_not_a_cost`
behavior for integer-preserving updates.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: acaba268-0e18-4cad-8cf5-4e87f1c44121
📒 Files selected for processing (11)
benchmarks/repsel_census/README.mdbenchmarks/repsel_census/fixtures/fixture_loop_bounded_i32.tscrates/perry-codegen/src/collectors/hir_facts.rscrates/perry-codegen/src/collectors/mod.rscrates/perry-codegen/src/collectors/repsel_benefit.rscrates/perry-codegen/src/collectors/repsel_benefit/tests.rscrates/perry-codegen/src/expr/slot_rep.rscrates/perry-codegen/src/stmt/let_stmt.rsdocs/representation-selection-rfc.mdscripts/compiler_output_harness/repsel_census.pytests/test_repsel_census.py
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@benchmarks/repsel_census/baseline.json`:
- Line 378: Remove the baseline canonical-i32 floors for suite_07_object_create,
suite_12_binary_trees, suite_13_factorial, and suite_14_closure, unless each
workload has an explicit no_i32_consuming_use refusal minimum in REFUSAL_FLOORS
within repsel_census.py; retain only floors justified by that guard.
In `@changelog.d/7132-repsel-profitability.md`:
- Around line 18-19: Keep the inline code span containing totalIter entirely on
one physical Markdown line, including its opening and closing backticks, so no
line-break or indentation whitespace appears inside the span and MD038 passes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b846d572-06ab-4a0e-a794-b02aafbf8e1f
📒 Files selected for processing (2)
benchmarks/repsel_census/baseline.jsonchangelog.d/7132-repsel-profitability.md
… the shape CodeRabbit on #7132: `x = x / 2` and `x = f(x)` are self-writes whose VALUE is a double. Keying the exemption on the syntactic shape scored them as free, which would promote a local whose hot consumer wants a double -- the exact failure this module refuses, re-entering through the fix.
…uble (#7128) WIP: profitability model, pending measurement.
… the shape CodeRabbit on #7132: `x = x / 2` and `x = f(x)` are self-writes whose VALUE is a double. Keying the exemption on the syntactic shape scored them as free, which would promote a local whose hot consumer wants a double -- the exact failure this module refuses, re-entering through the fix.
2a81b7b to
7643545
Compare
…inimum CodeRabbit on #7132: a floor that fell because a promotion was deliberately refused must be paired with the assertion that it is still being refused, otherwise the lower floor silently accommodates a DIFFERENT promotion going missing. Counts verified per workload with --opt-report, not inferred.
Fixes the
15_mandelbrotfinding in #7128.benchmarks/suite/15_mandelbrot.tsregressed +14.87% instructions retiredat #7121 — measured on a quiet Raspberry Pi 5 with
perf stat, bisected to#7121 by binary hash, and invisible in wall time because the workload is
FP-latency-bound.
The root cause, read out of the emitted AArch64
The brief's working hypothesis (
sitofp/fptosion a hot path) is part ofthe answer, and the smaller part. The innermost loop is
iteras a boxed double — 12 instructions, one basic block, one branch:Both exit tests are FP, so LLVM fuses them with
fccmpinto a single block.iterin a canonical i32 slot — 14 instructions, two blocks, two branches:An integer compare cannot fuse with an FP compare. The loop splits, gaining a
compare, a branch and a register copy for the induction phi — and separately
totalIter = totalIter + iternow needs aucvtf(c4: ucvtf d3, w11) it didnot need before.
2 instructions × 8,011,148 innermost iterations ≈ 16.0M, against a measured
+15,628,722. That is the whole regression, arithmetically.
pxandpyare the same shape one level out:const cx = (px - WIDTH / 2.0) * 4.0 / WIDTHreads an i32pxinto an f64 chain twice per inner iteration.(LLVM's IndVarSimplify already narrows both counters to
w-registers in botharms, so promoting them explicitly bought nothing even before the fusion loss.)
Where the fix belongs, and why
Not in the proof. #7122's monotone loop-induction interval proves
iter ∈ [0, 100], and that is true. The Let-site gate instmt/let_stmt.rsis aconjunction of "may we?" terms —
integer_locals,index_used_locals,strictly_i32_bounded_locals,loop_bounded_i32_locals,int_valued_ta_locals— with no "should we?" term anywhere in it. So widening a proof
automatically widens the emission, which is exactly what #7110 → #7121 did.
A cost model consulted by the collector, not a rule inside one. The new
collectors/repsel_benefit.rscomputes a per-local profitability verdict fromthe same HIR the other collectors walk, and the Let-site gate consults it as one
more conjunct. Three reasons for that shape rather than folding the condition
into
loop_bounded_i32.rs:selection is a wrong answer; an unprofitable one is a slower answer that no
correctness test can see. Folding them means the next widening of a range
proof silently changes the benefit verdict, and vice versa.
--opt-reportnow saysno_i32_consuming_usewhere it used to say nothing at all — or worse, wouldhave said
module_init_context, the rule perf(codegen): module-init / program-entry bodies select canonical i32/u32/Str (#7109) #7121 removed, sending the nextreader back to a bug that is already fixed.
D: every
__pshapeclone is dead-stripped before the object, andPtr<NumArray>emits nothing outside its own fixture. Both are "proven,selected, no byte changed". This is the module they plug into. I did not
widen it to them here — that is a separate measurement.
The rule, and whether it generalises
For an i32-range value,
doubleis a lossless and equal-cost representationof
+,-and comparison — one instruction either way on every target Perryships.
I32only buys something where the consumer cannot take a doublewithout a conversion: array/typed-array indexing, bitwise operands,
Math.imul. That is the same listdocs/representation-selection-rfc.md§5.3already gives for where
I32semantics are exact — the benefit set and theexactness set coincide, which is why this is a rule and not a heuristic. It
becomes a cost the moment a hot consumer needs the double back.
Each conjunct earns its place, and each has a test that turns red without it:
every read, so LICM hoists any conversion out of the loop.
const WIDTH = 800is read as a double twice per inner iteration of
15_mandelbrotand stillcosts nothing; judging it would be pure census churn.
11_prime_sieve's counters are all index-used, which is why its win isuntouched.
once.
return iterafter the loop is not a reason to refuse.It is not a special case for
15_mandelbrot. The refusal fires on 8 localsacross 6 benchmark programs (plus
hitin the fixture), and the ones outside15_mandelbrotare the same shapereached by different syntax:
result = result + (1.0 / i)(06_math_intensive),new Point(i, i + 1)(07_object_create,12_binary_trees),compute(i)(
14_closure),sum + (i % 1000)(13_factorial).REFUSAL_FLOORSpins twodifferent programs plus a fixture, so a later narrowing to "mandelbrot only"
goes red.
Deliberate under-approximation, since the model can only ever refuse: an
expression form it does not model contributes neither side; comparison is
neutral on both sides (otherwise
for (let i = 0; i < n; i++)with anumberparameter — the most common loop in JavaScript — would stop promoting, to buy
nothing); and a write of a local into its own slot never costs, though it still
counts as a benefit in an i32 position, because
seed = (Math.imul(seed, K) + C) & 0x7fffffffis a local whose only read isinside its own assignment.
Review follow-up: the
self_targetexemption (CodeRabbit, Major)CodeRabbit asked for a regression test on a self-referencing forced conversion
(
x = x / 2,x = f(x)) and predicted it would fail. It does, and it was alive bug in the first version of this PR. I ran its test before deciding
anything:
The first version keyed the exemption on the syntactic shape
t = … t ….That is wrong for the reason CodeRabbit gives:
x = x / 2andx = f(x)areself-writes whose value is a double, so scoring them free would promote a
local whose hot consumer wants a double — the exact failure this PR exists to
refuse, re-entering through the fix rather than the original path.
The rule now follows the representation flow, not the shape:
self_targetis cleared on the way through any operation that forces amaterialization (
Model::forced_double— a/or**operand, a call ornewargument) and kept only where theDoublecontext is inherited fromthe target slot (
iter = iter + 1,t = -(t + k)). Inherited-Double is theslot's own representation; forced-Double is a genuine convert-out-and-back.
The gap is closed, not accepted. All three cases are covered
(
/, call argument,newargument — they do not share a code path), plus apositive control that a representation-preserving chain is still exempt, with
a second local in the same expression asserted to be refused so the green
verdict cannot come from the walk stopping early.
Note for the record: this could not have reached the shipped compiler through
the
loop_bounded_i32path, because that proof requires every write to be astep (
v++,v = v ± k), sox = x / 2is never a candidate. Butstrictly_i32_bounded_localscan admith = clamp(h / 2)-shaped locals, and#7123 proposes to widen the proof further. "Unreachable today" is exactly the
reasoning that produced #7128 in the first place — a conjunction whose terms
were widened one at a time — so the rule is fixed rather than argued away.
Measurements
Raspberry Pi 5, aarch64, load 0.23 at start,
perf stat -e instructions:u,11 repeats, warmed. Compiler arms built from one target dir in one session,
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static;perrymd5s verified distinct,libperry_runtime.averified identical acrossarms (this PR touches only
perry-codegen). Every row carries the linkedbinary hash, so "the two arms were actually different binaries" — or
deliberately the same one — is on the record next to the number.
main203480849e622913at7122(pre-regression)9e622913main0e9e58ca0e9e58caat7122bac60649maina2e03bb6a2e03bb6at7122b9a40645The two wins the brief said must not regress, re-measured against
at7122with this PR's compiler rather than assumed: canonical
Stron08_string_concat−4.12%, canonical i32 on11_prime_sieve−1.05%.Both hold, and hold by construction: this PR's linked binary for each is
byte-identical to
main's.These are the numbers from the FINAL compiler (after the CodeRabbit fix below).
The earlier arm measured identically — the review fix moved 0 of 26 objects
across the corpus, verified rather than assumed.
Controls (same binary in both arms — any reading other than ~0 would mean
the rig is contaminated):
01_startup+0.02%,02_loop_overhead−0.00%,13_factorial+0.00%,06_math_intensive+0.00%; and from the full sweep,07_object_create−0.00%,12_binary_trees+0.00%,14_closure+0.00%,17_loop_data_dependent−0.00%,16_matrix_multiply+0.00%,batch+0.05%.batchis the loosest same-binary row at 1.6M instructions out of 3.37e9; Iquote it as the observed noise ceiling rather than the 0.02% floor #7128
measured on smaller programs.
Emission, corpus-wide. All 26 census workloads compiled with both arms and
compared as
objdump -dtext. Linux objects are nondeterministic (#7128 findingE — the LLVM module name embeds pid+nanotime), so the rig proves its own
determinism first by compiling every workload twice in the
mainarm: 26/26identical under this comparison, which is what makes the A/B column meaningful.
Exactly two objects change:
15_mandelbrot, andfixture_loop_bounded_i32(which this PR extends on purpose). For
15_mandelbrotthe disassembly isbyte-identical to
at7122's, and so is the linked binary. The other refusedpromotions were already emitting byte-identical code, which is why the census
counts fall without a single emitted byte moving.
Wall time.
15_mandelbrotreads 48 ms before and 49 ms after (min 48 in botharms) — 1 ms of timer quantisation on an FP-latency-bound workload, the same
non-signal #7128 recorded in the other direction. I am not claiming a wall win;
the claim is 15.6M fewer instructions.
Census
canonical-i32 falls 64 → 55. Every moved floor, deliberately:
suite_15_mandelbrotpy,px,itersuite_06_math_intensiveiin1.0 / isuite_07_object_createiinnew Point(i, i+1)suite_12_binary_treesiinnew Point3D(…)suite_13_factorialiinsum + (i % 1000)suite_14_closureiincompute(i)No other floor moved and no
LIVENESS_FLOORSminimum moved: every fixture stillpromotes exactly what it was written to promote. The five workloads other than
15_mandelbrotemit identical objects either way, so this is the censuslearning to report what codegen was already doing.
Every lowered floor is paired with a refusal minimum (CodeRabbit): all six
workloads above, plus the fixture, are in
REFUSAL_FLOORS, with counts read outof
--opt-reportper workload rather than inferred from the drop. A floor thatfell because a promotion was deliberately refused must carry the assertion that
it is still being refused — otherwise the lower floor silently accommodates a
different promotion going missing.
The gate — because no floor in this census can catch an EXTRA promotion
Every number in
benchmarks/repsel_censusis a promotion count and every gate onit is a floor. A floor cannot go red when a compiler promotes more, which
is precisely the failure this PR fixes. So the refusal gets a minimum of its own:
REFUSAL_FLOORSinscripts/compiler_output_harness/repsel_census.py(in code,not in the regenerable baseline, for the same reason as
LIVENESS_FLOORS),checked by
check_refusal_floorsand wired intocensus --gate.fixture_loop_bounded_i32.tsnow carries the paired case:iterate()'s counterand
mixedWithFloat()'s counter are admitted by the identical #7110 intervalproof and differ only in what consumes them. One must promote (its
canonical-i32: 3liveness floor) and one must be refused (its refusal floor),so neither an always-yes nor an always-no rule can satisfy the file.
Sabotage evidence
Direction 1 — the rule stops firing. Not simulated:
census --gaterun withthe unfixed
maincompiler against this PR's shipped baseline exits 1, andthe only failure is the new check (
REGRESSIONcount: 0):The same command with this PR's compiler prints
Census OK.and exits 0.Direction 2 — the rule over-fires. Each of the three conjuncts was
individually deleted and the tests re-run; each has its own disjoint set of
red tests, so no conjunct is decorative:
int_reads == 0..._is_a_benefit×3,write_into_an_i32_storage_local_is_a_benefithot_double_reads >= 1bare_guard_only_counter_survives,guard_against_a_non_integer_bound_is_not_a_cost,self_step_is_not_a_cost,double_use_outside_a_loop_is_not_a_cost,unmodelled_forms_are_neutral,a_self_write_through_a_preserving_chain_is_still_not_a_costwrites_after_decl >= 1write_once_local_is_out_of_scopeDirection 3 — the exemption over-reaches (the CodeRabbit case above): the
three self-referencing forced-conversion tests fail against the previous
version of the rule and pass at HEAD, with no collateral (398 passed / 3 failed
→ 401 passed / 0 failed).
Anti-vacuity: the boundary tests that assert a local is not refused carry a
second local in the same expression that is refused, so a green verdict
cannot come from the walk stopping early.
GC x representation-selection matrix
scripts/gc_repsel_matrix.sh --arms all --pressure 8, run locally on the Piagainst this PR's compiler (CI is hours deep in the runner queue):
FAIL=0, the state the brief records for
main. The singleXFAILis thepre-existing triaged entry (
repsel_ptr_shape_localsxrep_ptr_shape_off).UNVERis the arm-was-inert verdict, unchanged in shape frommain: thenon-evacuating arms are inert on the workloads that allocate too little to
trigger a collection, which is what that column exists to say out loud.
This is expected to be a null for this change and is reported as due
diligence rather than as evidence: the refusal moves values from an
unscanned i32 slot back to a GC-scanned double slot, which is the conservative
direction, and 24 of the 26 census objects are byte-identical anyway.
gc-ratchet
Run locally on the Pi, both profiles. The gated (
shared_ci) run reports:That is not this PR. The harness itself rejects the comparison (the pinned
baseline is macOS; the only host with
perfis Linux), and the control settlesit: the same ratchet, same host, same session, with the unfixed
maincompiler produces the byte-identical row —
1,107,552,+6.47%. So the deltais the platform, and this change contributes zero to it.
Every other gated metric —
heap_total_bytes,minor_cycles,step_cycles,copied_objects,promoted_objects,freed_bytes— reads +0.00% on alleight probes. The ungated column (
rss_bytes,peak_rss_bytes,wall_ms)moves by 7–150% in both arms alike, which is exactly the cross-platform drift
tolerances.jsondeclines to gate.The authoritative run is the
gc-ratchetCI job on this PR, which executes onthe baseline's own platform.
What I could not measure
here is measured on the Mac mini.
fccmphalf of the mechanism is AArch64-specific. Bothavailable hosts are ARM. The
sitofp-at-the-accumulator half istarget-independent; the fused-exit-test half may be smaller on x86-64. The rule
does not depend on which half dominates — a representation that only ever
converts back cannot be cheaper on any target — but the size of the win on
x86-64 is unmeasured.
.textquantisation plus nondeterministicobjects (repsel: the coverage work converted exactly as predicted, and almost none of it is faster (one −4.1% win, one +14.9% regression) #7128 finding E).