Skip to content

Answer Mod without computing the quotient - #115

Merged
benaadams merged 10 commits into
mainfrom
perf/mod-remainder-kernels
Sep 3, 2026
Merged

Answer Mod without computing the quotient#115
benaadams merged 10 commits into
mainfrom
perf/mod-remainder-kernels

Conversation

@benaadams

@benaadams benaadams commented Sep 3, 2026

Copy link
Copy Markdown
Member

Results

x % y by operand shape, against main. Lower PR/main is better: 0.62 means 38% less time.
x64 is a Ryzen 9 9950X (Zen 5, AVX-512) under Tier-1 with dynamic PGO, median of three runs;
ARM64 is the ubuntu-24.04-arm runner (Neoverse N2) through benchmark.yml, and
ModShapesBench in this PR is what produces that column. The shape is
(dividend limbs) x (divisor limbs), which is what decides how many Knuth digits the division
needs.

Shape x64 main -> PR (ns/op) x64 PR/main ARM64 main -> PR (ns/op) ARM64 PR/main
2x2 10.34 -> 6.38 0.62 34.82 -> 17.11 0.49
3x3 15.28 -> 8.14 0.53 37.92 -> 22.37 0.59
3x2 10.84 -> 8.14 0.76 34.95 -> 22.44 0.64
4x4 15.69 -> 11.02 0.70 33.09 -> 27.50 0.83
4x3 15.98 -> 12.93 0.82 36.60 -> 29.96 0.82
4x2 12.49 -> 11.60 0.93 34.60 -> 26.84 0.78
divisor >= 2^255 3.58 -> 2.47 0.74 6.92 -> 6.06 0.88
dividend below divisor 1.23 -> 0.98 0.80 3.70 -> 2.79 0.75
power-of-two divisor 2.65 -> 2.36 0.93 5.30 -> 4.85 0.92
dividend == divisor 1.46 -> 1.18 0.80
one-limb operands 1.73 -> 1.60 0.92 4.05 -> 3.54 0.87
benchmark key/value matrix 3.97 -> 3.20 0.80
mixed corpus 6.60 -> 5.33 0.81 23.53 -> 18.01 0.77
signed mixed corpus 30.21 -> 23.24 0.77
4x1 9.34 -> 9.21 0.98 23.14 -> 23.27 1.01
2x1 4.95 -> 4.69 0.95 18.78 -> 18.27 0.97
3x1 7.19 -> 7.27 1.01
geomean 0.823 0.778

Geomean over the twenty shapes, by ISA configuration:

Configuration PR/main
x64 AVX-512 0.823
x64 AVX2 only 0.830
x64, DOTNET_EnableHWIntrinsic=0 0.786
ARM64 Neoverse N2 0.778

Everything that calls Mod moves with it, and its neighbours do not:

Operation PR/main (x64)
Int256.Mod 0.740
SubtractMod 0.832
AddMod 0.843
MultiplyMod 0.976
Divide 0.998
Int256.Divide 1.011

A single-limb divisor is four dependent hardware divides with nothing around them to remove, so
those shapes sit at parity. 3x1 is 1% slower on x64 (7.19 -> 7.27 ns), the only shape anywhere
above the noise band on the wrong side.

What is in it

Mod used to be a thin wrapper over the divide. It tested the divisor for zero and one with
two 256-bit vector compares, asked CompareTo for a three-way answer, then called ModFull,
which dispatched on the divisor width to find a power of two, called DivideImpl, which
dispatched on the divisor width a second time, and finally asked for a full division and threw
the quotient away.

  • The quotient was never wanted. Knuth D needs the digit to produce the remainder, but
    nothing downstream reads it, so it now stays in a register instead of being assembled into a
    UInt256 and stored. The width dispatch happens once.
  • The kernels stay in general registers. Normalising through ShiftLeftSmall and
    ShiftRightSmall, which take and return a UInt256, meant four 8-byte stores followed by one
    32-byte load, a width store-to-load forwarding does not cover, plus two vmovq crossings for
    the shift count. The 192-bit path did that to build its remainder and the 256-bit path did it
    at both ends, with a spare 32-byte stack-to-stack copy of the normalised divisor in between.
  • Every digit multiplied by the whole divisor, including the limb the divide had just
    accounted for. The 2-by-1 divide leaves (u[j+n]:u[j+n-1]) - qhat*v[n-1] in rhat by
    construction, so the window still to reduce is (rhat:u[j+n-2]..u[j]) and only qhat times
    the low limbs comes off it. D3 was already carrying qhat*v[n-2], which is that subtrahend's
    top limb. A 192-bit divisor went from four 64x64 products a digit to two, a 256-bit divisor
    from five to three, and the subtract lost a limb. It is the collapse the 128-bit path already
    used, one limb up.
  • The digit count is dividend limbs minus divisor limbs, but the kernels always ran their
    maximum. A dividend that stops short of the top limb leaves the leading window below the
    normalised divisor, so that digit is a divide that can only produce zero. This is what rescues
    the same-width shapes, and they are the common ones.
  • Zero and one are one test on the divisor: (y0 >> 1) | y1 | y2 | y3 is zero for both and
    nothing else. x == 0 needs no test at all, because y >= 2 by then puts it in the x < y
    arm. The compare jumps straight to its answer rather than materialising -1/0/1 and re-testing
    it, and only pays a second compare on the limb where the two values are equal.
  • A divisor at or above 2^255 makes the quotient 1, so the remainder is x - y. That is now
    the general subtract, which loads both operands as vectors and stores the result as one,
    without touching a general register - worth more than the call it costs, but only where there
    is a vector unit, so the choice is on Vector128.IsHardwareAccelerated.
  • MulMod by a single-limb modulus normalised for a divide that needs none. Hardware div
    only requires the running remainder to stay below the divisor, which it does from zero onward.
    Its final reduction also needed one divide rather than two: both factors are already below the
    modulus, so the product's upper limb is too.
  • Int256.Mod took absolute values into two locals first, copying both operands even when
    neither was negative.

Not taken

  • Folding the width dispatch into Mod itself measured 0.887 on x64 and 1.043 on ARM64, the
    one place the platforms disagreed. On ARM it bought the wide shapes nothing - 0.822 against
    0.823 at 4x4, the same at every width - while giving every call the merged body's prologue:
    three saved registers and a 64-byte frame became six and 96 bytes. The calls that answer from
    the entry compare paid all of it: one-limb x % y went 3.52 -> 4.66 ns and a dividend below
    its divisor 2.79 -> 3.93. Reverting it costs the x64 geomean 0.746 -> 0.823 and leaves nothing
    slower than main anywhere. Worth revisiting only with a way to keep Mod's prologue small.
  • A branchless D3 correction measured 1.14, and 1.73 on the 128-bit path. Instrumenting the
    kernels showed the test fires about 1% of the time, so the branch predicts perfectly and costs
    nothing, while the mask puts about five cycles on the critical path of every digit.
  • The (a >> 1) >> (63 - shift) funnel lost to a plain shift == 0 branch by 1.5% on both
    192-bit shapes, the opposite of the signed shift's result: lzcnt resolves the branch long
    before the divides, so a mispredict has nothing to discard.
  • Masking shift counts with & 63 does not remove the JIT's own and reg, 63 before shlx/shrx.

Validation

  • 587,686 tests pass in the default, no-AVX-512, no-AVX2 and no-intrinsics configurations, and
    587,683 with -p:EnableZkEvm=true.
  • 2,000,000 adversarial dividend/divisor pairs agree with BigInteger: every bit boundary, every
    normalising shift, and quotients placed to fire the add-back. Counters on the rare paths
    confirm the corpus reaches them - the add-back 806 times, rhat overflowing a limb 9,613, the
    saturating quotient digit 6 - so the borrow & ~rcarry decision that guards the add-back is
    covered rather than assumed.
  • git diff --check clean.

x % y opened with y.IsZero, x.IsZero, y.IsOne and then CompareTo. On a machine with
AVX-512 that is three 32-byte loads, two vptest, a vpcmpuq/kortestb pair and the
vzeroupper they drag in, before any arithmetic happens.

Zero and one are one test on the divisor: (y0 >> 1) | y1 | y2 | y3 is zero for both
and for nothing else, and y0 tells them apart on the cold path. x == 0 needs no test
at all, because y >= 2 by then puts it in the x < y arm.

CompareTo cost more than the ordering it was asked for: the JIT compared every limb
twice, once for != and once for <, materialised -1/0/1 and then re-tested that against
-1 and 0. A descending compare that jumps straight to its answer only pays the second
compare on the limb where the two values are equal, and the single-limb dividend check
falls out of the limbs already loaded.

Mod is 190 bytes of code where it was 266. Measured against the same operands on a
Ryzen 9 9950X under Tier-1 with dynamic PGO, both orientations of the A/B run so the
slot bias cancels: x < y 0.82, x == y 0.81, one-limb 0.86, power-of-two divisors
0.92-0.94, the benchmark key/value matrix 0.96, a mixed corpus 0.97, wide divisors
0.98-1.00. Geomean 0.947 over twenty shapes, against an A/A control that reads 0.999.
ModFull dispatched on the divisor width to find a power of two, then called DivideImpl,
which dispatched on the divisor width a second time through another non-inlined call,
and then asked for a full division and threw the quotient away. Mod now dispatches once
and calls a kernel that never forms the quotient at all.

Keeping the kernels in general registers matters more than the quotient does. The old
256- and 192-bit paths normalised and denormalised through ShiftLeftSmall and
ShiftRightSmall, which take and return a UInt256: the 192-bit path assembled its
remainder with four 8-byte stores and then read it back with one 32-byte load, a width
store-to-load forwarding does not cover, and the 256-bit path did the same at both ends
plus a spare 32-byte stack-to-stack copy of the normalised divisor and four vmovq
crossings for the shift counts. The kernels do the funnel shifts in general registers
and store the result once.

A 128-bit divisor needs no multi-limb subtract at all: the 2-by-1 divide already leaves
the top of the partial remainder in rhat, so each step is one divide, one product and a
two-limb subtract.

Normalisation keeps its shift == 0 branch. Folding it away with the (a >> 1) >> (63 - s)
funnel, which is what the signed shift uses, costs one extra shift per limb and measured
1.5% worse on both 192-bit shapes even though the branch goes both ways half the time -
lzcnt resolves it long before the divides, so the mispredict has nothing to discard.
The 256-bit kernel needs no branch either way: ModFull answers y >= 2^255 with a
subtract, so its shift is always at least 1.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations so the slot bias cancels,
against an A/A control of 0.999: 4x4 0.79, 3x3 0.90, 2x1 0.92, mixed corpus 0.93,
key/value matrix 0.93, 4x2 and 2x2 0.94, 4x1 0.96, 4x3 0.97. Geomean 0.962; the shapes
that return before reaching a kernel are unchanged. 587,686 tests pass, and 400,000
adversarial dividend/divisor pairs - every bit boundary, every normalising shift, and
quotients placed to fire the add-back - agree with BigInteger.
The 192- and 128-bit kernels always ran their full digit count - two and three - however
few limbs the dividend actually filled. A dividend that stops short of limb 3 leaves the
leading window below the normalised divisor, so that digit is zero: the divide runs, the
correction runs, the subtract takes nothing off, and the remainder comes out unchanged.

Testing x's top limbs before normalising skips those steps. Same-width operands are the
case this rescues, and they are the common one.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations: 3x3 0.66 (16.2 -> 10.7 ns),
2x2 0.66 (10.2 -> 6.7), 3x2 0.93, a mixed corpus 0.91, the benchmark key/value matrix
0.91. Dividends that do fill four limbs pay one predicted test: 4x4 0.998, 4x3 1.004,
4x2 1.001. 587,686 tests pass and 400,000 adversarial pairs agree with BigInteger.
Every digit multiplied the quotient estimate by all of the divisor and subtracted the
result from the whole window, including the top limb the divide had just accounted for.
The 2-by-1 divide leaves (u[j+n]:u[j+n-1]) - qhat*v[n-1] in rhat by construction, so the
window still to reduce is (rhat:u[j+n-2]..u[j]) and only qhat times the low limbs is left
to take off it - the same collapse the 128-bit path already used, one limb up.

The D3 correction was already carrying qhat*v[n-2], which is exactly that subtrahend's top
limb, so keeping it costs nothing and removes a second multiply. A 192-bit divisor goes
from four 64x64 products a digit to two, a 256-bit divisor from five to three, and the
subtract loses a limb.

A correction that pushes rhat past 2^64 supplies the limb the borrow lands in, so the
add-back now fires on a borrow out of a zero one rather than on the borrow alone.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations: 4x3 0.84 (19.5 -> 16.3 ns),
3x3 0.84, 4x4 0.86 (11.9 -> 10.3), a mixed corpus 0.94, the key/value matrix 0.92; the
128- and 64-bit paths are untouched at 1.00. 587,686 tests pass and 400,000 adversarial
pairs agree with BigInteger.
Int256.Mod took absolute values into two locals before doing anything else, so every call
copied both operands - 32 bytes each - and then handed Mod the copies. Neither copy is
needed when both operands are already non-negative, which is the common case, and reading
the caller's own values also keeps Mod off a load that has to wait on the store that just
wrote them.

Splitting the sign handling into a fast arm rather than four arms matters: giving each
sign combination its own call site grew the method from 260 to 538 bytes for no gain,
while the two-arm form lands at 443 and wins.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations, three repeats: 4x4 0.870 /
0.889 / 0.883, 2x2 0.882 / 0.979 / 0.887, y == 1 0.74 / 0.91 / 0.76, one-limb operands
1.01 (unchanged). 587,686 tests pass.
The single-limb kernel skips a divide step whose limb and running remainder are both zero,
but only on the arm that uses hardware div; the reciprocal arm, which is what runs without
X86Base.X64 and on ARM64, ran all four unconditionally. A two-limb dividend paid four
reciprocal divides where the old code paid two.

With DOTNET_EnableHWIntrinsic=0 on a Ryzen 9 9950X, 2x1 goes from 1.222 against main to
0.916. The hardware arm is unchanged (1.006 over the single-limb shapes, inside the noise
of an A/A control).
MulMod against a single-limb modulus normalised it, shifted a copy of each operand up to
match, reduced, and shifted the remainder back - all so the reciprocal 2-by-1 divide would
see a divisor with its top bit set. Hardware div has no such requirement: the running
remainder starts at zero and stays below the divisor, so it goes straight into rdx and the
modulus goes in as it stands. The shifted copy also went through a UInt256, which is four
8-byte stores followed by one 32-byte load.

The final reduction needed one divide, not two. Both factors are already below the modulus,
so their product is below mod^2 and its upper limb below mod, which is exactly the condition
a single div wants.

The reciprocal arm still needs the normalised divisor, and it must be shared: deriving it
inside the reduction helper instead of once per call cost 1.27 with DOTNET_EnableHWIntrinsic=0,
so the two arms are now separate helpers and MulMod passes its own normalisation to the second.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations. Hardware div: 2x1 0.63, 3x1
0.75, 1x1 0.79, 4x1 0.84, mixed corpus 0.95, key/value matrix 0.95, wide moduli unchanged at
0.98-1.00. Without intrinsics: 2x1 0.82, mixed 0.98, and 4x1 at 1.02 - a four-limb dividend
never takes the zero-limb skip, so there it only pays for the tests. 587,686 tests pass in
each of the default, no-AVX-512, no-AVX2 and no-intrinsics configurations.
Mod tested the divisor for zero and one, compared it against the dividend, and then tail-called
ModFull, which read every divisor limb again to pick a kernel and called that. Doing the dispatch
where the limbs are already in registers removes a call frame and a round of loads, and it lets
the JIT tail-jump straight from Mod into the kernel: one call and one jump where there used to be
two calls, two returns and three reads of the divisor.

Power-of-two divisors gain most, because masking three limbs was never worth a second call.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations: power-of-two divisors 0.62-0.83,
2x2 0.83, 2x1 0.86, 3x2 0.90, 3x3 0.91, key/value matrix 0.91, mixed corpus 0.93, 4x3 and 4x2
0.93, 4x4 0.97. Geomean 0.887. Shapes that return before the dispatch read 0.99-1.05 across
repeats - Mod is 611 bytes of code where it was 190, and they are the ones that pay for it.

WideDivideModBenchmark bound ModFull by reflection; it now calls the public entry point, which
is the path.
A divisor at or above 2^255 makes the quotient 1, so the remainder is x - y. That went through
an inline borrow chain: four sub/cmp/setb/movzx groups and four 8-byte stores, all in general
registers. The general subtract loads both operands as vectors, subtracts, and stores the
result as a vector without touching a general register at all, and that is worth more than the
call it costs.

Only where there is a vector unit, though. Fixing the inline chain instead - the two-cycle
borrow helper and a 32-byte store - measured 1.068, because assembling that store from general
registers costs more than the four limb stores it replaces. So the choice is on
Vector128.IsHardwareAccelerated, and a build with no vector unit keeps the chain it had.

Ryzen 9 9950X, Tier-1 with dynamic PGO, both A/B orientations, on the shape whose divisor is
above 2^255: 0.501 with AVX-512, 0.496 with AVX2 only, 0.727 with SSE alone - which is the
same Vector128 path ARM64 takes - and 0.995 with DOTNET_EnableHWIntrinsic=0. The mixed corpus
picks up 0.98-0.99 everywhere. 587,686 tests pass in the default, no-AVX2 and no-intrinsics
configurations.
Folding it in measured 0.887 on a Ryzen 9 9950X, but on a Neoverse N2 it bought the wide
shapes nothing - 0.822 against 0.823 at four-by-four, and the same at every other width - and
gave every call the prologue the merged body needs. Mod went from three saved registers to
five on x64 and from a 64-byte frame with three on ARM64 to a 96-byte frame with six, which
the calls that answer from the entry compare pay in full: a one-limb x % y went from 3.52 to
4.14-4.66 ns across two runs, and a dividend below its divisor from 2.79 to 3.60-3.93.

So the dispatch goes back behind a call and Mod keeps its lazy limb reads. That costs the x64
geomean 0.746 -> 0.834, all of it on the power-of-two divisors and the wide shapes, and leaves
no shape slower than main on either platform. The x64 gain is real and available if the
tradeoff is ever worth making; it is recorded here rather than taken.
Copilot AI lite review requested due to automatic review settings September 3, 2026 02:30

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.

🔵 Needs a closer look

The changes substantially rewrite core division/modulo internals in a performance- and correctness-critical numeric type, so they warrant final human review despite no concrete issues found in the diff.

Pull request overview

This PR optimizes UInt256.Mod by computing remainders directly (without constructing/storing the quotient), reducing normalization overhead, and tightening fast paths; it also updates Int256.Mod to avoid unnecessary operand copies and adds a benchmark to measure remainder performance by operand “shape”.

Changes:

  • Reworked UInt256.Mod/ModFull to dispatch once by divisor width and run dedicated remainder-only kernels for 256/192/128/64-bit divisor widths (plus power-of-two and high-divisor shortcuts).
  • Optimized single-limb modulus flows (notably in MulModBy64Bits) to avoid normalization when hardware division is available and reduce the number of divides in final reduction.
  • Simplified Int256.Mod to skip absolute-value copies when both operands are already non-negative, and added ModShapesBench for cross-ISA remainder shape benchmarking.
File summaries
File Description
src/Nethermind.Int256/UInt256.DivideMod.cs Implements remainder-only kernels and streamlined fast paths to speed up UInt256.Mod and related modular ops without computing/storing the quotient.
src/Nethermind.Int256/Int256.cs Avoids unconditional operand copying in Int256.Mod, taking absolute values only when required and preserving dividend-sign semantics.
src/Nethermind.Int256.Benchmark/ModShapesBench.cs Adds a new BenchmarkDotNet benchmark to measure % performance across operand-width “shapes” and key fast-path scenarios.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@LukaszRozmej LukaszRozmej left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not sure if faster in zkvm

@benaadams
benaadams merged commit c3d8c93 into main Sep 3, 2026
16 checks passed
@benaadams
benaadams deleted the perf/mod-remainder-kernels branch September 3, 2026 06:32
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