execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing - #23231
Draft
AskAlexSharov wants to merge 8 commits into
Draft
execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing#23231AskAlexSharov wants to merge 8 commits into
AskAlexSharov wants to merge 8 commits into
Conversation
Upstream replaced the plain square-and-multiply in the modexp precompile with fixed-window (ipsilon/evmone#1618) and then sliding-window (ipsilon/evmone#1631) exponentiation.
Lets the routing in bigModExp.Run be re-derived when a backend changes.
math/big only takes its windowed Montgomery path once the exponent exceeds one word. Route by that boundary instead of by a 1-byte exponent: from 512-bit moduli up for wide exponents, from 1024-bit up otherwise.
Width is chosen from the exponent's bit length and popcount, so sparse exponents such as 65537 keep the plain square-and-multiply cost.
AskAlexSharov
force-pushed
the
alex/evmone_023_37
branch
from
August 13, 2026 06:00
b6414a6 to
36737c4
Compare
The window count assumed set bits cluster enough to share a window. An evenly spread exponent never does, so every window shrank back to one bit and the table build was pure loss: 0x5555 cost 23 modular multiplies against 21 for plain square-and-multiply. Require the modelled saving to beat the table cost twice over. Also derive the math/big word bound from bits.UintSize rather than assuming 64-bit, sweep every window width in the test instead of evmone's breakpoints, and cross-check the routing on even moduli.
AskAlexSharov
force-pushed
the
alex/evmone_023_37
branch
from
August 13, 2026 06:36
c785a75 to
875cb90
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the MODEXP precompile implementation in execution/vm to align with newer evmone behavior and to improve performance by refining backend routing (evmone vs math/big vs uint256) and adding a sliding-window exponentiation path for the uint256 fast path.
Changes:
- Bump
github.com/erigontech/evmone_precompilesto a newer revision (evmone v0.23.0 vendoring). - Replace the prior “small exponent” routing rule with a new
modexpBigIntFaster(exp, modLen)predicate keyed tomath/bigalgorithm thresholds. - Implement sliding-window exponentiation for the
uint256MODEXP path and add/extend routing + windowing tests and benchmarks.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
go.mod |
Updates the evmone_precompiles dependency version. |
go.sum |
Updates checksums for the bumped evmone_precompiles revision. |
execution/vm/contracts.go |
Adds modexpBigIntFaster, adjusts MODEXP routing, and adds sliding-window logic for modexpU256. |
execution/vm/modexp_u256_test.go |
Adds backend benchmarks and new tests to pin routing and validate windowing correctness. |
Suppressed comments (1)
execution/vm/modexp_u256_test.go:286
TestModexpBigIntFasterhard-codes an 8-byte “word” (b[n-8] = 0xff) and uses fixed sizes likeexp(8)/exp(9)for “one-word” / “just over one word”. ButmodexpBigIntFasterkeys offbits.UintSize, so this test becomes incorrect on 32-bit platforms (where a math/big Word is 4 bytes). Consider derivingwordBytes := bits.UintSize/8in the test and using it for the padded helper + the few “one-word” case inputs.
padded := func(n int) []byte { // n-byte field holding a 64-bit value
b := make([]byte, n)
b[n-8] = 0xff
return b
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
math/big's Montgomery inner loop wins on amd64 because of its dual carry-chain assembly (ADCX/ADOX), which has no equivalent elsewhere. The bound was fitted on amd64 and applied everywhere, so on arm64 it routed moduli to the slower backend: a 1024-bit modulus with a 64-bit exponent took 32.5us on math/big against 16.5us on evmone. Keep the amd64 bound and give every other target a bound of 2048-bit moduli, measured on arm64. Routed benchmark on an M4 Max: geomean -9.21%, worst cell -49.7%, and the 2048-bit moduli math/big still wins are unchanged. The backend benchmark now sweeps modulus parity as well, since an even modulus takes a different path in every backend. Parity does not move the crossover on either target.
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.
Follow-up to #22940. Three changes:
v0.21.0->v0.23.0(evmone v0.23.0 evmone_precompiles#5): upstream replaced MODEXP's square-and-multiply with fixed-window (crypto: Use fixed-window exponentiation in modexp ipsilon/evmone#1618) then sliding-window (crypto: Use a sliding window in modexp ipsilon/evmone#1631) exponentiation.intxunchanged,capi.cppneeded no changes.Runroutes large moduli tomath/bigby wheremath/bigchanges algorithm, not by a 1-byte exponent.uint256path gets a sliding window, which execution/vm: uint256 fast path for MODEXP when the modulus is in [2^192, 2^256) #22940 left out.End to end
bigModExp.Run, backend selection included.n5(AMD EPYC 4344P), 10 interleaved rounds,benchtime=500ms, identical benchmark code on both refs.Both regressing classes stay on evmone and are the bump's small-exponent cost, not a routing choice (see the last section).
math/bigalso allocates ~27 objects per call where evmone allocated 1.Routing
evmone / math/bigon the new evmone, above theuint256domain, onn5(AMD EPYC 4344P). Lower is better for evmone:The flip between an 8-byte and a 16-byte exponent is
math/bigswitching algorithm:nat.expNNonly takes windowed Montgomery above a one-word exponent (if len(y) > 1). The bound is that algorithm change, not a fitted constant:Mispredicts 3 of 30 cells, each by at most 8%. The old rule (
modLen > 32and exponent <= 255) was wrong both ways: 320-bit moduli with tiny exponents went tomath/bigwhere evmone is 1.6x faster; 2048-bit moduli with ordinary exponents stayed on evmone wheremath/bigis 1.4x-1.8x faster.Why
math/bigwins despite arbitrary precision: its Montgomery inner loop is hand-written assembly with a runtime ADX check (arith_amd64.s,addMulVVWW,hasADX), two carry chains viaADCX/ADOX. evmone's is portable C++ and no compiler forms that pattern — rebuilt with-madx -mbmi2it getsMULXbut zeroADCX/ADOX, recovering ~6% of a ~78% gap.Other targets
That edge is amd64's alone, so the bound cannot be, and the same table on arm64 (Apple M4 Max, odd moduli) sits almost entirely below 1:
One bound for both exponent classes: at 2048 bits
math/bigtakes every class but a full 64-bit exponent, which it loses by 4%. Below that it loses everywhere, by up to 3x. Targets other than amd64 and arm64 keep these bounds unmeasured, which costs them at most the 2048-bit row.Routed benchmark, same box, bounds the only difference (6 interleaved rounds,
benchtime=300ms):The one regression is the only cell where parity moves the winner: at 1536 bits
math/bigtakes an even modulus and loses an odd one, and one bound cannot have both.What stays on evmone
evmone is still the default and still wins these:
math/biguint256.Reciprocalyields nothing usable, so every multiply becomes a divisionuint256.SetBytestruncates; reducing the base first is the divisionuint256avoidsThe last row narrows to 1.02x-1.06x at 1024 bits, which is why the short-exponent cutover sits there instead of adding a third bound.
The uint256 path
Routing bounds from #22940 unchanged; the exponentiation inside them is now a sliding window. Width comes from bit length and popcount — both methods square once per bit, so only multiplies differ: a width-
wwindow costs2^(w-1)table entries plus one multiply per window, against one per set bit. Sparse exponents like 65537 stay on width 1, the previous code path exactly. The table is a fixed[16]uint256.Inton the stack, so the path stays allocation-free.At the EIP-7823-max exponent the margin was 1.21x before, evmone's own window having nearly closed it; now a flat ~1.5x.
uint256has no assembly — its lead is being a fixed 4-limb type (no limb loop, no bounds checks, no cgo) reducing with a precomputed reciprocal.The window count assumes set bits cluster enough to share a window, which an evenly spread exponent never does. Unguarded,
0x5555cost 23 modular multiplies against square-and-multiply's 21, so the saving must now beat the table cost twice over. With that guard no exponent below 2^22 and none of 5000 random wide exponents does more multiplies than width 1; worst case left is +1.2% on artificially evenly-spaced exponents.The bump on its own
evmone backend only, same box and method:
The window pays from ~64-bit exponents up and costs up to 11% below that:
window_width()returns 1 only up to 7 bits, and the scratch buffer is sized for the maximum width whatever width is picked. That is the whole of the two residual regressions; routing them tomath/bigwould be worse (1.3x). Worth raising upstream, with the missing ADX path.Tests
TestModexpRoutingAgrees—Runvsmath/bigover 910 shapes: 13 modulus widths x 7 exponent widths x 5 base widths x both parities. Parity is its own axis: an even modulus takes a different path in every backend.TestModexpBigIntFaster— the exponent classification that picks the bound (one byte over a word, a one-word value in a padded field) across 14 modulus widths, plus that neither bound can grow down into theuint256route.TestModexpU256Windows— bit lengths 1..300 plus 511/512/513, 1023/1024/1025, 8192, against all-ones, lone-top-bit, alternating and widely-spaced patterns. Sweeping rather than probing fixed edges keeps cases on the heuristic's transitions wherever they move.TestModexpU256*(20k-case fuzz) andTestPrecompiledModExp*unchanged and passing.Known limits
go.modpoints at an unmerged branch commit of evmone v0.23.0 evmone_precompiles#5; needs re-pointing atmainonce that merges.