Skip to content

core/vm: memoize MULMOD modulus reciprocal#2310

Open
lucca30 wants to merge 3 commits into
developfrom
lmartins/mulmod-memo
Open

core/vm: memoize MULMOD modulus reciprocal#2310
lucca30 wants to merge 3 commits into
developfrom
lmartins/mulmod-memo

Conversation

@lucca30

@lucca30 lucca30 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Memoize the modulus reciprocal used by the MULMOD opcode. For a full-width
(4-word) modulus, uint256.MulMod(x, y, m) recomputes Reciprocal(m) on every
call, and that reciprocal is ~60% of the call's cost. 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 we
cache (modulus, reciprocal) per EVM instance and reuse it via
MulModWithReciprocal when the modulus repeats.

Measured on a mainnet-fork experiment: ~14% faster block execution (details below).

The change

  • New core/vm/mulmod_memo.go: a one-entry (modulus, reciprocal) memo on the EVM.
  • EVM gains a mulmodMemo field (per-instance scratch state).
  • Both MULMOD execution paths (opMulmod and the switch dispatcher) call the memo
    instead of uint256.MulMod directly.

Full width only: narrower moduli — and m == 0 — never reach the reciprocal
reduction inside MulMod, so they fall through to the stock call unchanged.

Why this is consensus-safe

  1. Same arithmetic, cached constant. MulMod for a full-width modulus computes
    Reciprocal(m) and then reduces with it. We cache that reciprocal.
    MulModWithReciprocal(x, y, m, Reciprocal(m)) is defined to produce the identical
    result to MulMod(x, y, m). The memo changes only how the reciprocal is obtained
    (cached vs recomputed), never the arithmetic.
  2. Output is a pure function of (x, y, m), provably independent of cache
    contents: on a miss we recompute the reciprocal exactly as stock would; on a hit the
    cached value is bit-for-bit what stock would have produced. Two nodes always agree.
  3. Zero / narrow moduli take the stock path (z[3] == 0 guard), preserving
    m == 0 → 0.
  4. No gas change, so no hardfork gate. MULMOD's gas cost is untouched and no
    observable state differs; this is a pure execution-speed optimization that is safe to
    ship unconditionally.
  5. No synchronization needed. The memo is per-EVM scratch state, exactly like
    depth and returnData. The EVM type is documented as "should never be reused and
    is not thread safe"
    , every construction site builds a fresh instance, and each EVM
    executes on a single goroutine (verified: both parallel-execution paths construct
    their own EVM per task/worker; no EVM is shared across goroutines). An unsynchronized
    field is therefore both correct and the fastest option — a lock on the hottest opcode
    would be pure overhead.

Measurements

Mainnet-fork experiment — improved binary vs stock, identical hardware/chain/tx-mix,
window of 58,867 blocks / 6.75M txs:

metric stock with memo delta
execution 3.21 ms/Mgas 2.75 ms/Mgas ~14% faster
MULMOD per op 223 ns 148 ns −75 ns
MULMOD share of in-EVM time 13.0% 9.1%

uint256.Reciprocal accounted for ~5.15% of total node CPU before the change — the
exact cost this memo removes on the hit path.

Microbenchmark (BenchmarkMulmod, Apple M4 Pro, zero allocations):

BenchmarkMulmod/stock-12        48.65 ns/op
BenchmarkMulmod/memo/hit-12     15.33 ns/op   (~3.2x)
BenchmarkMulmod/memo/miss-12    50.51 ns/op   (~4% over stock, worst case)

The miss path (modulus changes on every call) costs one extra 4-word compare plus the
same reciprocal stock computes anyway. Real modular sequences reuse a modulus, so the
hit path dominates.

Testing

core/vm/mulmod_memo_test.go, all differential against stock uint256.MulMod:

  • 500k mixed cases — alternating moduli (misses), repeated moduli (hits), sub-4-word
    moduli, and edge values.
  • 100k repeated-modulus — the hit path with the memo primed.
  • Pointer-aliasing matrixdest==mod, x==dest==mod, y==dest==mod,
    x==y==dest==mod, each compared bit-for-bit to stock with identical aliasing.
  • Named edge cases — zero modulus (→ 0), modulus 1, single/three-word moduli, all-ones.
  • Cache-invalidation sequence — miss → hit → switch → hit → bypass.
  • FuzzMulmodMemo — differential fuzz over arbitrary operands, exercising miss + hit
    per input (3.6M+ executions, no mismatch).

Scope notes

  • ADDMOD is intentionally excluded: uint256.AddMod reduces by conditional
    subtraction, not a division reciprocal, so it has no per-call recompute to memoize.
  • Follow-up: a caching variant could live upstream in holiman/uint256; out of scope
    here.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

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%.
@lucca30

lucca30 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up (9615dac84): added TestMulmodMemoCacheState to bring changed-line mutation coverage to 100% (8/8).

diffguard's mutation pass initially surfaced 3 survivors, all in the memo's cache-management logic (the z[3] != 0 guard, the !mm.ok || mm.mod != *z recompute condition, and mm.ok = true). They survived for a principled reason: the memo is behavior-neutral by design, so mutating when the reciprocal is recomputed never changes the output — which is the exact safety property this PR relies on. Output-differential/fuzz tests therefore structurally cannot kill them.

The fix pins the internal caching contract via white-box assertions rather than adding more output cases: a full-width modulus primes the memo with the exact Reciprocal(m), a modulus switch refreshes it, and a narrow modulus never primes it.

Side note validating the guard: z[3] != 0 is a performance guard (it mirrors uint256.MulMod's own internal branch), not a correctness one — MulModWithReciprocal was bit-exact against stock MulMod on narrow moduli across 200k random cases.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 54.06%. Comparing base (ccc20e9) to head (9615dac).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           develop    #2310   +/-   ##
========================================
  Coverage    54.05%   54.06%           
========================================
  Files          907      908    +1     
  Lines       161982   161992   +10     
========================================
+ Hits         87564    87582   +18     
- Misses       69000    69004    +4     
+ Partials      5418     5406   -12     
Files with missing lines Coverage Δ
core/vm/evm.go 42.17% <ø> (ø)
core/vm/instructions.go 83.10% <100.00%> (ø)
core/vm/interpreter_dispatch.go 98.75% <100.00%> (ø)
core/vm/mulmod_memo.go 100.00% <100.00%> (ø)

... and 24 files with indirect coverage changes

Files with missing lines Coverage Δ
core/vm/evm.go 42.17% <ø> (ø)
core/vm/instructions.go 83.10% <100.00%> (ø)
core/vm/interpreter_dispatch.go 98.75% <100.00%> (ø)
core/vm/mulmod_memo.go 100.00% <100.00%> (ø)

... and 24 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lucca30

lucca30 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-tested, low-risk performance optimization; approving with the inline nit noted below.

Extended reasoning...

Overview

The PR adds a one-entry (modulus, reciprocal) memo cache to the EVM to avoid recomputing uint256.Reciprocal(m) on every MULMOD for full-width moduli. It touches core/vm/evm.go (new mulmodMemo field on EVM), core/vm/instructions.go (opMulmod), core/vm/interpreter_dispatch.go (generated switch-dispatch MULMOD case), and adds core/vm/mulmod_memo.go plus an extensive differential/fuzz test file.

Security risks

None identified. This is a pure execution-speed optimization: no gas cost changes, no new externally-observable behavior, and the memo is proven to be output-equivalent to stock uint256.MulMod (same reciprocal, cached vs recomputed). The EVM type is single-goroutine/non-reused per its existing contract, so the new unsynchronized field is safe — consistent with existing fields like depth and returnData.

Level of scrutiny

This touches a consensus-critical hot path (the MULMOD opcode, both dispatch paths), so it warrants real scrutiny despite being "just a cache." I verified: the full-width guard (z[3] != 0) correctly excludes narrow/zero moduli (preserving m == 0 → 0), the cache correctly refreshes on modulus switch, and both call sites (opMulmod and the switch-dispatch runSwitch) were updated consistently. Test coverage is thorough — 500k+ differential cases, pointer-aliasing matrix, cache-state white-box tests, and a fuzz target — and mutation-testing survivors were addressed in a follow-up commit with principled reasoning (behavior-neutral caching logic can't be killed by output-only tests).

Other factors

The one finding from this run — that core/vm/gen_dispatch/main.go wasn't updated to match the hand-edited generated interpreter_dispatch.go, so a future go generate would silently revert the switch-dispatch memo call — is already posted as an inline comment. It's a maintenance/durability nit with no correctness or consensus impact (both code paths always produce identical output), not a blocker.

Comment thread core/vm/interpreter_dispatch.go

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 introduces a per-EVM one-entry memoization of the modulus reciprocal used by the MULMOD opcode for full-width (4-word) moduli, allowing repeated modular multiplications with the same modulus to reuse uint256.Reciprocal(m) rather than recomputing it each time.

Changes:

  • Added modReciprocal memo helper and wired it into MULMOD execution (both opMulmod and the switch-dispatch fast path).
  • Extended EVM with a mulmodMemo scratch field to keep the cached (modulus, reciprocal) per instance.
  • Added differential/unit tests, fuzzing, and benchmarks validating memoized behavior against stock uint256.MulMod.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
core/vm/mulmod_memo.go Implements a 1-entry modulus→reciprocal memo and a memo-aware mulmod helper.
core/vm/mulmod_memo_test.go Adds differential tests, aliasing coverage, fuzzing, and benchmarks for the memoized path.
core/vm/interpreter_dispatch.go Switch-dispatch MULMOD path updated to call the memo helper (note: generated file).
core/vm/instructions.go Non-switch MULMOD opcode implementation updated to use the memo helper.
core/vm/evm.go Adds the mulmodMemo field to EVM to store per-instance cached reciprocal state.
Files not reviewed (1)
  • core/vm/interpreter_dispatch.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/vm/interpreter_dispatch.go
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.
@sonarqubecloud

Copy link
Copy Markdown

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