From cb87038b7ce7d4e533d2745e0c6ba802a6ce4435 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Thu, 16 Jul 2026 08:29:29 -0300 Subject: [PATCH 1/3] core/vm: memoize MULMOD modulus reciprocal uint256.MulMod recomputes Reciprocal(m) on every call for full-width (4-word) moduli, and that reciprocal is ~60% of the call cost. Long runs of modular multiplication against a single fixed modulus are a common on-chain pattern, so cache (modulus, reciprocal) per EVM instance and reuse it via MulModWithReciprocal when the modulus repeats. The result is a pure function of (x, y, m) and identical to stock: on a miss the reciprocal is recomputed exactly as before; on a hit the cached value is what stock would have computed. Gas is unchanged, so no fork gate is needed. The memo is per-EVM scratch state like depth/returnData; the EVM is single-goroutine and not thread safe, so no synchronization. Mainnet fork experiment (58,867 blocks / 6.75M txs): execution 3.21 -> 2.75 ms/Mgas (~14%), MULMOD 223 -> 148 ns/op. Microbench hit path ~3x, zero allocations. --- core/vm/evm.go | 4 + core/vm/instructions.go | 2 +- core/vm/interpreter_dispatch.go | 2 +- core/vm/mulmod_memo.go | 66 +++++++ core/vm/mulmod_memo_test.go | 300 ++++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 core/vm/mulmod_memo.go create mode 100644 core/vm/mulmod_memo_test.go diff --git a/core/vm/evm.go b/core/vm/evm.go index 5f19d0e5b5..f492d6fbed 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -185,6 +185,10 @@ type EVM struct { readOnly bool // Whether to throw on stateful modifications returnData []byte // Last CALL's return data for subsequent reuse + // mulmodMemo caches the reciprocal of the last full-width MULMOD modulus so + // that repeated moduli skip uint256.MulMod's internal Reciprocal recompute. + mulmodMemo modReciprocal + // interrupt is the block-building timeout flag. When set, the interpreter // checks it on every opcode across all call depths (Call, DelegateCall, // StaticCall, CallCode, Create). Use SetInterrupt to configure. diff --git a/core/vm/instructions.go b/core/vm/instructions.go index db0d396dd8..fadf664eff 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -199,7 +199,7 @@ func opAddmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { func opMulmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek() - z.MulMod(&x, &y, z) + evm.mulmodMemo.mulmod(&x, &y, z) return nil, nil } diff --git a/core/vm/interpreter_dispatch.go b/core/vm/interpreter_dispatch.go index c182fec1b3..8a6770b0ae 100644 --- a/core/vm/interpreter_dispatch.go +++ b/core/vm/interpreter_dispatch.go @@ -140,7 +140,7 @@ func (evm *EVM) runSwitch( x := stack.data[stack.top+1] y := stack.data[stack.top] z := &stack.data[stack.top-1] - z.MulMod(&x, &y, z) + evm.mulmodMemo.mulmod(&x, &y, z) case SIGNEXTEND: gasAccum += GasFastStep if stack.top < 2 { diff --git a/core/vm/mulmod_memo.go b/core/vm/mulmod_memo.go new file mode 100644 index 0000000000..c37fe2da1a --- /dev/null +++ b/core/vm/mulmod_memo.go @@ -0,0 +1,66 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package vm + +import "github.com/holiman/uint256" + +// modReciprocal memoizes uint256.Reciprocal for the last full-width (4-word) +// MULMOD modulus seen by an EVM instance. +// +// uint256.MulMod, for a full-width modulus, computes Reciprocal(m) internally +// and then reduces with it; the reciprocal is ~60% of that call's cost and is +// thrown away every time. A common on-chain pattern is a long run of modular +// multiplications against a single fixed-prime modulus (field arithmetic used +// broadly by cryptographic and verification contracts), so a one-entry memo of +// (modulus, reciprocal) hits almost always and elides the recompute. +// +// No synchronization: the memo is per-EVM scratch state, like depth and +// returnData. The EVM "should never be reused and is not thread safe" (see the +// EVM type doc); every construction site builds a fresh instance and each +// executes on a single goroutine, so an unsynchronized field is safe here. +type modReciprocal struct { + mod uint256.Int + mu [5]uint64 + ok bool +} + +// mulmod computes z = (x * y) % z in place, mirroring the exact semantics of +// z.MulMod(x, y, z) — including m == 0 yielding 0 — but reuses the memoized +// reciprocal when the modulus repeats. +// +// Correctness is a library guarantee, not a re-derivation: MulModWithReciprocal +// with mu == Reciprocal(m) produces the identical result to MulMod, because +// MulMod computes exactly that reciprocal and reduction internally. The memo +// only changes how the reciprocal is obtained (cached vs recomputed), never the +// arithmetic — the output is a pure function of (x, y, m), independent of cache +// state. On a miss we recompute Reciprocal exactly as stock would. +// +// Only full-width (z[3] != 0) moduli take the memo path; narrower moduli — and +// m == 0 — never reach the reciprocal reduction inside MulMod, so they fall +// through to the stock call unchanged. +func (mm *modReciprocal) mulmod(x, y, z *uint256.Int) { + if z[3] != 0 { + if !mm.ok || mm.mod != *z { + mm.mod = *z + mm.mu = uint256.Reciprocal(z) + mm.ok = true + } + z.MulModWithReciprocal(x, y, z, &mm.mu) + return + } + z.MulMod(x, y, z) +} diff --git a/core/vm/mulmod_memo_test.go b/core/vm/mulmod_memo_test.go new file mode 100644 index 0000000000..a01234c243 --- /dev/null +++ b/core/vm/mulmod_memo_test.go @@ -0,0 +1,300 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package vm + +import ( + "math/rand" + "testing" + + "github.com/holiman/uint256" +) + +// A representative 254-bit prime modulus, exercising the full-width (4-word) +// reciprocal path that the memo targets. Long runs of modular multiplication +// against a single such modulus are a common on-chain pattern. +var testPrime = uint256.MustFromHex("0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47") + +// mulmodEdges collects moduli that stress boundaries of the reduction: zero and +// small values (which bypass the memo path), the target prime, all-ones, and a +// value whose top bit is set. +var mulmodEdges = []*uint256.Int{ + uint256.NewInt(0), + uint256.NewInt(1), + uint256.NewInt(2), + testPrime, + uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + uint256.MustFromHex("0x10000000000000000"), + uint256.MustFromHex("0x8000000000000000000000000000000000000000000000000000000000000000"), +} + +// TestMulmodMemoDifferential checks the memoized mulmod against stock +// uint256.MulMod across random operands, alternating moduli (memo misses), +// repeated moduli (memo hits), sub-4-word moduli, and edge values. The EVM +// aliases destination and modulus (z.MulMod(&x, &y, z)); the test mirrors that. +func TestMulmodMemoDifferential(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(1)) + + var mm modReciprocal + var want, got uint256.Int + for i := 0; i < 500000; i++ { + var x, y, m uint256.Int + for w := 0; w < 4; w++ { + x[w] = rng.Uint64() + y[w] = rng.Uint64() + m[w] = rng.Uint64() + } + switch i % 8 { + case 0, 1: + m = *mulmodEdges[rng.Intn(len(mulmodEdges))] + case 2: + m[3] = 0 // 3-word modulus, bypasses the memo path + case 3: + m[3], m[2], m[1] = 0, 0, 0 // 1-word modulus + } + // The EVM aliases destination and modulus: z.MulMod(&x, &y, z). + want = m + want.MulMod(&x, &y, &want) + got = m + mm.mulmod(&x, &y, &got) + if want != got { + t.Fatalf("mismatch: x=%s y=%s m=%s stock=%s memo=%s", x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex()) + } + } +} + +// TestMulmodMemoRepeatedModulus exercises the hit path explicitly: many +// multiplications against one modulus with the memo primed. +func TestMulmodMemoRepeatedModulus(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(2)) + + var mm modReciprocal + var want, got uint256.Int + for i := 0; i < 100000; i++ { + var x, y uint256.Int + for w := 0; w < 4; w++ { + x[w] = rng.Uint64() + y[w] = rng.Uint64() + } + x.Mod(&x, testPrime) + y.Mod(&y, testPrime) + want = *testPrime + want.MulMod(&x, &y, &want) + got = *testPrime + mm.mulmod(&x, &y, &got) + if want != got { + t.Fatalf("mismatch at %d: x=%s y=%s stock=%s memo=%s", i, x.Hex(), y.Hex(), want.Hex(), got.Hex()) + } + } +} + +// TestMulmodMemoAliasing verifies the memo matches stock under every operand +// pointer-aliasing combination, not just the dest==modulus case the EVM +// currently produces. Each case runs stock and memo with identical aliasing and +// compares bit-for-bit, guarding against future changes to the call convention. +func TestMulmodMemoAliasing(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(3)) + base := []*uint256.Int{testPrime, uint256.NewInt(0), uint256.NewInt(1)} + + // Each alias mode runs one operation; stock() is compared to memo(), with + // a freshly primed/loaded operand set per invocation so aliasing writes do + // not leak between the two runs. + modes := []struct { + name string + run func(op func(x, y, z *uint256.Int), x, y, m uint256.Int) uint256.Int + }{ + {"dest==mod", func(op func(x, y, z *uint256.Int), x, y, m uint256.Int) uint256.Int { + z := m + op(&x, &y, &z) + return z + }}, + {"x==dest==mod", func(op func(x, y, z *uint256.Int), _, y, m uint256.Int) uint256.Int { + z := m + op(&z, &y, &z) + return z + }}, + {"y==dest==mod", func(op func(x, y, z *uint256.Int), x, _, m uint256.Int) uint256.Int { + z := m + op(&x, &z, &z) + return z + }}, + {"x==y==dest==mod", func(op func(x, y, z *uint256.Int), _, _, m uint256.Int) uint256.Int { + z := m + op(&z, &z, &z) + return z + }}, + } + + for i := 0; i < 50000; i++ { + var x, y, m uint256.Int + for w := 0; w < 4; w++ { + x[w] = rng.Uint64() + y[w] = rng.Uint64() + m[w] = rng.Uint64() + } + if i%4 == 0 { + m = *base[rng.Intn(len(base))] + } + for _, mode := range modes { + // Fresh memo each mode so both hit and miss states are exercised + // across the loop (modulus varies), while staying deterministic. + var mm modReciprocal + want := mode.run(func(x, y, z *uint256.Int) { z.MulMod(x, y, z) }, x, y, m) + got := mode.run(mm.mulmod, x, y, m) + if want != got { + t.Fatalf("%s mismatch: x=%s y=%s m=%s stock=%s memo=%s", + mode.name, x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex()) + } + } + } +} + +// TestMulmodMemoEdgeCases pins the boundary results explicitly, most importantly +// that a zero modulus yields zero via the non-memo path. +func TestMulmodMemoEdgeCases(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + x, y, m *uint256.Int + }{ + {"zero modulus", uint256.NewInt(7), uint256.NewInt(9), uint256.NewInt(0)}, + {"modulus one", uint256.NewInt(123456), uint256.NewInt(789), uint256.NewInt(1)}, + {"single-word modulus", uint256.NewInt(0xdeadbeef), uint256.NewInt(0xfeedface), uint256.NewInt(97)}, + {"three-word modulus", uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffff"), + uint256.MustFromHex("0x123456789abcdef0"), uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffff")}, + {"full-width prime", testPrime, testPrime, testPrime}, + {"all ones modulus", uint256.MustFromHex("0xdeadbeefcafebabe1234"), + uint256.MustFromHex("0x99887766554433221100"), + uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")}, + } + + var mm modReciprocal + for _, c := range cases { + want := *c.m + want.MulMod(c.x, c.y, &want) + got := *c.m + mm.mulmod(c.x, c.y, &got) + if want != got { + t.Errorf("%s: stock=%s memo=%s", c.name, want.Hex(), got.Hex()) + } + } +} + +// TestMulmodMemoModulusSwitch drives the cache-invalidation sequence directly: +// prime on modulus A (miss), reuse A (hit), switch to B (miss), reuse B (hit), +// then a sub-4-word modulus (bypass), each verified against stock. +func TestMulmodMemoModulusSwitch(t *testing.T) { + t.Parallel() + + a := testPrime + b := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") // secp256k1 order + small := uint256.NewInt(1_000_003) + x := uint256.MustFromHex("0xf123456789abcdeffedcba98765432100011223344556677889900aabbccddee") + y := uint256.MustFromHex("0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + + var mm modReciprocal + for step, m := range []*uint256.Int{a, a, b, b, a, small, a} { + want := *m + want.MulMod(x, y, &want) + got := *m + mm.mulmod(x, y, &got) + if want != got { + t.Fatalf("step %d m=%s: stock=%s memo=%s", step, m.Hex(), want.Hex(), got.Hex()) + } + } +} + +// FuzzMulmodMemo differentially fuzzes the memo against stock uint256.MulMod +// over arbitrary operands, exercising both a miss (first call) and a hit +// (second call with the same modulus) per input. +func FuzzMulmodMemo(f *testing.F) { + f.Add([]byte{0x2}, []byte{0x3}, []byte{0x5}) + f.Add(testPrime.Bytes(), testPrime.Bytes(), testPrime.Bytes()) + f.Add([]byte{0x1}, []byte{0x1}, []byte{}) // empty -> zero modulus + f.Add( + uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").Bytes(), + uint256.MustFromHex("0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210").Bytes(), + testPrime.Bytes(), + ) + + f.Fuzz(func(t *testing.T, xb, yb, mb []byte) { + x := new(uint256.Int).SetBytes(xb) + y := new(uint256.Int).SetBytes(yb) + m := new(uint256.Int).SetBytes(mb) + + var mm modReciprocal + // Call twice: first primes (miss), second uses the cache (hit). Both + // must equal stock, computed with the same dest==modulus aliasing. + for pass := 0; pass < 2; pass++ { + want := *m + want.MulMod(x, y, &want) + got := *m + mm.mulmod(x, y, &got) + if want != got { + t.Fatalf("pass %d mismatch: x=%s y=%s m=%s stock=%s memo=%s", + pass, x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex()) + } + } + }) +} + +// BenchmarkMulmod compares stock uint256.MulMod against the memo on both the +// hit path (repeated modulus) and the miss path (modulus changes every call). +// The hit-path speedup reflects the elided Reciprocal recompute. +func BenchmarkMulmod(b *testing.B) { + x := uint256.MustFromHex("0xf123456789abcdeffedcba98765432100011223344556677889900aabbccddee") + y := uint256.MustFromHex("0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + altPrime := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") + + b.Run("stock", func(b *testing.B) { + var z uint256.Int + for i := 0; i < b.N; i++ { + z = *testPrime + z.MulMod(x, y, &z) + } + _ = z + }) + b.Run("memo/hit", func(b *testing.B) { + var mm modReciprocal + var z uint256.Int + for i := 0; i < b.N; i++ { + z = *testPrime + mm.mulmod(x, y, &z) + } + _ = z + }) + b.Run("memo/miss", func(b *testing.B) { + var mm modReciprocal + var z uint256.Int + for i := 0; i < b.N; i++ { + // Alternate modulus every call to force a recompute (worst case). + if i&1 == 0 { + z = *testPrime + } else { + z = *altPrime + } + mm.mulmod(x, y, &z) + } + _ = z + }) +} From 9615dac84cdd4069f5fcaf5b53ab837bdeb328c8 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Thu, 16 Jul 2026 08:49:41 -0300 Subject: [PATCH 2/3] core/vm: pin MULMOD memo cache contract (white-box test) Add TestMulmodMemoCacheState asserting the memo is primed with the exact reciprocal on a full-width modulus, refreshed on a modulus switch, and never primed by a narrow modulus. The differential/fuzz tests verify output equivalence but cannot distinguish cached/recomputed/bypassed states, since the memo is behavior-neutral by design; this test pins that internal contract and brings changed-line mutation coverage to 100%. --- core/vm/mulmod_memo_test.go | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/core/vm/mulmod_memo_test.go b/core/vm/mulmod_memo_test.go index a01234c243..45e2bd7ae4 100644 --- a/core/vm/mulmod_memo_test.go +++ b/core/vm/mulmod_memo_test.go @@ -224,6 +224,54 @@ func TestMulmodMemoModulusSwitch(t *testing.T) { } } +// TestMulmodMemoCacheState pins the caching contract directly (white-box), +// which differential output tests cannot: because every path computes the +// correct result, only the memo's internal state distinguishes "cached", +// "recomputed", and "bypassed". It asserts a full-width modulus primes the +// memo with the exact reciprocal, a modulus switch refreshes it, and a narrow +// modulus never primes it (so the full-width-only reciprocal path is taken +// exactly when intended). +func TestMulmodMemoCacheState(t *testing.T) { + t.Parallel() + + x := uint256.NewInt(0xdeadbeef) + y := uint256.NewInt(0xfeedface) + + // A full-width modulus primes the memo with Reciprocal(m). + var mm modReciprocal + z := *testPrime + mm.mulmod(x, y, &z) + if !mm.ok { + t.Fatal("full-width modulus did not prime the memo") + } + if mm.mod != *testPrime { + t.Fatalf("cached modulus = %s, want %s", mm.mod.Hex(), testPrime.Hex()) + } + if mm.mu != uint256.Reciprocal(testPrime) { + t.Fatal("cached reciprocal != Reciprocal(modulus)") + } + + // Switching to a different full-width modulus refreshes both fields. + m2 := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") + z2 := *m2 + mm.mulmod(x, y, &z2) + if mm.mod != *m2 { + t.Fatalf("cache not refreshed on switch: cached %s, want %s", mm.mod.Hex(), m2.Hex()) + } + if mm.mu != uint256.Reciprocal(m2) { + t.Fatal("reciprocal not refreshed on modulus switch") + } + + // A narrow modulus must never prime the memo: the reciprocal path is + // reserved for full-width moduli. + var narrow modReciprocal + zs := *uint256.NewInt(1_000_003) + narrow.mulmod(x, y, &zs) + if narrow.ok { + t.Fatal("narrow modulus primed the memo; reciprocal path must be full-width only") + } +} + // FuzzMulmodMemo differentially fuzzes the memo against stock uint256.MulMod // over arbitrary operands, exercising both a miss (first call) and a hit // (second call with the same modulus) per input. From c73e5e312890330f0c7e42ba33654ab3b36cab46 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Mon, 20 Jul 2026 15:10:01 -0300 Subject: [PATCH 3/3] core/vm/gen_dispatch: emit memoized MULMOD in generated dispatch The MULMOD case in the hand-written switch dispatch (interpreter_dispatch.go) was edited to call evm.mulmodMemo.mulmod, but the generator template still emitted z.MulMod. A future `go generate ./core/vm/` would silently revert the memoized call on the switch-dispatch hot path. Update the generator body so a regen is a no-op and the optimization is durable. --- core/vm/gen_dispatch/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/vm/gen_dispatch/main.go b/core/vm/gen_dispatch/main.go index 71f6332bad..3c2a6bac15 100644 --- a/core/vm/gen_dispatch/main.go +++ b/core/vm/gen_dispatch/main.go @@ -171,7 +171,7 @@ var opcodes = []opDef{ {name: "ADDMOD", gas: "GasMidStep", mode: modeAccumulate, shape: shapeTernaryOp, funcName: "opAddmod", body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nz.AddMod(&x, &y, z)"}, {name: "MULMOD", gas: "GasMidStep", mode: modeAccumulate, shape: shapeTernaryOp, funcName: "opMulmod", - body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nz.MulMod(&x, &y, z)"}, + body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nevm.mulmodMemo.mulmod(&x, &y, z)"}, {name: "SIGNEXTEND", gas: "GasFastStep", mode: modeAccumulate, shape: shapeBinaryOp, funcName: "opSignextend", body: "back := stack.data[stack.top]\nnum := &stack.data[stack.top-1]\nnum.ExtendSign(num, &back)"},