You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
repsel: #7132's profitability refusal over-fires on x86-64 (fccmp-fusion term is AArch64-specific) — cost model needs a per-target term #7146
x86-64 validation of #7132's profitability refusal (measured on webserver2, Intel Xeon Icelake, commit a3b31c0d8; full data in #7128 (comment)) returned verdict (b): the refusal over-fires on x86-64.
The evidence
The documented root cause of the mandelbrot regression is half AArch64-specific: on ARM, the double-typed counter lets LLVM fuse both loop-exit tests with fcmp+fccmp into one block (12 insn / 1 block), and the i32 form loses that fusion (14 insn / 2 blocks, +2 insn per iteration). x86-64 has no fccmp — the double form already pays two compares and two branches:
target
arm A (double)
arm B (i32)
Δ insn
Δ blocks
AArch64 (control)
12 insn / 1 block
14 / 2
+2
+1
x86-64 Icelake
13 insn / 2 blocks
14 / 2
+1
0
x86-64 generic SSE2
17 / 2
18 / 2
+1
0
The residual +1 (induction-phi mov) costs no time: 41 alternating runs, median B/A = 1.0000 — the loop is FP-latency-bound and arm B moves the counter onto idle integer ports (9 FP ops/iter vs 11).
So on x86-64 the refusal denies 9 promotions across 7 workloads and buys nothing. It is not a regression there either — which argues for a per-target term in the cost model, not for weakening the rule where it is right (AArch64).
Suggested shape
collectors/repsel_benefit.rs currently answers "does every hot consumer want a double?" target-independently. The branch-fusion penalty term should be conditioned on the target (present on AArch64-class targets with fccmp, absent on x86-64). The conversion-cost term (sitofp/ucvtf) is target-independent and stays.
Caveats from the measurement
The x86-64 instruction delta is analytic (exact for the straight-line body × printed trip count); the box's PMU is not virtualised, so there is no directly-counted instructions-retired figure.
Only 15_mandelbrot was timed; the other six refusal sites are different syntactic shapes and remain unmeasured on x86-64.
Measured on main @ 514ff7c9a (macOS arm64): the per-target term should not be built — the refusal now measures zero on AArch64 too, and the fusion has swapped sides
Verdict up front: I could not measure x86-64 (arm64 host, stated plainly), but I
did not need to. The premise this issue rests on — "the rule is right where it
fires on AArch64, so add a per-target term rather than weaken it" — does not
survive re-measurement on current main. There is no AArch64 benefit left to
protect.
Method
One tree, two compilers differing only by a throwaway PERRY_REPSEL_BENEFIT=0
knob that empties collect_unprofitable_canonical_i32_locals's result set (and
is keyed into BUILD_CACHE_ENV_VARS, so the object cache cannot hand one arm
the other's objects). --profile perry-dev, -p perry -p perry-runtime-static -p perry-stdlib-static; PERRY_RUNTIME_DIR pinned and the two .as byte-identical across every hop (only compiler crates changed). Instrument: /usr/bin/time -l instructions retired, 5 interleaved reps per arm; program
output compared. Host is contended, so wall clock is not quoted anywhere.
1. Where the refusal changes bytes at all
benchmarks/suite/*.ts, 30 files, --no-link objects compared with cmp:
the refusal fires (--opt-report prints no_i32_consuming_use) on 12
the emitted object differs on 6: 15_mandelbrot, bench_json_readonly, bench_json_readonly_indexed, bench_json_roundtrip, bench_json_typed_roundtrip, bench_string_heavy
On the other 6 the report names a refusal that emits nothing.
2. Instructions retired on all six, medians of 5 interleaved reps
workload
refusal ON
refusal OFF
Δ
15_mandelbrot
1,613,899,624
1,612,506,854
−0.09%
bench_json_readonly
3,313,845,141
3,316,415,805
+0.08%
bench_json_readonly_indexed
3,351,404,311
3,353,510,564
+0.06%
bench_json_roundtrip
3,516,894,191
3,509,691,602
−0.20%
bench_json_typed_roundtrip
11,062,026,162
11,056,038,512
−0.05%
bench_string_heavy
979,274,583
978,921,293
−0.04%
Every one is inside the within-arm spread. The +14.87% this rule exists for
does not reproduce, on the workload it was measured on, on the target it was
measured on.
Re-run for 15_mandelbrot with --release compiler and --release libperry_{runtime,stdlib}.a (same knob, same pinned PERRY_RUNTIME_DIR, 5
interleaved reps): ON 1,610,420,345 vs OFF 1,610,347,220 — 0.00%.
3. Why — and it is not subtle: the fusion is now in the i32 arm
Emitted AArch64 for 15_mandelbrot's innermost loop, current main, defaults:
;; refusal ON — `iter` is a double
fcmp d0, d12
cset w8, ls
fcmp d13, d14 ;; TWO separate compares, no fccmp
csel w23, wzr, w8, pl
;; refusal OFF — `iter` is an i32
fcmp d0, d13
ccmp w24, w21, #0x2, ls ;; <-- the conditional-compare fusion is HERE
cset w25, lo
The rule refuses the i32 promotion in order to keep a fused single-block exit
test. On current main the double arm does not get one and the i32 arm does.
The +2 insn / +1 block model in collectors/repsel_benefit.rs's header is
inverted.
Cause, demonstrated causally. With PERRY_GC_MOVING_LOOP_POLLS=0 the double
arm gets its fccmp d14, d15, #0x0, ls back and the loop is one basic block
again. #7721 made GC back-edge polls default-ON; the poll's ldr w8,[x21] / cbz / bl now sits between the compare and the branch, which
is exactly what destroys the shape #7128 measured. (With polls off the two arms'
objects are byte-identical, so the refusal emits nothing there either.)
4. The ±2-instruction argument is now ~0.4% of the loop
15_mandelbrot's inner loop is no longer 12 instructions. It is 29, with six bl calls to js_dynamic_mul / js_dynamic_sub — x*x, y*y, 2.0*x, *y all route through the dynamic arithmetic helper. 1.613e9 instructions /
8,011,148 inner iterations = 201 per iteration, against #7128's ~13.
Reproduced on the unmodified benchmarks/suite/15_mandelbrot.ts, with and
without auto-optimize. (Filed separately — it is a much larger number than
anything in this issue.)
Recommendation: close, do not build the term
The x86-64 measurement in this issue reported median B/A = 1.0000 — i.e. the
promotions the refusal denies are worth nothing there. The AArch64 benefit the
term was meant to leave intact measures 0.0 ± 0.2% here. A per-target term
would therefore add the first target-dependent branch to a model whose own
header advertises target independence, buy a measured zero on both targets, and
create an x86-64-only configuration that no CI job exercises — the failure mode
CLAUDE.md's "★ Four ways a gate can be unable to fail" is about.
What the data does support is re-opening a different question: the refusal is
a profitability rule whose cost model is stale (§3) on a workload that has
changed out from under it (§4). If it earns nothing on either target it should
be deleted rather than made target-aware — but that is a separate decision, and
it needs an x86-64 arm to make it safely.
Unmeasured, plainly: x86-64 (arm64 host only); the six refusal sites whose
objects are identical were not timed (they cannot differ); nothing was measured
under --profile dist (--release was, for 15_mandelbrot); no wall-clock
number is quoted anywhere.
Closing as refuted by measurement, per the comment above.
The requested per-target term should not be built: the AArch64 benefit it was
meant to preserve measures 0.0 ± 0.2% on all six suite workloads where the
refusal changes emitted bytes (including 15_mandelbrot itself, and confirmed
on --release artifacts at 0.00%), and this issue's own x86-64 measurement was
median B/A = 1.0000. On current main the conditional-compare fusion is in the i32 arm, not the double arm — #7721's default-ON GC back-edge polls put an ldr/cbz/bl between the compare and the branch, and the cost model in collectors/repsel_benefit.rs is stale in consequence.
The residual question — whether the refusal should exist at all now — needs an
x86-64 arm to answer safely and is not this issue. The workload change that
made the model stale is filed as #8105.
Activity
proggeramlug commentedon Aug 14, 2026
Measured on
main@514ff7c9a(macOS arm64): the per-target term should not be built — the refusal now measures zero on AArch64 too, and the fusion has swapped sidesVerdict up front: I could not measure x86-64 (arm64 host, stated plainly), but I
did not need to. The premise this issue rests on — "the rule is right where it
fires on AArch64, so add a per-target term rather than weaken it" — does not
survive re-measurement on current
main. There is no AArch64 benefit left toprotect.
Method
One tree, two compilers differing only by a throwaway
PERRY_REPSEL_BENEFIT=0knob that empties
collect_unprofitable_canonical_i32_locals's result set (andis keyed into
BUILD_CACHE_ENV_VARS, so the object cache cannot hand one armthe other's objects).
--profile perry-dev,-p perry -p perry-runtime-static -p perry-stdlib-static;PERRY_RUNTIME_DIRpinned and the two.asbyte-identical across every hop (only compiler crates changed). Instrument:
/usr/bin/time -linstructions retired, 5 interleaved reps per arm; programoutput compared. Host is contended, so wall clock is not quoted anywhere.
1. Where the refusal changes bytes at all
benchmarks/suite/*.ts, 30 files,--no-linkobjects compared withcmp:--opt-reportprintsno_i32_consuming_use) on 1215_mandelbrot,bench_json_readonly,bench_json_readonly_indexed,bench_json_roundtrip,bench_json_typed_roundtrip,bench_string_heavyOn the other 6 the report names a refusal that emits nothing.
2. Instructions retired on all six, medians of 5 interleaved reps
15_mandelbrotbench_json_readonlybench_json_readonly_indexedbench_json_roundtripbench_json_typed_roundtripbench_string_heavyEvery one is inside the within-arm spread. The +14.87% this rule exists for
does not reproduce, on the workload it was measured on, on the target it was
measured on.
Re-run for
15_mandelbrotwith--releasecompiler and--releaselibperry_{runtime,stdlib}.a(same knob, same pinnedPERRY_RUNTIME_DIR, 5interleaved reps): ON 1,610,420,345 vs OFF 1,610,347,220 — 0.00%.
3. Why — and it is not subtle: the fusion is now in the i32 arm
Emitted AArch64 for
15_mandelbrot's innermost loop, currentmain, defaults:The rule refuses the i32 promotion in order to keep a fused single-block exit
test. On current
mainthe double arm does not get one and the i32 arm does.The
+2 insn / +1 blockmodel incollectors/repsel_benefit.rs's header isinverted.
Cause, demonstrated causally. With
PERRY_GC_MOVING_LOOP_POLLS=0the doublearm gets its
fccmp d14, d15, #0x0, lsback and the loop is one basic blockagain. #7721 made GC back-edge polls default-ON; the poll's
ldr w8,[x21]/cbz/blnow sits between the compare and the branch, whichis exactly what destroys the shape #7128 measured. (With polls off the two arms'
objects are byte-identical, so the refusal emits nothing there either.)
4. The ±2-instruction argument is now ~0.4% of the loop
15_mandelbrot's inner loop is no longer 12 instructions. It is 29, with sixblcalls tojs_dynamic_mul/js_dynamic_sub—x*x,y*y,2.0*x,*yall route through the dynamic arithmetic helper. 1.613e9 instructions /8,011,148 inner iterations = 201 per iteration, against #7128's ~13.
Reproduced on the unmodified
benchmarks/suite/15_mandelbrot.ts, with andwithout auto-optimize. (Filed separately — it is a much larger number than
anything in this issue.)
Recommendation: close, do not build the term
The x86-64 measurement in this issue reported median B/A = 1.0000 — i.e. the
promotions the refusal denies are worth nothing there. The AArch64 benefit the
term was meant to leave intact measures 0.0 ± 0.2% here. A per-target term
would therefore add the first target-dependent branch to a model whose own
header advertises target independence, buy a measured zero on both targets, and
create an x86-64-only configuration that no CI job exercises — the failure mode
CLAUDE.md's "★ Four ways a gate can be unable to fail" is about.
What the data does support is re-opening a different question: the refusal is
a profitability rule whose cost model is stale (§3) on a workload that has
changed out from under it (§4). If it earns nothing on either target it should
be deleted rather than made target-aware — but that is a separate decision, and
it needs an x86-64 arm to make it safely.
Unmeasured, plainly: x86-64 (arm64 host only); the six refusal sites whose
objects are identical were not timed (they cannot differ); nothing was measured
under
--profile dist(--releasewas, for15_mandelbrot); no wall-clocknumber is quoted anywhere.
proggeramlug commentedon Aug 14, 2026
Closing as refuted by measurement, per the comment above.
The requested per-target term should not be built: the AArch64 benefit it was
meant to preserve measures 0.0 ± 0.2% on all six suite workloads where the
refusal changes emitted bytes (including
15_mandelbrotitself, and confirmedon
--releaseartifacts at 0.00%), and this issue's own x86-64 measurement wasmedian B/A = 1.0000. On current
mainthe conditional-compare fusion is in thei32 arm, not the double arm — #7721's default-ON GC back-edge polls put an
ldr/cbz/blbetween the compare and the branch, and the cost model incollectors/repsel_benefit.rsis stale in consequence.The residual question — whether the refusal should exist at all now — needs an
x86-64 arm to answer safely and is not this issue. The workload change that
made the model stale is filed as #8105.