perf: scalar-friendly const_eq fold for x86-64 dependent-use latency - #617
Closed
prestwich wants to merge 11 commits into
Closed
perf: scalar-friendly const_eq fold for x86-64 dependent-use latency#617prestwich wants to merge 11 commits into
prestwich wants to merge 11 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
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.
Member
Author
|
Folded into #615: the |
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 forconst_eqat 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:eq/256(derived==)const_eq/256(#615 dw form)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 anadcchain), 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 theptest → setevector→scalar domain crossing, is the 8.45 ns/link. This is the x86-64 sibling of the aarch64 NEON/fmovpathology #615 fixed, but worse (6.5× vs 4×).The fix
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 sameccmpflag 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:pxor/ptest+ STLF stallxor/or, no memory round-trippxor/ptestptestat 8+ limbs; scalar at 4 limbsccmpflag chainccmpflag chain (identical)ccmpcmeq/umaxvis_zero/const_is_zeroinherit the fix through the existingconst_eq(&ZERO)routing; the> 16limb paths (counting loop, OR-fold,bcmpagainstZERO) are untouched.Measurements
Criterion,
chained/*reported per link (reported time ÷ 64). Zen 2 = Threadripper 3990X pinned withtaskset; M-series = Apple Silicon.Latency (the target):
chained/const_eq/256chained/const_eq/512chained/is_zero/256chained/const_is_zero/256is_zero/384,/512standaloneGuards (must not move, and didn't):
dist/eq/{equal,differ_low,differ_high,mixed50}/256dist/is_zero/mixed10/256chained/eq/*(derived==, untouched)eq/is_zero/const_*at 64–192, 4096 bitsKnown tradeoff: standalone
const_eq/256throughput 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/512standalone 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 theEQ_FOLD_MAX_LIMBScomment.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 breaksStructuralPartialEq, i.e.Uintconstants inmatchpatterns), so itsbcmp-shaped lowering is not ours to change. Incidentally, the benches show how fragile that lowering is: the untouchedeq/192flipped from 1.5 ns (inlined) to 7.8 ns (out-of-linebcmpcall) purely from unrelated code motion in the bench binary, andeq/384/eq/512already sit at ~10.5 ns standalone on Linux/Zen 2. Latency-sensitive code should preferconst_eq/is_zero.Commits
bench:— the real-world benchmark suite (fields/chained/curves/dist groups) this investigation was run withperf: …(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 aboveperf: reformulate const_eq fold to stay scalar under dependent use— the fixTesting
cargo test --lib: 147 passed (includestest_const_eq_ctfecompile-time evaluation, proptest equivalence ofconst_eq/const_is_zerovs==/is_zeroacross allSIZES, and single-limb-difference detection)cargo clippy --all-targets: clean;cargo +nightly fmt --check: clean🤖 Generated with Claude Code