Skip to content

execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing - #23231

Draft
AskAlexSharov wants to merge 8 commits into
mainfrom
alex/evmone_023_37
Draft

execution/vm: evmone v0.23.0, MODEXP routing and uint256 windowing#23231
AskAlexSharov wants to merge 8 commits into
mainfrom
alex/evmone_023_37

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #22940. Three changes:

  1. Vendored evmone 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. intx unchanged, capi.cpp needed no changes.
  2. Run routes large moduli to math/big by where math/big changes algorithm, not by a 1-byte exponent.
  3. The uint256 path 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.

routed class main PR delta
2048-bit mod / 2048-bit exp 4.487 ms 2.041 ms −54.50%
2048-bit mod / 256-bit exp 549.5 µs 258.5 µs −52.96%
1024-bit mod / 256-bit exp 147.06 µs 80.79 µs −45.06%
512-bit mod / 256-bit exp 44.78 µs 29.05 µs −35.12%
2048-bit mod / 65537 26.78 µs 18.21 µs −32.01%
256-bit mod / 8192-bit exp 299.5 µs 242.7 µs −18.96%
256-bit mod / 2048-bit exp 74.94 µs 60.95 µs −18.67%
512-bit mod / 64-bit exp 11.68 µs 10.21 µs −12.61%
128-bit mod / 64-bit exp 1.524 µs 1.349 µs −11.48%
256-bit mod / 64-bit exp 2.436 µs 2.186 µs −10.26%
2^192 mod / 64-bit exp 2.425 µs 2.182 µs −10.04%
256-bit mod / 65537 526.4 ns 516.0 ns −1.97%
1024-bit mod / 3 260.6 ns 260.9 ns ~
1024-bit mod / 65537 7.329 µs 7.316 µs ~
2048-bit mod / 3 296.2 ns 297.5 ns ~
base 1024 B, 256-bit mod / 65537 2.204 µs 2.269 µs +2.90%
512-bit mod / 65537 2.319 µs 2.571 µs +10.89%
geomean 11.75 µs 9.438 µs −19.71%

Both regressing classes stay on evmone and are the bump's small-exponent cost, not a routing choice (see the last section). math/big also allocates ~27 objects per call where evmone allocated 1.

Routing

evmone / math/big on the new evmone, above the uint256 domain, on n5 (AMD EPYC 4344P). Lower is better for evmone:

modulus \ exponent 1 B 2 B 4 B 8 B 16 B 32 B 64 B
320-bit 0.62 0.55 0.46 0.46 0.94 0.96 0.96
512-bit 0.90 0.72 0.70 0.64 1.23 1.28 1.31
768-bit 1.08 0.92 0.88 0.83 1.15 1.18 1.19
1024-bit 1.55 1.19 0.98 0.94 1.46 1.56 1.53
2048-bit 2.21 1.63 1.37 1.78 1.80
4096-bit 3.91 2.35 1.86 1.91 1.91
8192-bit 6.55 3.23 2.40 1.99 2.01

The flip between an 8-byte and a 16-byte exponent is math/big switching algorithm: nat.expNN only takes windowed Montgomery above a one-word exponent (if len(y) > 1). The bound is that algorithm change, not a fitted constant:

amd64:  exponent > 64 bits -> math/big from 512-bit moduli up
        otherwise          -> math/big from 1024-bit moduli up

Mispredicts 3 of 30 cells, each by at most 8%. The old rule (modLen > 32 and exponent <= 255) was wrong both ways: 320-bit moduli with tiny exponents went to math/big where evmone is 1.6x faster; 2048-bit moduli with ordinary exponents stayed on evmone where math/big is 1.4x-1.8x faster.

Why math/big wins despite arbitrary precision: its Montgomery inner loop is hand-written assembly with a runtime ADX check (arith_amd64.s, addMulVVWW, hasADX), two carry chains via ADCX/ADOX. evmone's is portable C++ and no compiler forms that pattern — rebuilt with -madx -mbmi2 it gets MULX but zero ADCX/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:

modulus \ exponent 65537 64-bit 256-bit 2048-bit
512-bit 0.39 0.33 0.80 0.78
768-bit 0.52 0.45 0.85 0.78
1024-bit 0.62 0.53 0.89 0.82
1536-bit 0.89 0.81 0.95 0.89
2048-bit 1.16 0.96 1.05 1.02
others: math/big from 2048-bit moduli up

One bound for both exponent classes: at 2048 bits math/big takes 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):

routed class before after delta
1024-bit mod / 64-bit exp 32.58 µs 16.47 µs −49.45%
1024-bit mod / 65537 6.943 µs 4.231 µs −39.07%
1536-bit mod / 64-bit exp 49.50 µs 37.08 µs −25.09%
768-bit mod / 2048-bit exp 410.9 µs 317.3 µs −22.79%
512-bit mod / 2048-bit exp 206.1 µs 163.2 µs −20.81%
512-bit mod / 256-bit exp 26.85 µs 21.28 µs −20.75%
1024-bit mod / 2048-bit exp 592.8 µs 482.6 µs −18.59%
1536-bit mod / 65537 10.47 µs 8.963 µs −14.40%
768-bit mod / 256-bit exp 52.56 µs 45.33 µs −13.76%
1024-bit mod / 256-bit exp 75.83 µs 66.74 µs −11.98%
1536-bit even mod / 256-bit exp 134.1 µs 140.3 µs +4.66%
2048-bit mod, all exponents ~
geomean 26.01 µs 23.62 µs −9.21%

The one regression is the only cell where parity moves the winner: at 1536 bits math/big takes 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:

region why Go cannot take it evmone vs math/big
modulus < 2^192 uint256.Reciprocal yields nothing usable, so every multiply becomes a division 4.6x-5.2x
base > 32 B, small modulus uint256.SetBytes truncates; reducing the base first is the division uint256 avoids 4.5x
320..1024-bit moduli, exponent <= 64 bits 5-16 limbs suit neither a fixed 4-limb type nor allocating arbitrary precision 1.02x-2.2x

The 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-w window costs 2^(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.Int on the stack, so the path stays allocation-free.

256-bit modulus, exponent before after vs evmone
65537 498.5 ns 487.1 ns 1.79x faster
64-bit 2.404 µs 2.161 µs 1.52x
256-bit 9.173 µs 7.968 µs 1.49x
512-bit 18.71 µs 15.58 µs 1.49x
2048-bit 74.43 µs 60.96 µs 1.50x
8192-bit 299.2 µs 243.0 µs 1.49x

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. uint256 has 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, 0x5555 cost 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:

modulus / exponent main PR delta
256-bit / 65537 809.7 ns 873.0 ns +7.81%
256-bit / 64-bit 3.686 µs 3.329 µs −9.69%
256-bit / 256-bit 14.19 µs 11.85 µs −16.48%
256-bit / 512-bit 29.31 µs 23.17 µs −20.95%
256-bit / 2048-bit 118.08 µs 91.77 µs −22.28%
256-bit / 8192-bit 466.6 µs 362.7 µs −22.27%
128-bit / 64-bit 1.449 µs 1.337 µs −7.76%
64-bit / 64-bit 458.8 ns 459.6 ns ~
2048-bit / 65537 26.62 µs 29.55 µs +11.01%
2048-bit / 2048-bit 4.496 ms 3.509 ms −21.96%

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 to math/big would be worse (1.3x). Worth raising upstream, with the missing ADX path.

Tests

  • TestModexpRoutingAgreesRun vs math/big over 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 the uint256 route.
  • 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.
  • Existing TestModexpU256* (20k-case fuzz) and TestPrecompiledModExp* unchanged and passing.

Known limits

  • go.mod points at an unmerged branch commit of evmone v0.23.0 evmone_precompiles#5; needs re-pointing at main once that merges.
  • Bounds are measured on amd64 and arm64 only. Every other target uses the arm64 bounds unmeasured.
  • The width-1 path carries the windowed path's 512-byte table in its stack frame, since Go sizes a frame for all locals regardless of branch.

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 Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants