Skip to content

Skip the zero leading quotient digit and reuse correction products in MULMOD reduction - #120

Merged
benaadams merged 14 commits into
mainfrom
mulmod-perf
Sep 7, 2026
Merged

Skip the zero leading quotient digit and reuse correction products in MULMOD reduction#120
benaadams merged 14 commits into
mainfrom
mulmod-perf

Conversation

@benaadams

@benaadams benaadams commented Sep 7, 2026

Copy link
Copy Markdown
Member

Results

MultiplyMod(x, y, m) by modulus shape, against main. Lower PR/main is better: 0.86 means 14% less time.
x64 is a Ryzen 9 9950X (Zen 5, AVX-512) under Tier-1 with dynamic PGO; ARM64 is the ubuntu-24.04-arm
runner (Neoverse N2) through benchmark.yml, and MulModOptimizationBenchmarks in this PR is what produces
that column. The zk column runs the same ten workloads as a bflat riscv64 guest under ziskemu and counts
whole-guest instruction steps, startup included. x and y are random 256-bit values, so the product fills
all eight limbs; the shape names the modulus. full, 192 and 128 have the high bit of their top limb set
and need no normalising shift; the shift variants have a top limb below 2^32 and need one. 64 is a
one-limb odd modulus, narrow is a one-limb x against a random odd 256-bit modulus, max is 2^256 - 1 and
pow64 is 2^k.

Shape x64 main -> PR (ns/op) x64 PR/main ARM64 main -> PR (ns/op) ARM64 PR/main zk steps PR/main
full 53.24 -> 48.64 0.91 96.83 -> 83.01 0.86 0.80
192 51.47 -> 47.87 0.93 100.72 -> 87.82 0.87 0.79
128 48.29 -> 44.72 0.93 97.64 -> 82.59 0.85 0.84
128shift 57.50 -> 55.95 0.97 107.24 -> 98.34 0.92 0.88
192shift 54.58 -> 54.22 0.99 105.47 -> 102.46 0.97 0.85
shift 56.30 -> 55.81 0.99 103.24 -> 102.57 0.99 0.87
narrow 21.26 -> 21.54 1.01 44.36 -> 37.06 0.84 0.85
max 19.36 -> 17.47 0.90 31.93 -> 31.98 1.00 0.97
64 20.01 -> 19.74 0.99 34.56 -> 33.45 0.97 1.00
pow64 6.17 -> 5.86 0.95 2.67 -> 2.51 0.94 1.00
geomean 0.957 0.918 0.883

Geomean over the ten shapes, by configuration. FullOpts (DOTNET_TieredCompilation=0) compiles everything
once without profile data and is where the frame changes below show most; PGO already recovers part of that
for main, so the Tier-1 rows are the conservative figure:

Configuration PR/main
x64 AVX-512, Tier-1 PGO 0.957
x64 AVX2 only, Tier-1 PGO 0.965
x64 DOTNET_EnableHWIntrinsic=0, Tier-1 PGO 0.909
x64 AVX-512, FullOpts 0.913
x64 AVX2 only, FullOpts 0.888
x64 DOTNET_EnableHWIntrinsic=0, FullOpts 0.850
ARM64 Neoverse N2 0.918
zk guest, instruction steps 0.883

The guest in detail, since steps are the proving cost there. Memory cost is Zisk's weighted memory-access
count, not RAM:

Shape steps main -> PR PR/main memory cost main -> PR PR/main
full 2,694,517 -> 2,146,666 0.797 5,706,345 -> 4,848,313 0.850
192 2,690,877 -> 2,134,628 0.793 5,546,601 -> 4,600,749 0.829
128 2,382,422 -> 1,993,258 0.837 5,883,839 -> 5,450,631 0.926
192shift 2,738,703 -> 2,335,927 0.853 5,546,601 -> 4,699,665 0.847
shift 2,748,335 -> 2,391,742 0.870 5,706,345 -> 4,996,145 0.876
128shift 2,438,526 -> 2,151,007 0.882 5,938,265 -> 5,588,225 0.941
narrow 1,490,959 -> 1,268,140 0.851 4,819,561 -> 4,661,225 0.967
max 1,017,659 -> 988,987 0.972 3,490,254 -> 3,175,030 0.910
64 1,071,358 -> 1,073,405 1.002 3,164,777 -> 3,164,945 1.000
pow64 388,263 -> 388,262 1.000 2,674,281 -> 2,674,449 1.000

On the wrong side of the noise band: x64 narrow under PGO is 1% slower with AVX-512 and 4% slower with AVX2
only (20.96 -> 21.79 ns), while FullOpts and every other target improve it; 64 with intrinsics off under PGO
is 2% slower (43.04 -> 44.00 ns); the guest's 64 takes 0.19% more steps, and 64 and pow64 each cost 168
more memory units. The ARM figures are single ShortRun jobs on separate runners, so its rows inside 3%
(192shift, shift, 64, max) should be read as parity.

What is in it

MultiplyMod with a modulus of two or more limbs is a 256x256 -> 512-bit product followed by Knuth D
against the modulus, one quotient digit per iteration. Each iteration divides the top of the window by the
modulus's top limb, corrects the estimate, then multiplies the estimate back through the whole modulus and
subtracts. Three things in that loop were being paid for and never used.

  • The leading quotient digit was almost always zero, and every kernel computed it anyway. When the
    modulus needs no normalising shift, the product's eight limbs get a zero ninth limb on top and the first
    window is (0 : u7) / nd_top, which is 0 or 1, and 0 whenever u7 < nd_top. For uniformly random operands
    that is about 95% of calls (the product of two uniform draws skews low); for operands already below the
    modulus, as in field arithmetic, x*y < m^2 makes it every call bar a modulus whose top limb is all ones.
    One compare before the loop drops the whole iteration: its divide, its correction and its multiply-subtract.
    The 256-, 192- and 128-bit reducers all get it, in the no-shift branch only - after a normalising shift the
    ninth limb holds real bits, and a check that covered that case cost the shift shapes more than it saved. In
    the reciprocal 128-bit path the check shortens the digit count after normalisation rather than uLen before
    it, which keeps the loop free of spills.

  • The reciprocal path threw its remainder away. On x64, DivRem returns rhat == (u8:u9) - qhat*nd_top
    exactly, so the kernels already skipped the top limb's product and subtracted one limb less. Every other
    target - ARM64, DOTNET_EnableHWIntrinsic=0, the zk guest - went through EstimateQhat, which runs the same
    2-by-1 reciprocal divide, discards its remainder, and falls into the full multiply-subtract. UDivRem2By1 is
    exact too, so those targets now take the x64 path in the 192- and 256-bit reducers and in the 128-bit
    KnuthStep. Only the saturated digit (u8 >= nd_top) and a correction that overflows rhat, where the
    identity does not fit a limb, still go the long way. This is most of the ARM, scalar and guest movement.

  • The correction already held the next limb's product. The Knuth correction loop maintains
    ph:pl == qhat * nd_{n-2} so it can test the estimate; the multiply-subtract then computed
    Multiply64(nd_{n-2}, qhat) again. The 192-bit reducer now reuses ph:pl on every target, and reads the
    limb it already loaded rather than reloading it through the ref. The 256-bit reducer reuses it only where the
    widening multiply is software: with mulx or ARM64 mul/umulh the product is cheaper than keeping two
    more values live across the four-limb subtraction, and reusing it there measured no repeatable gain.

  • Power-of-two moduli masked both operands before multiplying. The low k bits of a product depend only on
    the low k bits of its operands, so one mask after the multiply is enough.

  • Frames. Multiply256To512BitLarge was NoInlining and now inlines into its callers, and the 128-bit
    reducer inlines into MulModBy65To128Bits on host JITs. The wide body moves out of MultiplyMod's entry so
    the zero, one and narrow-modulus exits stop paying its prologue: the public entry goes from 864 bytes, two
    saved registers and a 104-byte frame to 193 bytes, a 40-byte frame and tail jumps into MultiplyModWide or
    Mod, while the wide helper grows to 1,955 bytes with the product inlined. Under FullOpts the trivial exits
    gain 0.3-1.1 ns (zero 1.67 -> 1.36 ns, one 7.11 -> 6.41 ns over 10M-call samples); under PGO they are
    flat. MultiplyOverflow reaches the same product through Multiply256To512Bit and picks up the inlined body
    too; it was not measured here. Two targets want the opposite policy, and say so in one constant each:

    • ARM64 keeps the wide body in the entry frame. With the split applied there as well, the
      ARM run at the previous commit had
      full at 89.89 ns, 192 at 91.72 and shift at 107.16, against 83.01, 87.82 and 102.57 with the body
      inline, so ArmBase.Arm64.IsSupported calls the core directly.
    • The zk guest (UInt256.zkevm.cs) forces the wide body inline and leaves the 128-bit reducer to the
      compiler: a separate call raised guest steps and memory traffic, and forcing the reducer inline lowered
      memory cost but raised steps. With those two constants the final guest matches the monolithic build's
      steps and memory cost exactly on all ten shapes.

Not taken

  • Reusing the 256-bit correction product on x64 and ARM64. Keeping ph:pl alive through the four-limb
    subtraction lengthened live ranges with no repeatable gain against a hardware widening multiply, so those
    targets recompute and the software targets reuse. The 192-bit reducer has one limb less in flight and reuses
    everywhere.
  • Scalar add-with-carry chains for the 2^256 - 1 fold were slower than the existing SIMD add in every
    x64 configuration; the fold stays as it was.
  • A leading-digit check after normalisation, so shifted moduli could skip too, added more to the shift
    shapes than it removed. The check stays in the no-shift branch.

Validation

  • Full CI matrix at the final SHA: all 13
    test jobs pass - Windows/Linux/macOS, native ARM64, intrinsics on and off, debug and release, and the
    zkevm variant.
  • Locally, 588,053 tests pass with intrinsics on and 588,052 with them off (one hardware-only test skipped)
    at the commit before the ARM routing change; that last commit only alters which call ARM64 takes, and its
    x64 listings are identical bar relocation addresses.
  • New MultiplyModLeadingDigitTests drive the three reducers directly: leading limb one below, equal to and
    one above the divisor's top limb, 5- to 8-limb dividends, shifts of 0/1/31/63 with zero and all-ones tails,
    output aliased onto the divisor; and 4,096 constructed q*d + r cases per reducer with r in
    {0, d/2, d-1} across all 64 normalising shifts. The power-of-two Mod sweep now covers every exponent
    instead of every third.
  • 50,000 random MultiplyMod results agree with BigInteger, and 150,000 aliased calls agree with the
    unaliased result, in each of the AVX-512, AVX2-only and no-intrinsics configurations.
  • Every guest workload's checksum matches an independent Python big-integer oracle, and the linked guest
    carries no floating-point, compressed or atomic instructions.
  • git diff --check clean.

Measurement notes: x64 is .NET 10.0.11 with DOTNET_TieredCompilation=1 and DOTNET_TC_CallCountingDelayMs=0
(PGO) or DOTNET_TieredCompilation=0 (FullOpts), DOTNET_EnableAVX512=0 for the AVX2 column, processes run
serially and pinned to one core, 1,024 operand triples per shape, nine samples of 1,024,000 calls after a
1,024,000-call warm-up, baseline and candidate in ABBA order, the mean of the two process medians reported.
The ARM baseline is main plus the benchmark class
and the candidate is the final SHA, both
BenchmarkDotNet ShortRun. Guest counts are ziskemu 1.2.0-alpha on bflat riscv64 builds, 1,024 MULMODs per
shape, startup included.

@benaadams benaadams changed the title Reduce MULMOD quotient work and tune inlining across host and zk targets Skip the zero leading quotient digit and reuse correction products in MULMOD reduction Sep 7, 2026
@benaadams
benaadams marked this pull request as ready for review September 7, 2026 11:08
Copilot AI lite review requested due to automatic review settings September 7, 2026 11:08

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.

🟡 Changes recommended

A reducer hot-path currently treats X86Base.X64.IsSupported as “hardware widening multiply available”, which can cause unnecessary software widening multiplies on x64 CPUs without BMI2 (and contradicts the intended reuse-vs-recompute decision).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR optimizes UInt256.MultiplyMod for multi-limb moduli by reducing unnecessary work in the Knuth D reduction loop and by tuning inlining/frame layout for different targets (host JIT vs zkevm guest). The changes target both throughput (fewer reduction iterations / less redundant multiply-subtract work) and call-frame overhead on trivial exit paths.

Changes:

  • Split MultiplyMod into a trivial-entry + wide-body helper and add ARM64 routing to keep the wide reduction in the entry frame when appropriate.
  • Optimize the 192/256-bit Knuth D reducers by skipping a provably-zero leading quotient digit, preserving exact remainders from reciprocal division, and reusing correction products where beneficial.
  • Add targeted correctness tests for leading-digit boundary cases and add/extend benchmarks and mod-kernel coverage for power-of-two moduli.
File summaries
File Description
src/Nethermind.Int256/UInt256.zkevm.cs Adds zkevm-specific inlining policy constants for the wide MULMOD body and 128-bit reducer.
src/Nethermind.Int256/UInt256.std.cs Adds host-JIT inlining policy constants to keep wide spills off trivial paths and expose the 128-bit reducer.
src/Nethermind.Int256/UInt256.DivideMod.cs Implements the core MULMOD/reduction optimizations (leading digit skip, exact-remainder reuse, product reuse) and refactors wide-body framing/inlining.
src/Nethermind.Int256.Tests/MultiplyModLeadingDigitTests.cs Adds regression tests for leading quotient digit boundary cases and constructed q*d+r scenarios across reducers.
src/Nethermind.Int256.Tests/ModKernelTests.cs Expands the power-of-two modulus sweep to cover every exponent.
src/Nethermind.Int256.Benchmark/MulModOptimizationBenchmarks.cs Adds a dedicated benchmark harness to measure MULMOD performance across modulus “shape” scenarios.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

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

Comment thread src/Nethermind.Int256/UInt256.DivideMod.cs

@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.

No correctness defects found. The three algorithmic claims hold, and the changed branches are genuinely covered by the new tests.

What I verified

1. Leading-digit skip. In the shift == 0 branch the ninth limb really is zero (w8 = 0, and limbs above uLen-1 are zero by construction of uLen), so the top window is (0 : u[uLen-1]) / ndTop and u[uLen-1] < ndTop forces qhat = 0 — the whole iteration is a no-op. Restricting the check to the no-shift branch is necessary, and the PR does. uLen >= 5 always, so m cannot go negative (uLen = 5 -> m = 0 is the tight case in the 256-bit reducer).

2. UDivRem2By1's remainder is exact. It is Möller–Granlund udiv_qrnnd_preinv with both final corrections, so rhat == (u8:u9) - qhat*ndTop holds identically to X86Base.X64.DivRem. Its preconditions (normalised divisor, u8 < ndTop) are guaranteed by the u8 >= ndTop -> goto FullSubMul guard immediately above. Dropping the now-unreachable qhat = EstimateQhat(...) fallthrough is right.

3. ph:pl reuse. The correction loop maintains ph:pl == qhat * nd_{n-2} across every qhat--, and both early exits (rhat overflow -> FullSubMul, saturated digit -> FullSubMul) leave before the reuse site, so a stale product cannot be consumed. v1 = u10 is safe too: u10 is read at the top of the iteration and only un[j] is written before it. Likewise the power-of-two mask — mask + 1 divides 2^64, so masking after the wrapping multiply is equivalent.

Testing

check result
Full suite, default ISA 588,053 pass
Full suite, DOTNET_EnableHWIntrinsic=0 588,052 pass, 1 skipped
Full suite, DOTNET_EnableAVX2=0 (Bmi2=false, X86Base.X64=true) 588,053 pass
Independent BigInteger differential harness: 60k direct reducer cases per width + 400k MultiplyMod, std and zkevm variants, four ISA configs all match
Branch instrumentation driven by the PR's own new tests reaches skip-taken and skip-not-taken, saturated digit, rhat-overflow -> FullSubMul, and add-back, in both the 192- and 256-bit reducers and in KnuthStep
Mutation: < d3 -> <= d3 in the 256-bit skip caught by Leading_quotient_digit_boundaries_match_BigInteger (plus 78 existing tests)

So the new tests are real regression tests, not smoke. I did not attempt to reproduce the timings.

Worth changing

  1. Constructed_quotients_and_remainders_match_BigInteger is missing [TestCase(128)]. The description says "4,096 constructed q*d + r cases per reducer", but the 128-bit reducer only gets the boundary test. Adding 128 works as written — q gets 384 bits, the product stays within 512, and hi != 0.

  2. Both new tests take a single parameter, so [Values] fits better than stacked [TestCase]: public void Leading_quotient_digit_boundaries_match_BigInteger([Values(128, 192, 256)] int bits).

  3. The 128-bit reducer states the same optimisation twiceuLen-- under X86Base.X64.IsSupported, m-- under !X86Base.X64.IsSupported — and the x64 line carries no comment at all, while its 192/256 twins say "skip the provably zero leading quotient digit". The // Keeping uLen unchanged... comment explains why the form differs but not what either line does. A "what" comment on the first, cross-referencing the second, would save the next reader a 28-line scroll.

  4. Bmi2.X64.IsSupported || ArmBase.Arm64.IsSupported with // Avoid repeating the software widening product. On x64 without BMI2, Multiply64 falls through to Math.BigMul, which is a JIT intrinsic lowering to mul — hardware, not software. The predicate really means "the widening multiply is a single cheap instruction that does not need mulx". Either reword the comment or hoist the condition into a named local with that meaning; as written it will mislead.

  5. Benchmark class. MultiplyModMaxTargeted already covers max / full (FullWidthMiss) / narrow with an enum-typed [Params] and [NoIntrinsicsJob]. The new class overlaps it, uses string params where both sibling benchmarks (ModShapesBench.Shape, MultiplyModMaxCase) use enums, and hides "narrow" behind the _ => default arm so nothing in the switch names it. It also calls UInt256.MultiplyMod directly in the loop, whereas ModShapesBench deliberately routes through a [MethodImpl(MethodImplOptions.NoInlining)] wrapper — "one real call per operation, as production callers see it" — which matters here specifically, since the entry-frame split is only observable across a real call boundary. Extending MultiplyModMaxCase with the shift/192/128/pow2 shapes would be the DRY move; failing that, at least add the job and warmup/iteration attributes so the reported matrix is reproducible from the class alone.

Minor

  • // qhat overshot against the full divisor ... (rare) in KnuthStep: I measured 304k/1.16M ~= 26% on random inputs. Pre-existing wording, and not a regression — the old non-x64 route had the same add-back rate — but this PR makes it the universal path, so it is a fair moment to drop "(rare)".
  • CI has intrinsics-on and intrinsics-off jobs, but none with X86Base.X64 on and BMI2 off, which is the exact configuration the last commit (hi2 = ph; lo2 = pl on x64) targets. Both halves are covered separately; a DOTNET_EnableAVX2=0 job would cover the combination. I ran it locally and it is green.

Nice to see this land as a net removal of code (EstimateQhatEst, the duplicated Multiply64) rather than an addition.

@kamilchodola

Copy link
Copy Markdown
Contributor

Client A/B against Nethermind master (2026-09-07)

Tested this PR as a staging package (Nethermind.Numerics.Int256 1.7.1-mulmod120, built from PR head c8476f7 by Test / Publish run 34117921871) pinned into Nethermind master 53ea862 (branch perf/int256-1.7.1-mulmod120), against the same master, on both reproducible-benchmark boxes.

Verdict: safe, zero regressions, response parity 497/497 on both ISAs. The only measurable gain is on the arm64 eth_call path; block processing is neutral on both ISAs. That ordering matches the PR's own micro-benchmarks (ARM64 0.918 vs x64-PGO 0.957): MULMOD is a small share of both workloads, so neither can resolve a single-opcode speedup into more than this.

eth_call, 497-record heavy simulation corpus (plain A/B, one round, arms interleaved)

box cell avg CPU-ms/req p99 paired per-record median (95% CI) closed-loop throughput
amd64 100 rps +0.2% +0.4% +0.1% -0.7% (-1.4 .. -0.1) +0.6%
amd64 300 rps¹ -1.1% -0.3% -1.2%
arm64 100 rps -0.1% +0.1% -0.5% -1.8% (-1.9 .. -1.6) +1.1%
arm64 300 rps -2.5% -1.5% -2.5%

¹ amd64 at 300 rps sits at ~90% of the box's serialization ceiling (≈2% HTTP failures in both arms), so that cell measures queueing as much as compute. The two non-EVM selector classes are flat on both boxes, so the arm64 gain is in EVM arithmetic. PR failure rate 0.00% at 100 rps on both boxes.

Runs: amd64 34126784881, arm64 34126788243.

Block processing, EXPB fusaka (999 mainnet blocks, paired per-block, with an identical-code A/A control image)

box arm n paired mean vs master paired median blocks worse
amd64 PR -0.035 ms/block -0.05 484/999
amd64 A/A control 6 -0.090 ms/block -0.15 426/999
arm64 PR 6 -0.018 ms/block 0.00 432/999
arm64 A/A control 6 +0.064 ms/block 0.00 475/999

The PR delta is inside the A/A floor on both boxes (split-half floors 0.13-0.29 ms on amd64, up to 0.08 ms on arm64): neutral. All 34 client jobs: no exceptions, no invalid blocks, clean shutdown.

² Both amd64 "PR run 3" jobs died 9 s in because the workflow's per-job yq download from GitHub releases returned a 504 page twice in a row; infrastructure, not this PR.

Runs: amd64 34126791812 / 34126795596, arm64 34126798850 / 34126802317 (forward and mirrored image order).

@benaadams
benaadams merged commit e240c80 into main Sep 7, 2026
14 checks passed
@benaadams
benaadams deleted the mulmod-perf branch September 7, 2026 15:18
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.

4 participants