Skip to content

crypto: Use fixed-window exponentiation in modexp - #1618

Open
AskAlexSharov wants to merge 1 commit into
ipsilon:masterfrom
AskAlexSharov:alex/modexp-windowing
Open

crypto: Use fixed-window exponentiation in modexp#1618
AskAlexSharov wants to merge 1 commit into
ipsilon:masterfrom
AskAlexSharov:alex/modexp-windowing

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 1, 2026

Copy link
Copy Markdown

Summary

modexp_odd uses 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 consumes w exponent 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 = 2 up to 48 bits, w = 3 up to 144 bits, w = 4 above.

With w = 1 the 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:

case before after speedup
mod_len:32 / exp_bits:256 19,604 ns 12,904 ns 1.52x
mod_len:32 / exp_bits:8192 650,488 ns 419,503 ns 1.55x
mod_len:504 / exp_bits:255 2,868,036 ns 1,869,504 ns 1.53x
mod_len:512 / exp_bits:8192 96,520,499 ns 60,619,147 ns 1.59x
mod_len:32 / exp_bits:33 2,582 ns 2,089 ns 1.24x

Small exponents (≤ 16 bits) are unchanged; none regress.

Cost

The power table adds MODEXP_TABLE_MAX * n words to the stack scratch buffer (STACK_CAPACITY and the modexp_odd scratch requirement are updated). At the EIP-7823 limit (n = 128 words) 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. Existing expmod vectors and large_inputs continue to pass.

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
AskAlexSharov force-pushed the alex/modexp-windowing branch from ef07630 to 006ba7d Compare August 1, 2026 13:13
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`.
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.

1 participant