execution/vm: uint256 fast path for MODEXP when the modulus is in [2^192, 2^256) - #22940
Conversation
d8b4459 to
f6c5031
Compare
388c684 to
ebb545a
Compare
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.
ebb545a to
712a736
Compare
There was a problem hiding this comment.
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
MODEXPinputs withmodLen <= 32to a newmodexpU256implementation. - Implement
modexpU256with byte-wise base reduction and reciprocal-based modular multiplication. - Add unit + randomized cross-check tests comparing the
uint256path againstmath/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.
There was a problem hiding this comment.
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
dstmust be “zero-filled”, butmodexpU256overwrites alllen(dst)bytes viacopy, andRunalready allocates a fresh zeroed slice. This requirement is misleading; consider documenting the real preconditions instead (e.g.len(dst) == len(mod)andlen(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
left a comment
There was a problem hiding this comment.
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.
…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.
taratorio
left a comment
There was a problem hiding this comment.
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.
taratorio
left a comment
There was a problem hiding this comment.
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.
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%).
Final before/after benchmark summaryThis consolidates the original headline cases and all regression/control cases from the review rounds after
Inputs routed to uint256
Fixed edge cases
Inputs left on their existing backend
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 |
See also: ipsilon/evmone#1618
Summary
Add a fixed-width
uint256square-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 theevmonedefault. This is a pure addition — all existing branches are unchanged frommain.uint256avoids arbitrary-precision bookkeeping and the cgo boundary for this case, and is allocation-free.Routing
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.Reciprocalreturns nothing usable whenm[3] == 0, andMulModWithReciprocalthen falls back to a fulludivremon every multiply. Since the operand is a byte field, the test is on the modulus value, not onmodLen— a 32-byte field holding a 128-bit modulus stays onevmone.baseLen <= 32. A wider base has to be folded in before exponentiation, which costs more than the wholeevmonecall. It is also a correctness precondition:uint256.SetBytessilently truncates above 32 bytes.Benchmarks — time (median ns/op)
The tables compare backends directly. Which branch erigon actually uses per row:
pbig(geth's patchedmath/bigfork) 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
arm64
For the rows this PR touches,
uint256is faster than the previous (evmone) path on both machines: e.g. x86785 -> 485(65537),19,599 -> 12,844(256-bit exp); arm64468 -> 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
In
Runall paths write into one sharedresultbuffer, so these are each algorithm's own temporary allocations. Theuint256path 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 <= 32alone and folded an oversized base in byte by byte. @taratorio found that both moved input classes thatevmonehandles better. End-to-endRunbenchmarks, Apple M4 Max,-benchtime=300ms -count=6:The controls are the rows that must stay on the
uint256path, and they do; the ~5 ns is the routing predicate itself. All other rows are back onevmone, i.e. atmain'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 againstevmone's 1.3 µs, so it would not have recovered the class.Correctness
TestModexpU256Applicablepins the routing boundaries, including2^192−1vs2^192, moduli padded with leading zero bytes, and bases of 33 and 1024 bytes.TestModexpU256cross-checks fixed vectors againstmath/big(odd/even/power-of-two moduli, base0/1/>mod, exponent0/small/full-width).TestModexpU256Randomfuzzes 20,000 random inputs againstmath/big, drawing moduli with randomly zeroed leading bytes so the boundary is hit from both sides.TestPrecompiledModExp*vectors exercise this path and pass.uint256.MulModWithReciprocal), valid for odd and even moduli alike.Scope / follow-ups
math/big/evmonebranches are untouched.uint256gives 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.2^192could be handled without division by reducing on a narrower fixed width (e.g. 192-bit operands with a 384/192 reduce), butuint256exposes no such primitive, so they stay onevmone.