crypto: Use fixed-window exponentiation in modexp - #1618
Open
AskAlexSharov wants to merge 1 commit into
Open
Conversation
AskAlexSharov
force-pushed
the
alex/modexp-windowing
branch
2 times, most recently
from
August 1, 2026 13:00
7959fbf to
ef07630
Compare
modexp_odd used binary square-and-multiply: one Montgomery multiply per set exponent bit. For large exponents that is roughly twice the multiplies a windowed method needs. Precompute a small table of base powers (b^1 .. b^(2^w - 1) in Montgomery form) and consume w exponent bits per multiply. The window width scales with the exponent size (w = 1..4) so the table cost stays amortized even for a sparse exponent, and small exponents keep the plain binary path (w = 1). With w = 1 the loop is identical to the previous binary square-and-multiply. Measured ~1.5-1.6x on large-exponent modexp (256-bit modulus, 256-bit exponent: 19.6us -> 12.9us; 4096-bit modulus, 8192-bit exponent: 96.5ms -> 60.6ms on an AMD EPYC 4344P); smaller exponents also improve and none regress. The power table adds MODEXP_TABLE_MAX*n words to the stack scratch buffer. Add expmod.windowing_vs_gmp: a differential test against GMP over many exponent bit-lengths and patterns, for odd and even moduli.
AskAlexSharov
force-pushed
the
alex/modexp-windowing
branch
from
August 1, 2026 13:13
ef07630 to
006ba7d
Compare
Sahil-4555
pushed a commit
to Sahil-4555/erigon
that referenced
this pull request
Aug 7, 2026
…192, 2^256) (erigontech#22940) See also: ipsilon/evmone#1618 ## Summary Add a fixed-width `uint256` square-and-multiply for MODEXP and route inputs there when the modulus is in `[2^192, 2^256)` and the base fits in 256 bits, instead of to the `evmone` default. This is a **pure addition** — all existing branches are unchanged from `main`. `uint256` avoids arbitrary-precision bookkeeping and the cgo boundary for this case, and is allocation-free. ## Routing ``` mod in {0, 1} -> trivial result base == 1 -> 1 modLen > 32, exp <= 1 byte -> math/big (unchanged from main) 2^192 <= mod < 2^256, baseLen <= 32 -> uint256 (this PR — new) otherwise -> evmone (default; unchanged from main) ``` Only that one class moves. Gas and EIP-7823 limits are untouched. The two routing bounds are not tuned constants, they are where the implementation stops paying: - **`mod >= 2^192`.** `uint256.Reciprocal` returns nothing usable when `m[3] == 0`, and `MulModWithReciprocal` then falls back to a full `udivrem` on every multiply. Since the operand is a byte field, the test is on the modulus *value*, not on `modLen` — a 32-byte field holding a 128-bit modulus stays on `evmone`. - **`baseLen <= 32`.** A wider base has to be folded in before exponentiation, which costs more than the whole `evmone` call. It is also a correctness precondition: `uint256.SetBytes` silently truncates above 32 bytes. ## Benchmarks — time (median ns/op) The tables compare backends directly. Which branch erigon actually uses per row: | row | erigon backend | |---|---| | 256-bit / * | **uint256** (this PR) | | 2048-bit / exp3 | math/big (case unchanged from main) | | 2048-bit / 65537, full | evmone (default) | `pbig` (geth's patched `math/big` fork) and GMP (`mpz_powm`, reth's optional backend, via a tight reused-handle binding) are shown for reference only; neither is used by erigon. **x86_64** | modulus / exponent | Go big | pbig | evmone | GMP | uint256 | |---|--:|--:|--:|--:|--:| | 256-bit / 65537 | 422 | 1,563 | 785 | 709 | 485 | | 256-bit / 64-bit exp | 6,264 | 4,557 | 4,961 | 1,715 | 3,197 | | 256-bit / 256-bit exp | 15,113 | 14,811 | 19,599 | 5,533 | 12,844 | | 2048-bit / exp3 | 3,733 | 3,711 | 5,775 | 4,797 | n/a | | 2048-bit / 65537 | 24,053 | 24,279 | 28,396 | 16,432 | n/a | | 2048-bit / full exp | 2,002,185 | 2,004,973 | 6,138,396 | 1,766,554 | n/a | **arm64** | modulus / exponent | Go big | pbig | evmone | GMP | uint256 | |---|--:|--:|--:|--:|--:| | 256-bit / 65537 | 325 | 1,362 | 468 | 712 | 386 | | 256-bit / 64-bit exp | 6,669 | 4,146 | 2,815 | 2,002 | 2,323 | | 256-bit / 256-bit exp | 13,632 | 13,398 | 11,341 | 7,114 | 9,831 | | 2048-bit / exp3 | 3,096 | 3,102 | 3,502 | 3,820 | n/a | | 2048-bit / 65537 | 19,463 | 20,462 | 15,444 | 12,250 | n/a | | 2048-bit / full exp | 1,692,423 | 1,673,792 | 3,215,552 | 1,289,299 | n/a | For the rows this PR touches, `uint256` is faster than the previous (`evmone`) path on both machines: e.g. x86 `785 -> 485` (65537), `19,599 -> 12,844` (256-bit exp); arm64 `468 -> 386`, `11,341 -> 9,831`. The 2048-bit rows are shown for completeness and are unaffected by this PR. ## Benchmarks — allocations, allocs/op (bytes/op), architecture-independent | modulus / exponent | Go big | pbig | evmone | GMP | uint256 | |---|--:|--:|--:|--:|--:| | 256-bit / 65537 | 8 (352) | 6 (424) | 1 (32) | 2 (16) | 0 (0) | | 256-bit / 64-bit exp | 12 (768) | 6 (424) | 1 (32) | 2 (56) | 0 (0) | | 256-bit / 256-bit exp | 23 (2,097) | 6 (480) | 1 (32) | 2 (56) | 0 (0) | | 2048-bit / exp3 | 8 (3,498) | 8 (3,498) | 1 (256) | 2 (296) | n/a | | 2048-bit / 65537 | 11 (5,227) | 9 (3,114) | 1 (256) | 2 (296) | n/a | | 2048-bit / full exp | 26 (12,941) | 9 (3,444) | 1 (256) | 2 (296) | n/a | In `Run` all paths write into one shared `result` buffer, so these are each algorithm's own temporary allocations. The `uint256` path is allocation-free. (`evmone`'s single Go alloc and GMP's two are the output slices; their working temporaries live in C, outside Go's allocator.) ## Review fix: narrowing the routing (51eb334) The first revision routed on `modLen <= 32` alone and folded an oversized base in byte by byte. @taratorio found that both moved input classes that `evmone` handles better. End-to-end `Run` benchmarks, Apple M4 Max, `-benchtime=300ms -count=6`: | case | before | after | |---|--:|--:| | **control** base 32 B, mod 256-bit, exp 65537 | 386 ns | 392 ns | | **control** base 32 B, mod 256-bit, exp 8 B | 1743 ns | 1753 ns | | **control** base 32 B, mod 2^192+237, exp 8 B | 1765 ns | 1775 ns | | base 32 B, mod 2^192−237, exp 8 B | 4651 ns | 2089 ns | | base 32 B, mod 128-bit, exp 8 B | 3731 ns | 1246 ns | | base 32 B, mod 128-bit, exp 65537 | 732 ns | 328 ns | | base 32 B, mod 64-bit, exp 8 B | 2094 ns | 599 ns | | base 64 B, mod 256-bit, exp 1 | 1313 ns | 191 ns | | base 128 B, mod 256-bit, exp 1 | 2553 ns | 272 ns | | base 1024 B, mod 256-bit, exp 1 | 19,923 ns | 1344 ns | | base 1024 B, mod 256-bit, exp 65537 | 20,413 ns | 1768 ns | | base 1024 B, mod 256-bit, exp 8 B | 21,797 ns | 3594 ns | The controls are the rows that must stay on the `uint256` path, and they do; the ~5 ns is the routing predicate itself. All other rows are back on `evmone`, i.e. at `main`'s numbers. Note the third control (`2^192+237`) versus the fourth row (`2^192−237`): adjacent moduli on opposite sides of the reciprocal boundary. Folding the base 32 bytes at a time (`b = b*(2^256 mod m) + chunk`) instead of guarding was measured too — around 1.6–2 µs for a 1024-byte base against `evmone`'s 1.3 µs, so it would not have recovered the class. ## Correctness - `TestModexpU256Applicable` pins the routing boundaries, including `2^192−1` vs `2^192`, moduli padded with leading zero bytes, and bases of 33 and 1024 bytes. - `TestModexpU256` cross-checks fixed vectors against `math/big` (odd/even/power-of-two moduli, base `0`/`1`/`>mod`, exponent `0`/small/full-width). - `TestModexpU256Random` fuzzes 20,000 random inputs against `math/big`, drawing moduli with randomly zeroed leading bytes so the boundary is hit from both sides. - Existing `TestPrecompiledModExp*` vectors exercise this path and pass. - The reduction is Barrett-style (`uint256.MulModWithReciprocal`), valid for odd and even moduli alike. ## Scope / follow-ups - Only the compute backend changes for the routed class; gas, EIP-7823 limits, result encoding, and the existing `math/big`/`evmone` branches are untouched. - A 4-bit windowed `uint256` gives a further ~1.5x for large exponents but regresses tiny ones, so it's left out to keep this minimal and strictly non-regressing for small exponents. - Moduli below `2^192` could be handled without division by reducing on a narrower fixed width (e.g. 192-bit operands with a 384/192 reduce), but `uint256` exposes no such primitive, so they stay on `evmone`.
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
modexp_odduses binary square-and-multiply — one Montgomery multiply per set exponent bit. For large exponents that is roughly twice the multiplies a windowed method needs.This precomputes a small table of base powers (
b^1 .. b^(2^w - 1)in Montgomery form) and consumeswexponent bits per multiply. The window width scales with the exponent size so the table cost stays amortized even for a sparse exponent:w = 1(plain binary, no table) for exponents ≤ 16 bits;w = 2up to 48 bits,w = 3up to 144 bits,w = 4above.With
w = 1the loop is byte-for-byte the previous binary square-and-multiply.Benchmarks
evmone-precompiles-bench --benchmark_filter='modexp<expmod_execute_evmone>', AMD EPYC 4344P, gcc 15.2.0, Release:Small exponents (≤ 16 bits) are unchanged; none regress.
Cost
The power table adds
MODEXP_TABLE_MAX * nwords to the stack scratch buffer (STACK_CAPACITYand themodexp_oddscratch requirement are updated). At the EIP-7823 limit (n = 128words) that is ~15 KB of additional stack in the single modexp frame.The window widths and thresholds are simple, conservative choices; happy to tune them or switch to a sliding window (odd-power table, ~half the entries) if preferred.
Correctness
Adds
expmod.windowing_vs_gmp: a differential test comparing evmone against GMP across many exponent bit-lengths (crossing the window-width thresholds) and bit patterns (all window values), for odd and even moduli. Existingexpmodvectors andlarge_inputscontinue to pass.