Skip to content

repsel: #7132's profitability refusal over-fires on x86-64 (fccmp-fusion term is AArch64-specific) — cost model needs a per-target term #7146

Closed

Description

@proggeramlug

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.

Activity

  1. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    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.

  2. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions