Skip to content

perf: scalar-friendly const_eq fold for x86-64 dependent-use latency - #617

Closed
prestwich wants to merge 11 commits into
mainfrom
prestwich/x86-eq-latency
Closed

perf: scalar-friendly const_eq fold for x86-64 dependent-use latency#617
prestwich wants to merge 11 commits into
mainfrom
prestwich/x86-eq-latency

Conversation

@prestwich

Copy link
Copy Markdown
Member

Summary

Reformulates const_eq's ≤16-limb path from u128 double-word chunks to a limb-wise XOR/OR fold, fixing a large dependent-use latency penalty on x86-64 while keeping the aarch64 codegen from #615 bit-identical. Measured on Zen 2 (Threadripper 3990X): −44% per-link latency for const_eq at 256 bits (3.71 → 2.09 ns), −52% at 512 bits, with all distribution and throughput guards flat. On Apple M-series the same change is −18.5% at 256 bits.

Stacks on #615 (its two commits are included here) and on the real-world benchmark suite this investigation used; draft until both land or this gets rebased onto them.

The problem

The chained/* latency benches (64 serially-dependent op applications, so the CPU cannot overlap iterations) showed that consuming an equality result costs far more than producing one on Zen 2:

bench (Zen 2) standalone chained, per link
eq/256 (derived ==) 1.30 ns 8.45 ns
const_eq/256 (#615 dw form) 0.85 ns 3.71 ns

Root cause, from the generated asm: both forms lower to an SSE sequence (movdqu×4, pxor×2, por, ptest, sete). In a dependency chain the consumer updates the limbs with scalar code (4×8-byte stores from an adc chain), and the next compare reloads them as 16-byte vector loads. A 16-byte load spanning two 8-byte stores fails store-to-load forwarding and stalls until the stores drain — that, plus the ptest → sete vector→scalar domain crossing, is the 8.45 ns/link. This is the x86-64 sibling of the aarch64 NEON/fmov pathology #615 fixed, but worse (6.5× vs 4×).

The fix

// LIMBS <= EQ_FOLD_MAX_LIMBS (16):
let mut acc = 0;
const_range_for!(i in 0..LIMBS => {
    acc |= a[i] ^ b[i];
});
acc == 0

No cfg(target_arch) needed — LLVM canonicalizes the &-chain of limb equalities, the u128 double-word chain, and this fold to identical IR on aarch64 (all lower to the same ccmp flag chain #615 chose, verified by symbol-aliased asm), but on x86-64 only the u64 fold escapes re-vectorization when the result feeds a dependency chain:

context dw chunks (#615) limb-wise fold (this PR)
x86-64, dependent use pxor/ptest + STLF stall pure-GPR xor/or, no memory round-trip
x86-64, standalone pxor/ptest ptest at 8+ limbs; scalar at 4 limbs
aarch64, dependent use ccmp flag chain ccmp flag chain (identical)
aarch64, standalone ccmp NEON cmeq/umaxv

is_zero/const_is_zero inherit the fix through the existing const_eq(&ZERO) routing; the > 16 limb paths (counting loop, OR-fold, bcmp against ZERO) are untouched.

Measurements

Criterion, chained/* reported per link (reported time ÷ 64). Zen 2 = Threadripper 3990X pinned with taskset; M-series = Apple Silicon.

Latency (the target):

bench Zen 2 before → after M-series before → after
chained/const_eq/256 3.71 → 2.09 ns (−44%) 2.26 → 1.83 ns (−18.5%)
chained/const_eq/512 ~6.5 (unstable) → 3.40 ns (−52%) 3.33 → 2.96 ns (−11%)
chained/is_zero/256 1.75 → 1.72 ns (−3.5%) 1.87 → 1.88 ns (flat)
chained/const_is_zero/256 1.73 → 1.72 ns 1.83 → 1.89 ns
is_zero/384, /512 standalone −18% … −23% −3.5%

Guards (must not move, and didn't):

bench Zen 2 M-series
dist/eq/{equal,differ_low,differ_high,mixed50}/256 −2% flat
dist/is_zero/mixed10/256 flat flat
chained/eq/* (derived ==, untouched) +2% (noise) flat
eq/is_zero/const_* at 64–192, 4096 bits noise band (±7%) noise band

Known tradeoff: standalone const_eq/256 throughput on Zen 2 goes 0.85 → 1.12 ns (+34%; dist/const_eq/* show the same, flat across distributions since both forms are branchless). At exactly 4 limbs x86-64 keeps even the standalone fold scalar; at 8+ limbs it re-vectorizes (const_eq/512 standalone is unchanged). 1.12 ns still beats the derived == (1.32 ns) on the same inputs, and an equality result in real code is almost always consumed dependently — which is where the 44% win is — so this looks like the right trade. Documented in the EQ_FOLD_MAX_LIMBS comment.

What this can't fix

chained/eq/256 — the derived == itself — stays at ~8.5 ns/link on Zen 2. #[derive(PartialEq)] must remain (removing it breaks StructuralPartialEq, i.e. Uint constants in match patterns), so its bcmp-shaped lowering is not ours to change. Incidentally, the benches show how fragile that lowering is: the untouched eq/192 flipped from 1.5 ns (inlined) to 7.8 ns (out-of-line bcmp call) purely from unrelated code motion in the bench binary, and eq/384/eq/512 already sit at ~10.5 ns standalone on Linux/Zen 2. Latency-sensitive code should prefer const_eq/is_zero.

Commits

  • 7 × bench: — the real-world benchmark suite (fields/chained/curves/dist groups) this investigation was run with
  • 2 × perf: … (cherry-picks of perf: unify const_eq/const_is_zero with their runtime equivalents #615)
  • bench: add const_eq/const_is_zero chained + dist variants, 512-bit widths — new guards used above
  • perf: reformulate const_eq fold to stay scalar under dependent use — the fix

Testing

  • cargo test --lib: 147 passed (includes test_const_eq_ctfe compile-time evaluation, proptest equivalence of const_eq/const_is_zero vs ==/is_zero across all SIZES, and single-limb-difference detection)
  • cargo clippy --all-targets: clean; cargo +nightly fmt --check: clean
  • Asm inspected on both targets (x86-64 generic and aarch64) for standalone and chained contexts at 4, 8, and 16 limbs

🤖 Generated with Claude Code

prestwich and others added 11 commits July 10, 2026 00:38
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Adds const_eq_dw/const_is_zero_dw alongside the existing const_eq/
const_is_zero so both can be benchmarked side by side, plus criterion
entries for all four variants (and eq/is_zero baselines).

const_eq's limb-counting loop gets auto-vectorized by LLVM into a SIMD
horizontal reduction (NEON cmeq+umaxv+fmov on aarch64), which costs ~4x
dependent-use latency vs the derived PartialEq. Comparing u128
double-word chunks with &-accumulation instead lowers to the same code
as the derived == (ccmp chain on aarch64, pxor/ptest on x86-64) while
remaining const-evaluable.

Apple M-series measurements:

  dependent-chain latency, U256 (scratchpad microbench):
    eq                 1.65 ns
    const_eq           5.95 ns
    const_eq_dw        1.68 ns  <- matches derived ==
    is_zero            1.30 ns
    const_is_zero      5.95 ns
    const_is_zero_dw   1.25 ns  <- matches is_zero

  criterion throughput (random inputs), old const_eq -> const_eq_dw:
    eq/192        613 ps -> 460 ps  (derived ==: 821 ps)
    eq/256        598 ps -> 652 ps  (derived ==: 815 ps)
    eq/512       1.20 ns -> 1.23 ns (derived ==: 1.55 ns)
    eq/4096      10.1 ns -> 18.0 ns (derived ==: 15.1 ns)
    is_zero/256   575 ps -> 387 ps  (is_zero:     421 ps)
    is_zero/4096 10.8 ns ->  5.4 ns (is_zero:     1.9 ns)

Known tradeoff: at >=512 bits on x86-64 the dw form emits a mixed
SIMD/GPR pattern (pextrq/orq) instead of const_eq's pure pxor/por/ptest,
and at 4096 bits the csel chain loses to both the old vectorized loop
and memcmp. If adopted, consider keeping the counting loop for large
LIMBS or capping the dw path at LIMBS <= 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Replaces the prototype const_eq_dw/const_is_zero_dw with final hybrid
implementations, making const_eq equivalent to the derived == and
const_is_zero equivalent to is_zero at every width up to 16 limbs:

- const_eq: compares u128 double-word chunks for LIMBS <= 16 (lowers to
  the same code as derived PartialEq: ccmp chain on aarch64, pxor/ptest
  on x86-64), falls back to the limb-counting loop above (the chunked
  serial chain hits a cliff at 24 limbs on Zen 2, and loses to
  auto-vectorization by 64 limbs on Apple M-series).
- const_is_zero: const_eq(&ZERO) for LIMBS <= 16; plain OR-fold above
  (auto-vectorizes to a compare-free vector reduction).
- is_zero: delegates to const_is_zero for LIMBS <= 16; keeps
  `*self == Self::ZERO` above, where LLVM's bcmp against the zeros
  constant beats every const-compatible form (1.6 ns vs 10 ns at 4096
  bits) and is unreachable from const fn.

The "might perform worse" caveats are gone except a scoped note on
const_is_zero for LIMBS > 16. PartialEq remains derived so Uint keeps
StructuralPartialEq (const pattern matching).

Measurements (Apple M4-class / AMD Zen 2 Threadripper 3990X):

  U256 dependent-chain latency:
    const_eq       5.95 -> 1.68 ns (M),  4.03 -> 4.09 ns (Zen 2, was ok)
    const_is_zero  5.95 -> 1.25 ns (M),  3.62 -> 2.89 ns (Zen 2)
    both now match ==/is_zero exactly on both machines

  criterion throughput vs old const_eq (M-series):
    const_eq/192   613 -> 478 ps   const_eq/256   598 -> 619 ps
    const_eq/512  1.20 -> 1.29 ns  const_eq/4096 10.1 -> 9.5 ns
    const_is_zero/256  575 -> 384 ps (now beats old is_zero's 421 ps)

  Zen 2 (const_eq, unequal inputs): U512 1.69, U768 2.19, U1024 3.00 ns
  vs derived == 2.51/2.42/2.71; U1536+ unchanged from old counting form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
The double-word AND-fold lowers to pxor/ptest on x86-64 even when the
result feeds a scalar dependency chain: each link's adc-updated limbs
are stored 8 bytes at a time and reloaded as 16-byte vector loads,
defeating store-to-load forwarding (~8.5 ns/link on Zen 2 vs ~1.3
standalone). A limb-wise XOR/OR fold canonicalizes to the identical
ccmp flag chain on aarch64, but on x86-64 LLVM lowers it by context:
vectorized ptest standalone, pure-GPR scalar under dependent use.
@prestwich

Copy link
Copy Markdown
Member Author

Folded into #615: the const_eq XOR/OR-fold reformulation was cherry-picked onto prestwich/const-eq-dw (47de7fd), and #615's description now carries the x86-64 STLF analysis, measurements, and known tradeoffs from this PR. Note: the bench-guard commit here (6b7bd7d, chained/const_eq/dist/const_eq variants) depends on the real-world bench suite files and is not part of #615 — it should be cherry-picked into #616 (or wherever the bench suite lands) so the wall-clock latency guards survive.

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.

1 participant