Repository navigation
execution/vm: evmone_precompiles v0.23.0 - #23231
AskAlexSharov wants to merge 26 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.
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.
c785a75 to
875cb90
Compare
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.
State the math/big reason once at modexpBigIntFaster, move the window heuristic's second paragraph to the PR body, and say why the width-1 loop is kept separate.
The ref was a branch, so the simulator was rebuilt from its moving head on every run while the fixtures stayed at v8.1.2. The branch moved on 08-31 and every merge-queue run since errors all 25122 tests.
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for one reproduced routing regression and the two existing unresolved review concerns.
The new modexpBigIntFaster rule treats every exponent above one machine word as the same performance class, but the backend benchmark jumps from 64-bit directly to 256-bit exponents. On Apple M2 Max with a full-width odd 2048-bit modulus, five 300 ms rounds gave these medians:
| exponent | math/big | evmone |
|---|---|---|
| dense 65-bit | 158.2 us | 100.2 us |
| sparse 65-bit (2^64+1) | 158.6 us | 80.2 us |
| random 256-bit | 316.2 us | 343.0 us |
The arm64 256-byte bound therefore routes both 65-bit cases from evmone on main to math/big in this PR, making them about 58% and 98% slower. Please benchmark the dense and sparse just-over-one-word boundary and use a finer exponent-size rule, or conservatively keep that range on evmone.
The open ADX portability thread also needs a non-ADX amd64 result or fallback, and the PR description needs to cover and benchmark the Erigon-side routing and uint256 changes in addition to the dependency bump.
chfast
left a comment
There was a problem hiding this comment.
What is the general direction here? To be fastest in the each benchmark? The benchmarks are missing gas/s values. You may be optimizing a case which already runs 1000Mgas/s.
math/big's Montgomery loop falls back to MULQ without ADX/BMI2, so the measured crossover does not hold on the release baseline's older CPUs.
A publish/commit/unpublish cycle landing between BeginTemporalRo and the LatestSD capture left the head block visible in neither layer, so the call failed with "block not found: N". Use BeginTemporalRoWithOverlay, which retries when the publish sequence moves around the open.
The pinned handle already carries the overlay, so CheckBlockExecuted does not need to re-resolve it through WithOverlay.
There was a problem hiding this comment.
🔵 Needs a closer look
The window-selection performance behavior lacks direct regression assertions.
Review details
Suppressed comments (1)
execution/vm/contracts.go:630
- The existing tests verify exponentiation results but never assert this selector's output, so changing a transition or dropping the sparse-exponent guard can undo the intended performance behavior while every test still passes. Add table-driven cases for representative
(expBits, expOnes)pairs that select widths 1–5, including the guard boundary and exponent 65537.
func modexpU256WindowWidth(expBits, expOnes int) int {
- Files reviewed: 6/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…nto alex/evmone_023_37
yperbasis
left a comment
There was a problem hiding this comment.
Two blocking routing regressions remain:
-
modexpBigIntFastertreats every nonzero exponent prefix above one machine word as one class. On Apple M2 Max with Go 1.26, a full-width 2048-bit modulus and a 65-bit exponent measured:- dense:
math/big161 us, evmone v0.23 100 us - sparse:
math/big161 us, evmone v0.23 80 us
Against the previous evmone path, the selected backend is about 13% slower for dense exponents and 117% slower for sparse exponents. Crossovers are also non-monotonic at later word boundaries, so the current boolean classification is not sufficient. Please benchmark dense and sparse exponents immediately above each word boundary and use enough exponent information to avoid these regressions.
- dense:
-
The decision uses the declared modulus length, although both backends ignore leading zero modulus bytes. Existing tests and benchmarks force the modulus to full width. With a 256-byte modulus field containing only a 256-bit significant modulus and a sparse 65-bit exponent, the new route measured about 10.9 us versus 2.44 us through the previous evmone path, around 4.5x slower. Please add padded-modulus coverage and classify by significant modulus width, or retain evmone for these inputs.
|
What C++ compilers do you use on each platform? |
|
I also dont know. modexp is not in the "red" on benchmarkoor. so not really sure what's happening with these series of changes. is modexp used a lot in blocks? what is the justification? or it's just for fun? |
Neither backend works on a leading zero modulus byte, so a narrow modulus in a wide field was classified as if it were wide.
Pull request was converted to draft
|
@yperbasis reproduced both regressions and re-derived the rule. Apple M4 Max, arm64, Go 1.26, medians of 3 x 200ms rounds, full-width odd modulus. Point 1 — the boolean classification was wrong, and in the direction you said. Extended
So the crossover is not monotonic in exponent width: lowest for a sub-word exponent, highest just above one word (math/big turns on its window but the exponent is too short to repay the table), then falls again once the exponent is wide. A boolean cannot express that, which is why the 65-bit cases were being routed to math/big at 3.6x the cost.
On amd64 I have no measurement for the middle band, so it is set past the EIP-7823 operand cap: those inputs keep the pre-PR routing (evmone) rather than inheriting a bound derived from a different exponent shape. The narrow and wide bounds are unchanged, as is the non-ADX fallback. Point 2 — significant modulus width. Already fixed in Boundary coverage is pinned in |
BenchmarkModexpBackends jumped 64-bit to 256-bit exponents, so it never measured the band where math/big turns on its window without being long enough to repay the table. Adding dense and sparse exponents either side of each word boundary, plus 512- and 1024-byte moduli and padded-modulus cases, shows the crossover is not monotonic in exponent width.
7c2556a to
2b5db12
Compare
|
I suggest doing this first: #23874. |
yperbasis
left a comment
There was a problem hiding this comment.
Two blockers remain:
-
The new wide-exponent band still misroutes the first input above 256 bits. On Apple M2 Max with the shipping
-O3 -DNDEBUGC++ flags, five 300 ms rounds for a 512-byte odd modulus and sparse 257-bit exponent measured:- selected
math/big: 1.514 ms - evmone v0.23: 1.182 ms
- current-main evmone: 1.115 ms
The selected backend is 28% slower than the PR evmone backend and about 36% slower than the current-main path. Dense 257-bit inputs are also 11% slower through
math/big, and the sparse regression remains at 1024-byte moduli. Under EIP-2565, this case falls from about 314 to 231 Mgas/s.BenchmarkModexpBackendsstill jumps from 256 to 2048 bits, so it misses the first input assigned to the 512-byte wide-band bound. Please add exact 257-bit dense and sparse cases and revise the routing bound. - selected
-
Current CI lint fails at
execution/vm/modexp_u256_test.go:164:packModexpInputneeds a blank line before its declaration forgofumpt. Please fix the formatting and rerun lint.
|
closing in favor of #25100 |
Bumps the vendored evmone precompiles to v0.23.0 (erigontech/evmone_precompiles#5, now merged). Upstream replaced MODEXP's square-and-multiply with fixed-window then sliding-window exponentiation.
evmone backend only,
n5(AMD EPYC 4344P), 10 interleaved rounds,benchtime=500ms:The dependency bump exposed three Erigon-side changes in the same precompile, kept together because they share one benchmark (
BenchmarkModexpBackends) and one routing decision inbigModExp.Run:Backend routing (
modexpBigIntFaster). The old rule sent a modulus over 32 bytes tomath/bigwhen the exponent fit in one byte.math/bigonly takes its windowed Montgomery path once the exponent exceeds one word, so the real crossover sits either side of that boundary, not at a 1-byte exponent. The bounds are per-target: on arm64 the old amd64-fitted bound routed a 1024-bit modulus with a 64-bit exponent tomath/bigat 32.5 µs against evmone's 16.5 µs. Routed benchmark on an M4 Max after the split: geomean −9.21%, worst cell −49.7%, and the 2048-bit modulimath/bigstill wins are unchanged. On amd64 the low bounds now apply only with ADX+BMI2, sincemath/big's Montgomery inner loop drops to its MULQ path without them and the release baseline covers such CPUs.uint256 exponentiation. The
[2^192, 2^256)modulus path moves from square-and-multiply to a sliding window, width chosen from the exponent's bit length and popcount. The table has to pay for itself twice over before a wider window is taken, so sparse exponents such as 65537 stay on width 1 — without that guard0x5555cost 23 modular multiplies against square-and-multiply's 21. The odd-power table is a stack array, so the path still allocates nothing.modexp_u256_test.gopins the routing (bothmath/bigbounds, every window width, even and odd moduli) againstmath/bigas the oracle.