Optimize MultiplyMod: reuse div remainder in Knuth reduction (-27% to -37%) - #101
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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
MultiplyModdispatch to classify modulus size with scalar limb tests, route 64-bit moduli directly, and avoid a post-multiply vectorIsZerocheck 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 viaa * band high viaBmi2.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. |
kamilchodola
approved these changes
Jul 30, 2026
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
Optimises
MultiplyModend 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.DivRemreturns the remainder anyway, and the Knuth correction loop maintains the exact identityrhat == (u_top:u_next) - qhat*d_top(eachqhat--addsd_topback intorhat). The top-limb product in the mul-subtract is therefore redundant: the new top limb is justrhat - borrow, and overshoot detection isborrow > 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 previousEstimateQhat+ full mul-subtract path. Applied toRemainder512By256Bits,Remainder512By192Bits, andKnuthStep.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 vectorIsZeroload 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:
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
BigInteger: 0 failures.FullyQualifiedName~MultiplyModunit suite run against the branch head.