Skip to content

Optimize MultiplyMod: reuse div remainder in Knuth reduction (-27% to -37%) - #101

Merged
benaadams merged 6 commits into
mainfrom
mulmod-improves
Jul 30, 2026
Merged

Optimize MultiplyMod: reuse div remainder in Knuth reduction (-27% to -37%)#101
benaadams merged 6 commits into
mainfrom
mulmod-improves

Conversation

@benaadams

Copy link
Copy Markdown
Member

Summary

Optimises MultiplyMod end to end: tighter JIT output for the Knuth reductions, a cheaper 256x256->512 multiply, and an algebraic shortcut that removes one 64x64 product per quotient digit along with the per-digit call.

Key change: reuse the div remainder

X86Base.X64.DivRem returns the remainder anyway, and the Knuth correction loop maintains the exact identity rhat == (u_top:u_next) - qhat*d_top (each qhat-- adds d_top back into rhat). The top-limb product in the mul-subtract is therefore redundant: the new top limb is just rhat - borrow, and overshoot detection is borrow > rhat. With one fewer mulx chain, the div + correction now inline into the reduction loops without register-pressure spills, removing the per-digit call and its stack-arg stores; the window's top-limb store also turns out to be dead (never read by later iterations or denormalisation). Rare cases (saturated digit, rhat overflow during correction) fall back to the previous EstimateQhat + full mul-subtract path. Applied to Remainder512By256Bits, Remainder512By192Bits, and KnuthStep.

Entry dispatch is also restructured: the modulus size is classified first with scalar tests (a modulus >= 2^128 cannot be 0 or 1, so the wide path skips the throw/one gates), 64-bit moduli route straight to MulModBy64Bits, and the product's high half is tested with a scalar or-chain - a vector IsZero load there spans the four scalar stores just written by the multiply and defeats store-forwarding.

Benchmarks vs main

Random full-width operands; medians of 3 alternating A/B rounds, each best-of-12 in-process rounds of 2M ops; x64 (Zen4-class), .NET 10:

benchmark main branch delta
MulMod, 256-bit modulus (msb set) 72.6 ns 53.3 ns -27%
MulMod, 256-bit modulus (random top limb) 85.5 ns 56.9 ns -33%
MulMod, 192-bit modulus 84.4 ns 58.3 ns -31%
MulMod, 128-bit modulus 95.4 ns 60.3 ns -37%
MulMod, 64-bit modulus ~25 ns ~25 ns neutral
MulMod latency (chained results), 256-bit modulus 94.3 ns 76.3 ns -19%
ExpMod, 256-bit modulus, random 256-bit exponent 36.7 us 23.6 us -36%

The shorter-modulus paths gain the most because their cost is quotient-digit-bound (a 512-bit product over a 128-bit modulus needs 7 serial quotient digits vs 5 for a 256-bit modulus), and the per-digit saving applies to every digit.

Verification

  • 20 million randomized MulMod cases (edge-limb biased: 0, 1, 2^63, 2^64-1, etc.; modulus widths rotating 64/128/192/256-bit) cross-checked against BigInteger: 0 failures.
  • Latency-chain outputs are bit-identical between main and branch (matching xor sinks over 2M chained operations).
  • FullyQualifiedName~MultiplyMod unit suite run against the branch head.

Replace UInt256 constructor calls with direct Unsafe.AsRef field writes
to avoid Vector256.Create overhead that prevents JIT inlining. Rewrite
case-1 division loop to use descending ref pointer instead of indexed
access, avoiding lea recomputation caused by div clobbering rax.
Consolidate 4 lzcnt call sites into one via dTop local variable.

Code size: 1553 → 983 bytes, stack frame: 408 → 152 bytes.
Split Remainder512By256Bits into specialized 4-limb (d.u3 != 0) and
3-limb (Remainder512By192Bits) paths, removing dead dLen dispatch.
Added NoInlining to URemKnuth4 to prevent JIT inlining bloat.
Assembly: 983 → 666 bytes across iterations 1-3.
… size)

EstimateQhat changed from [AggressiveInlining] to [NoInlining]: the
hardware div instruction dominates its latency (~35-90 cycles on Zen4),
making ~5 cycle call overhead negligible, while de-inlining saves ~297
bytes and reduces register pressure conflicts with SubMulTo4's mulx
chains. URemKnuth2/3/4 inlined as static local functions in their
respective Remainder methods, removing the separate method indirection.

Remainder512By256Bits: 1409 -> 1112 bytes.
Compute the low product separately and use BMI2's high-only multiply overload so RyuJIT can keep both halves in registers instead of spilling through a pointer argument.

Pass the large multiplication operands by readonly reference to remove the caller-side 32-byte copies. Together these changes reduce stack usage and code size while improving MultiplyMod throughput.
The Knuth correction loop maintains the exact identity
rhat == (u_top:u_next) - qhat*d_top (each qhat-- adds d_top back into
rhat), so the top-limb product in the mul-subtract is redundant: the
new top limb is rhat - borrow and overshoot detection is borrow > rhat.

Inline the div + correction into the reduction loops (register pressure
now fits with one fewer mulx chain), removing the per-digit call and
its stack-arg stores; rare saturate/overflow cases fall back to the
EstimateQhat + full SubMul path. The window top-limb store is dead
(never read by later iterations or denormalisation) and is removed.
Applied to Remainder512By256Bits, Remainder512By192Bits and KnuthStep.

Also dispatch MultiplyMod on modulus size first (a modulus >= 2^128
cannot be 0 or 1, so the wide path skips the throw/one gates), move the
<=128-bit handling into a NoInlining helper, and test the product high
half with a scalar or-chain: the vector IsZero load spanned the four
scalar stores just written by the multiply and defeated store
forwarding.

MulMod throughput: 256-bit modulus 59.6 -> 53.1 ns (-11%), 192-bit
71.4 -> 62.3 ns (-13%), 256-bit latency chain ~-7%; verified against
20M randomized BigInteger cross-checks (all modulus widths).
The NoInlining <=128-bit helper added a call layer in front of
MulModBy64Bits, costing ~2ns on the 64-bit modulus path. Route it
directly from MultiplyMod (only the divide-by-zero throw is needed
there; MulModBy64Bits handles mod == 1 and trivial operands), and
narrow the helper to 65..128-bit moduli, which cannot be 0 or 1.
Copilot AI review requested due to automatic review settings July 30, 2026 04:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes UInt256.MultiplyMod and its underlying Knuth-style remainder reductions to reduce instruction count and register pressure in the hot modular-multiplication path, improving throughput for 128/192/256-bit moduli.

Changes:

  • Restructures MultiplyMod dispatch to classify modulus size with scalar limb tests, route 64-bit moduli directly, and avoid a post-multiply vector IsZero check on the high half.
  • Reworks Knuth reduction steps for 192-bit and 256-bit moduli to reuse DivRem’s remainder (rhat) to eliminate redundant top-limb products and dead stores in the sliding window.
  • Updates Multiply64’s BMI2 path to compute low via a * b and high via Bmi2.X64.MultiplyNoFlags(a, b) to help the JIT keep results in registers.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/Nethermind.Int256/UInt256.DivideMod.cs Reworks MultiplyMod dispatch and remainder-by-192/256 routines, plus Knuth step logic to reuse DivRem remainder and reduce mul-sub work.
src/Nethermind.Int256/UInt256.cs Adjusts Multiply64 BMI2 implementation to favor register allocation and reduce spills.

@benaadams
benaadams merged commit 3617143 into main Jul 30, 2026
12 checks passed
@benaadams
benaadams deleted the mulmod-improves branch July 30, 2026 06:41
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.

3 participants