Skip to content

execution/vm: evmone_precompiles v0.23.0 - #23231

Closed
AskAlexSharov wants to merge 26 commits into
mainfrom
alex/evmone_023_37
Closed

AskAlexSharov wants to merge 26 commits into
mainfrom
alex/evmone_023_37

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

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:

modulus / exponent main PR delta
2048-bit / 2048-bit 4.496 ms 3.509 ms −21.96%
256-bit / 8192-bit 466.6 µs 362.7 µs −22.27%
256-bit / 2048-bit 118.08 µs 91.77 µs −22.28%
256-bit / 64-bit 3.686 µs 3.329 µs −9.69%
256-bit / 65537 809.7 ns 873.0 ns +7.81%
2048-bit / 65537 26.62 µs 29.55 µs +11.01%

The dependency bump exposed three Erigon-side changes in the same precompile, kept together because they share one benchmark (BenchmarkModexpBackends) and one routing decision in bigModExp.Run:

Backend routing (modexpBigIntFaster). The old rule sent a modulus over 32 bytes to math/big when the exponent fit in one byte. math/big only 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 to math/big at 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 moduli math/big still wins are unchanged. On amd64 the low bounds now apply only with ADX+BMI2, since math/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 guard 0x5555 cost 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.go pins the routing (both math/big bounds, every window width, even and odd moduli) against math/big as the oracle.

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.
@AskAlexSharov AskAlexSharov changed the title execution/vm: evmone v0.23.0 (windowed MODEXP) execution/vm: evmone v0.23.0, route large-modulus MODEXP to math/big Aug 13, 2026
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 AskAlexSharov changed the title execution/vm: evmone v0.23.0, route large-modulus MODEXP to math/big execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing Aug 13, 2026
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.

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 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_precompiles to a newer revision (evmone v0.23.0 vendoring).
  • Replace the prior “small exponent” routing rule with a new modexpBigIntFaster(exp, modLen) predicate keyed to math/big algorithm thresholds.
  • Implement sliding-window exponentiation for the uint256 MODEXP 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

  • TestModexpBigIntFaster hard-codes an 8-byte “word” (b[n-8] = 0xff) and uses fixed sizes like exp(8) / exp(9) for “one-word” / “just over one word”. But modexpBigIntFaster keys off bits.UintSize, so this test becomes incorrect on 32-bit platforms (where a math/big Word is 4 bytes). Consider deriving wordBytes := bits.UintSize/8 in 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.

Comment thread execution/vm/modexp_u256_test.go Resolved
AskAlexSharov and others added 10 commits August 15, 2026 11:11
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.
@AskAlexSharov AskAlexSharov changed the title execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing execution/vm: evmone_precompiles v0.23.0 Sep 2, 2026
@AskAlexSharov
AskAlexSharov marked this pull request as ready for review September 2, 2026 06:41
Comment thread execution/vm/modexp_u256_test.go Resolved
@taratorio
taratorio requested a review from chfast September 2, 2026 10:25

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

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 chfast 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.

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.

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.

🔵 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

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

Two blocking routing regressions remain:

  1. modexpBigIntFaster treats 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/big 161 us, evmone v0.23 100 us
    • sparse: math/big 161 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.

  2. 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.

@chfast

chfast commented Sep 3, 2026

Copy link
Copy Markdown
Member

What C++ compilers do you use on each platform?

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author
erigon@dev-bm-e3-ethmainnet-n0:~/erigon (alex/all_dbg6_37)$ cc -v
Using built-in specs.
COLLECT_GCC=cc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-linux-gnu/15/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none:amdgcn-amdhsa
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 15.2.0-14ubuntu1~24~ppa1' --with-bugurl=file:///usr/share/doc/gcc-15/README.Bugs --enable-languages=c,ada,c++,go,d,fortran,objc,obj-c++,m2,rust,cobol,algol68 --prefix=/usr --with-gcc-major-version-only --program-suffix=-15 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/libexec --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --enable-bootstrap --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-libstdcxx-backtrace --enable-gnu-unique-object --disable-vtable-verify --enable-plugin --enable-default-pie --with-system-zlib --enable-libphobos-checking=release --with-target-system-zlib=auto --enable-objc-gc=auto --enable-multiarch --disable-werror --enable-cet --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none=/build/gcc-15-9rFPkZ/gcc-15-15.2.0/debian/tmp-nvptx/usr,amdgcn-amdhsa=/build/gcc-15-9rFPkZ/gcc-15-15.2.0/debian/tmp-gcn/usr --enable-offload-defaulted --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu --with-build-config=bootstrap-lto-lean --enable-link-serialization=2
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 15.2.0 (Ubuntu 15.2.0-14ubuntu1~24~ppa1)




cc -v
Apple clang version 21.0.0 (clang-2100.1.1.101)
Target: arm64-apple-darwin25.6.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
``

@taratorio

Copy link
Copy Markdown
Member

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.

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.
@AskAlexSharov
AskAlexSharov marked this pull request as draft September 6, 2026 03:36
auto-merge was automatically disabled September 6, 2026 03:36

Pull request was converted to draft

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

@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 BenchmarkModexpBackends with dense and sparse exponents either side of each word boundary (65-bit, 129-bit), and with 512- and 1024-byte moduli, which the old ladder stopped short of. ns/op, ratio = big/evmone, >1 means math/big loses:

modulus exp65537 exp64bit exp65bit dense exp65bit sparse exp256bit exp2048bit
2048-bit 0.87 0.95 3.63 2.77 1.23 1.15
4096-bit 0.58 0.69 3.31 1.94 0.94 0.82
8192-bit 0.40 0.18 0.92 0.73 0.88 0.67

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.

modexpBigIntFaster now has three bands — <= 1 word, 1 word < exp <= 256 bits, > 256 bits — each with its own modulus bound. arm64: 256 / 1024 / 512 bytes. The 65-bit cases at a 2048-bit modulus go back to evmone.

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 01ba10b: modexpBigIntFaster measures bytes.TrimLeft(mod, "\x00"), not the declared field. Added padded-modulus benchmark cases (padded/mod256in2048, padded/mod512in2048) so the fixture stops forcing full width, and TestModexpBigIntFaster asserts a 64-bit modulus in a wide field routes on its own width for every exponent band.

Boundary coverage is pinned in TestModexpBigIntFaster (exponent at and just over 32 bytes, one word in a padded field, one word in a 1024-byte field).

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

chfast commented Sep 8, 2026

Copy link
Copy Markdown
Member

I suggest doing this first: #23874.

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

@chfast makes sense, waiting on #23874. The band bounds here were measured without the -march / -O3 cgo flags, so they describe an evmone that is ~2.9% slower on modexp than the one that will ship. I will re-run BenchmarkModexpBackends and re-derive the three bounds once it lands.

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

Two blockers remain:

  1. The new wide-exponent band still misroutes the first input above 256 bits. On Apple M2 Max with the shipping -O3 -DNDEBUG C++ 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. BenchmarkModexpBackends still 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.

  2. Current CI lint fails at execution/vm/modexp_u256_test.go:164: packModexpInput needs a blank line before its declaration for gofumpt. Please fix the formatting and rerun lint.

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

closing in favor of #25100

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants