Skip to content

execution/vm: uint256 fast path for MODEXP when the modulus is in [2^192, 2^256) - #22940

Merged
taratorio merged 5 commits into
mainfrom
alex/modexp-uint256-fastpath
Aug 7, 2026
Merged

execution/vm: uint256 fast path for MODEXP when the modulus is in [2^192, 2^256)#22940
taratorio merged 5 commits into
mainfrom
alex/modexp-uint256-fastpath

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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.

@AskAlexSharov
AskAlexSharov force-pushed the alex/modexp-uint256-fastpath branch from d8b4459 to f6c5031 Compare August 1, 2026 11:08
@AskAlexSharov
AskAlexSharov marked this pull request as draft August 1, 2026 11:09
@AskAlexSharov
AskAlexSharov force-pushed the alex/modexp-uint256-fastpath branch 3 times, most recently from 388c684 to ebb545a Compare August 1, 2026 11:35
Add a fixed-width uint256 square-and-multiply for MODEXP when the modulus fits
in 256 bits, routing those inputs there instead of to the evmone default. All
existing branches are unchanged from main (math/big for >256-bit moduli with a
one-byte exponent; evmone otherwise) — this is a pure addition.

uint256 avoids arbitrary-precision bookkeeping and the cgo boundary for the
common small-modulus case (~1.2-1.6x on measured hardware) and is allocation-
free. Base and exponent lengths are unrestricted (the base is reduced mod m
first).

Correctness: TestModexpU256 checks fixed vectors (odd/even moduli, base
0/1/>mod/>256-bit, exp 0/small/large) and TestModexpU256Random fuzzes 20k random
inputs against math/big; existing TestPrecompiledModExp* vectors pass.
@AskAlexSharov
AskAlexSharov force-pushed the alex/modexp-uint256-fastpath branch from ebb545a to 712a736 Compare August 1, 2026 12:17
@AskAlexSharov
AskAlexSharov requested a review from taratorio August 3, 2026 03:07
@yperbasis
yperbasis requested a review from chfast August 3, 2026 08:11
@AskAlexSharov
AskAlexSharov marked this pull request as ready for review August 3, 2026 08:57

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.

Pull request overview

This PR adds a MODEXP fast path in execution/vm that uses a fixed-width uint256 square-and-multiply implementation when the modulus fits in 256 bits, avoiding the default evmone backend for that input class and aiming to reduce overhead/allocations.

Changes:

  • Route MODEXP inputs with modLen <= 32 to a new modexpU256 implementation.
  • Implement modexpU256 with byte-wise base reduction and reciprocal-based modular multiplication.
  • Add unit + randomized cross-check tests comparing the uint256 path against math/big.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
execution/vm/contracts.go Adds modLen <= 32 routing and implements the modexpU256 fixed-width modular exponentiation helper.
execution/vm/modexp_u256_test.go Adds fixed-vector and randomized tests validating modexpU256 against math/big.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread execution/vm/modexp_u256_test.go Outdated

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

execution/vm/contracts.go:648

  • The doc comment says dst must be “zero-filled”, but modexpU256 overwrites all len(dst) bytes via copy, and Run already allocates a fresh zeroed slice. This requirement is misleading; consider documenting the real preconditions instead (e.g. len(dst) == len(mod) and len(mod) <= 32).
// modexpU256 computes base^exp mod modulus for the case where the modulus fits
// in 256 bits, writing the big-endian result into dst (which must be len(modulus)
// bytes and zero-filled). The base and exponent may be any length.

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

I found two input classes where the new routing regresses performance. These are end-to-end benchmarks on Apple M1/arm64 (-benchtime=200ms -count=2). As a control, the intended full-width case with a 32-byte base, 32-byte modulus, and exponent 65537 improves from about 774 ns with evmone to 564 ns with this path.

Comment thread execution/vm/contracts.go Outdated
Comment thread execution/vm/contracts.go Outdated
…y wins

Route to modexpU256 only when the base fits in 256 bits and the modulus is
at least 2^192. Below 2^192 uint256.Reciprocal returns nothing usable and
every modular multiply falls back to a full division; an oversized base was
folded in one byte at a time. Both classes lost to evmone.
@AskAlexSharov AskAlexSharov changed the title execution/vm: uint256 fast path for MODEXP when modulus and base ≤ 256 bits execution/vm: uint256 fast path for MODEXP when the modulus is in [2^192, 2^256) Aug 6, 2026

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

The changes in 51eb334 address both findings from the previous round. I found two remaining valid input classes where the fast path regresses relative to main's evmone path; details are inline. The measurements are end-to-end bigModExp.Run benchmarks on arm64, with the baseline using the same parsing and routing checks before calling evmone.

Comment thread execution/vm/contracts.go
Comment thread execution/vm/contracts.go

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

The zero-base part of b8ac76e fixes the previous finding, and the new correctness tests pass. One padded-exponent performance regression remains; details are inline. Measurements are end-to-end bigModExp.Run benchmarks on Apple M1 with the evmone baseline using the same parsing and routing checks.

Comment thread execution/vm/contracts.go
The byte-wise scan cost one iteration per padding byte, so a 1024-byte
exponent field made the uint256 fast path slower than the evmone default
branch for small exponent values. Consume aligned uint64 chunks first,
then the byte tail.

Full c.Run, 1024-byte exponent field, Apple M4 Max, n=8-10:

                 main (evmone)   before    after
  exp value 0        208.9ns    362.8ns    93.3ns
  exp value 1        282.2ns    398.1ns   135.0ns

A 32-byte exponent field is unchanged within noise (-7.4%).

Copy link
Copy Markdown
Member

Final before/after benchmark summary

This consolidates the original headline cases and all regression/control cases from the review rounds after a3856623.

  • Before: the current code with modexpU256Applicable forced to false, which selects the pre-PR/main backend while keeping identical parsing and routing checks.
  • After: final PR head a3856623.
  • Method: end-to-end bigModExp.Run, Apple M1 arm64, five paired samples; times below are medians.
  • Allocation figures include the result buffer.

Inputs routed to uint256

Input Before After Allocs/op (bytes/op), before → after Result
256-bit modulus, exp 65537 838 ns 563 ns 1 (32 B) → 1 (32 B) 1.49× faster
256-bit modulus, 64-bit exp 5.12 µs 3.51 µs 1 (32 B) → 1 (32 B) 1.45× faster
256-bit modulus, 256-bit exp 21.8 µs 15.6 µs 1 (32 B) → 1 (32 B) 1.42× faster
modulus 2^192+237, 64-bit exp 4.95 µs 4.06 µs 1 (32 B) → 1 (32 B) 1.20× faster

Fixed edge cases

Input Before After Allocs/op (bytes/op), before → after Result
1024-byte exponent field, value 0 380 ns 165 ns 1 (32 B) → 1 (32 B) 2.28× faster
1024-byte exponent field, value 1 440 ns 219 ns 1 (32 B) → 1 (32 B) 2.01× faster
zero base, 1024-byte full exponent 146 ns 72.7 ns 1 (32 B) → 1 (32 B) 2.02× faster
base equal to modulus, 1024-byte full exponent 552 µs 56.8 ns 1 (32 B) → 1 (32 B) ~9,800× faster

Inputs left on their existing backend

Input Before After Allocs/op (bytes/op), before → after Result
modulus 2^192−237, 64-bit exp 5.35 µs 5.17 µs 1 (32 B) → 1 (32 B) ≈ same
128-bit modulus, 64-bit exp 3.09 µs 3.08 µs 1 (32 B) → 1 (32 B) ≈ same
128-bit modulus, exp 65537 550 ns 557 ns 1 (32 B) → 1 (32 B) ≈ same
64-bit modulus, 64-bit exp 1.42 µs 1.43 µs 1 (32 B) → 1 (32 B) ≈ same
64-byte base, exp 1 362 ns 362 ns 1 (32 B) → 1 (32 B) ≈ same
128-byte base, exp 1 465 ns 454 ns 1 (32 B) → 1 (32 B) ≈ same
1024-byte base, exp 1 2.37 µs 2.50 µs 1 (32 B) → 1 (32 B) ≈ same
1024-byte base, exp 65537 2.92 µs 2.94 µs 1 (32 B) → 1 (32 B) ≈ same
1024-byte base, 64-bit exp 6.60 µs 6.85 µs 1 (32 B) → 1 (32 B) ≈ same
2048-bit modulus, exp 3 4.09 µs 4.16 µs 8 (3,496 B) → 8 (3,496 B) ≈ same
2048-bit modulus, exp 65537 23.7 µs 23.6 µs 1 (256 B) → 1 (256 B) ≈ same
2048-bit modulus, 2048-bit exp 5.23 ms 5.14 ms 1 (256 B) → 1 (256 B) ≈ same

The fallback rows execute the same backend before and after; their small movements are benchmark noise. The final routed domain improves by about 18–33%, the previously regressing edge cases are now faster than main, and allocation counts remain unchanged. The uint256 computation itself adds no temporary allocations; the single 32-byte allocation shown on its rows is the result buffer created by bigModExp.Run.

@taratorio
taratorio added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 415932f Aug 7, 2026
136 checks passed
@taratorio
taratorio deleted the alex/modexp-uint256-fastpath branch August 7, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants