perf: rewrite square_redc to unroll and use single-word carries - #616
Draft
prestwich wants to merge 11 commits into
Draft
perf: rewrite square_redc to unroll and use single-word carries#616prestwich wants to merge 11 commits into
prestwich wants to merge 11 commits into
Conversation
This was referenced Jul 10, 2026
The previous implementation lost to mul_redc(a, a) at every width on both aarch64 and x86-64 (2.05x slower at 256 bits on Apple M-series, 1.4x on Zen 2) despite doing fewer multiplies. Two structural problems defeated LLVM: the doubled-cross-term inner loop had a data-dependent (triangular) trip count, and its 2*a[i]*a[j] accumulation needed a 65-bit (u64, bool) carry that cannot live in the flags register. The result was rolled, branchy code with a serialized carry chain, while mul_redc's CIOS loop fully unrolls into straight-line code with two independent carry chains. The new implementation accumulates the undoubled upper-triangle cross products (constant-trip guarded loops, plain u64 carries), doubles the whole product and adds the diagonal squares in one flag-friendly pass, then runs a CIOS-style sliding-window Montgomery reduction with constant indices. Every loop fully unrolls through N = 8; above that the loops re-roll, so it falls back to mul_redc(a, a), which measures faster there (up to 39% at 2048 bits on M-series). Measured with fields/*/square_redc and chained/square_redc/*: - Apple M-series: 25.2 -> 12.3 ns at 256 bits, 49.4 -> 21.6 at 384, 72.1 -> 37.9 at 512; now at or below mul_redc at every width. - AMD Zen 2: 32.7 -> 23.5 ns at 256 bits, 61 -> 42.0 at 384, 110 -> 79.5 at 512. - Instruction counts (callgrind, x86-64): 16%/23%/11% below mul_redc(a, a) at 4/6/8 limbs, so CodSpeed instrumentation also improves. 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
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
prestwich
force-pushed
the
prestwich/square-redc
branch
from
July 10, 2026 11:52
baa6986 to
1fe181f
Compare
Directed tests with all-ones-shaped moduli and a near m-1 drive the (u64, bool) carry sums to their exact 2^65 - 1 ceiling, which uniform-random proptests cannot reach. Extend the equivalence proptest to 320/448 bits (the odd specialized widths 5 and 7) and 576 bits (the first mul_redc fallback width). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE
Merging this PR will not alter performance
Performance Changes
Comparing |
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
square_redcwas slower thanmul_redc(a, a)at every width on every architecture tested — 2.05× at 256 bits on Apple M-series, ~1.4× on AMD Zen 2 — when squaring should cost at most a multiply. This PR rewrites it so that it wins (or ties) everywhere, and adds the real-world benchmark suite that found and verifies the problem.Root cause
The old implementation did fewer multiplies on paper but compiled to much worse code, for two structural reasons:
for j in (i + 1)..N) had a data-dependent, triangular trip count, which LLVM refuses to fully unroll. At N = 4,mul_redccompiles to ~400 straight-line branch-free instructions, while the oldsquare_redccompiled to a ~105-instruction rolled nested loop — with the loop-invariantmodulus[N - 1]test re-executed on every outer iteration.2·a[i]·a[j] + limb + carryoverflows 128 bits, forcing a 65-bit(u64, bool)carry. A two-word carry cannot live in the flags register, so it was materialized and re-added on every iteration, serializing the whole cross-term chain.New algorithm
u64carries; the triangular structure is expressed as guards inside constant-trip loops so everything unrolls and the guards fold away.mul_redc(a, a)— which measures up to 39% faster there (2048 bits, M-series).Benchmarks added
Four new bench groups under
ruint-benchcapture real-world workloads that the existing random-modulus benches miss, and serve as the regression harness for this fix:fields/{field}/{op}— fixed-modulus kernels over the 11 moduli that dominate real usage (secp256k1 p/n, P-256 p/n, BN254 p/r, BLS12-381 p/r, BLS12-377 p/r, CSIDH-512), includingmul_redc,square_redc, and real fixed exponents (Fermat inversion, sqrt decompression, 65537). Montgomery contexts are self-checked at registration time.chained/{op}/{tag}— 64-deep serial dependency chains measuring critical-path latency, which throughput benches and instruction counting are both blind to. Includesconst_eq/const_is_zerovariants at 256 and 512 bits — the only regression guard for perf: unify const_eq/const_is_zero with their runtime equivalents #615's latency-shaped fold fix (cherry-picked here from perf: scalar-friendly const_eq fold for x86-64 dependent-use latency #617 after it was folded into perf: unify const_eq/const_is_zero with their runtime equivalents #615).curves/{curve}/{kernel}— composite kernels (Jacobian double/add with known-answer checks against 2G/3G, Fp2 mul, 16-bit scalar-mul segment, ECDSA verify prologue, 64-element batch inversion).dist/{op}/{distribution}/256— comparisons under realistic input distributions (equal/differ-late, 160-bit addresses, small counters) where branchy vs branchless implementations rank differently. Includesconst_eqvariants guarding perf: unify const_eq/const_is_zero with their runtime equivalents #615's standalone-throughput tradeoff.Measurements
Wall clock via
fields/*/square_redc(was → now,mul_redcfor reference):Dependent-chain latency (
chained/square_redc/*) improves similarly (e.g. 384-bit on M-series: 1.56 µs vs mul's 2.07 µs per 64-op chain).Work counts: exact dynamic instruction counts (callgrind, x86-64) are strictly below both the old code and
mul_redc(a, a)at 4/6/8 limbs (e.g. 273 vs 327 vs 422 instructions per op at 256 bits), so CodSpeed's instrumentation metric improves as well. Crossover sweep (synthetic moduli at 5, 7, 16, 32 limbs) confirms the N ≤ 8 specialization threshold on both architectures.Correctness: existing proptest suites (
algorithms::mul_redc::test_square_redc,modular::tests::test_square_redc) cover 16–4096 bits against the division-based reference, now extended to 320/448 bits (the odd specialized widths N = 5, 7) and 576 bits (the first fallback width). A directed carry-saturation test squaresa = m - 1under all-ones-shaped moduli at every N ≤ 9, driving the(u64, bool)carry sums to their exact2^65 - 1ceiling — the corner uniform-random proptests cannot reach and the classic hiding spot for a lost squaring carry. Green on stable and with thenightlyfeature. All bench groups run registration-time self-checks (Montgomery round-trips, secp256k1 known-answer points, algebraic identities) so a wrong constant panics instead of benchmarking garbage.Stacked on #615 (
prestwich/const-eq-dw): this PR is based on and targets that branch, notmain, and is rebased onto its current tip (the XOR/OR-foldconst_eqabsorbed from #617, plus its cutoff-boundary tests). It must not merge until #615 lands, then be retargeted tomain. Thesquare_redcwork itself is functionally independent (no file overlap) and could be rebased ontomaindirectly if #615 stalls — but thechained/const_eqanddist/const_eqbench guards in this PR exist to protect #615's fix, so the two should land together.🤖 Generated with Claude Code
https://claude.ai/code/session_01UJ4S2Ns5cyvmkTFJ7mgrWE