core/vm: replace interpreter dispatch with a generated switch

This commit is contained in:
jonny rhea 2026-06-10 14:34:46 -05:00
parent dddbaa4bf3
commit 419e5e1438
16 changed files with 3331 additions and 16 deletions

View file

@ -131,6 +131,11 @@ type EVM struct {
returnData []byte // Last CALL's return data for subsequent reuse
arena *stackArena
// forceTableLoop is a test-only switch. The differential test sets it so
// that Run uses the table loop (execTraced) for the whole call tree, which
// lets the test compare it against the generated dispatch.
forceTableLoop bool
}
// NewEVM constructs an EVM instance with the supplied block context, state

497
core/vm/gen/main.go Normal file
View file

@ -0,0 +1,497 @@
// 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 <http://www.gnu.org/licenses/>.
// Command gen generates core/vm/interp_gen.go, the EVM interpreter's untraced
// fast-path dispatch. The output is a switch over the opcode byte that:
//
// - inlines the hot, fork-stable opcodes (arithmetic / comparison / bitwise /
// PUSH / DUP / SWAP / POP / JUMP / JUMPI / PC / MSIZE / JUMPDEST) by
// splicing the existing opXxx handler bodies from instructions.go and
// eips.go, with their static gas and stack bounds baked in as constants
// derived from the per-fork instruction tables via vm.GenForks.
//
// - calls the fork-invariant cold ops (KECCAK256 / MLOAD / MSTORE / MSTORE8,
// see directCold) directly by name, skipping the table's function
// pointers, which Go cannot inline through.
//
// - dispatches everything fork-varying (CALL / CREATE / SSTORE / SLOAD / LOG /
// the COPY family and so on) through the active per-fork JumpTable in the
// default case, exactly as the legacy loop did, so volatile gas and opcode
// logic stays shared rather than restated.
//
// The generated file is committed and a CI test asserts it matches `go generate`
// output. Do not hand-edit interp_gen.go.
//
// Usage: go generate ./core/vm/...
package main
import (
"bytes"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/printer"
"go/token"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/ethereum/go-ethereum/core/vm"
)
const stackLimit = 1024 // params.StackLimit
// inlineHandler maps an opcode byte to the opXxx handler whose body is spliced
// inline for that opcode. These are the hot, fork-stable opcodes with no dynamic
// gas. Opcodes not listed here (or in directCold, or PUSH3-32 / DUP1-16, which
// are handled specially) fall through to the default case, which dispatches via
// the per-fork table.
var inlineHandler = map[byte]string{
0x01: "opAdd", 0x02: "opMul", 0x03: "opSub", 0x04: "opDiv", 0x05: "opSdiv",
0x06: "opMod", 0x07: "opSmod", 0x08: "opAddmod", 0x09: "opMulmod", 0x0b: "opSignExtend",
0x10: "opLt", 0x11: "opGt", 0x12: "opSlt", 0x13: "opSgt", 0x14: "opEq", 0x15: "opIszero",
0x16: "opAnd", 0x17: "opOr", 0x18: "opXor", 0x19: "opNot", 0x1a: "opByte",
0x1b: "opSHL", 0x1c: "opSHR", 0x1d: "opSAR", 0x1e: "opCLZ",
0x50: "opPop", 0x56: "opJump", 0x57: "opJumpi", 0x58: "opPc", 0x59: "opMsize", 0x5b: "opJumpdest",
0x5f: "opPush0", 0x60: "opPush1", 0x61: "opPush2",
0x90: "opSwap1", 0x91: "opSwap2", 0x92: "opSwap3", 0x93: "opSwap4",
0x94: "opSwap5", 0x95: "opSwap6", 0x96: "opSwap7", 0x97: "opSwap8",
0x98: "opSwap9", 0x99: "opSwap10", 0x9a: "opSwap11", 0x9b: "opSwap12",
0x9c: "opSwap13", 0x9d: "opSwap14", 0x9e: "opSwap15", 0x9f: "opSwap16",
}
// directCold lists cold opcodes (dynamic gas, not inlined) whose handler,
// dynamic-gas, and memory-size functions are the same across every fork
// (verified: untouched by any enableXxx). They are emitted as direct calls to
// those functions by name instead of the indirect operation.* pointer calls
// in the default case. Go inlines the plain functions, and the var-aliased gas
// funcs (gasMLoad and friends alias pureMemoryGascost) at least skip the
// table load. Measured at ~3.4% on snailtracer (p=0.000). Fork-varying cold ops
// (CALL/SSTORE/SLOAD and friends) do not qualify and stay on the per-fork
// table in the default case.
//
// opcode → {handler, dynamicGas, memorySize}
//
// Limited to the memory/hash ops that appear in hot loops. Adding more (e.g.
// CALLDATACOPY/RETURN, typically once per call) grows the generated function
// and regresses tiny benchmarks through code layout, for negligible gain.
var directCold = map[byte][3]string{
0x20: {"opKeccak256", "gasKeccak256", "memoryKeccak256"}, // KECCAK256
0x51: {"opMload", "gasMLoad", "memoryMLoad"}, // MLOAD
0x52: {"opMstore", "gasMStore", "memoryMStore"}, // MSTORE
0x53: {"opMstore8", "gasMStore8", "memoryMStore8"}, // MSTORE8
}
// opMeta is the per-opcode metadata derived from the per-fork tables.
type opMeta struct {
defined bool
name string // opcode mnemonic, e.g. "ADD"
introF string // params.Rules field activating it, empty for Frontier (always on)
constGas uint64
minStack int
maxStack int
}
type generator struct {
fset *token.FileSet
handlers map[string]*ast.FuncDecl
meta [256]opMeta
buf *bytes.Buffer
}
func (g *generator) p(format string, args ...any) { fmt.Fprintf(g.buf, format, args...) }
// ---------------------------------------------------------------------------
// Handler parsing + body splicing
// ---------------------------------------------------------------------------
// parseHandlers parses instructions.go and eips.go and returns every top-level
// opXxx function declaration by name.
func parseHandlers(vmDir string) (*token.FileSet, map[string]*ast.FuncDecl) {
fset := token.NewFileSet()
handlers := map[string]*ast.FuncDecl{}
for _, name := range []string{"instructions.go", "eips.go"} {
path := filepath.Join(vmDir, name)
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
fatalf("parse %s: %v", path, err)
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Body == nil {
continue
}
handlers[fn.Name.Name] = fn
}
}
return fset, handlers
}
var returnRe = regexp.MustCompile(`^(\s*)return\s+([^,]+),\s*(.+)$`)
// inlineBody returns the source of handler's body, rewritten so it can be
// spliced into the dispatch loop: the `*pc` dereference becomes the loop's `pc`
// local, and each `return r0, r1` becomes loop control flow. Success
// (r1 == nil) advances pc and continues, an error sets err and breaks.
func (g *generator) inlineBody(handler string) string {
fn := g.handlers[handler]
if fn == nil {
fatalf("no handler %q to inline", handler)
}
var raw bytes.Buffer
cfg := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
for _, stmt := range fn.Body.List {
if err := cfg.Fprint(&raw, g.fset, stmt); err != nil {
fatalf("print %s body: %v", handler, err)
}
raw.WriteByte('\n')
}
src := strings.ReplaceAll(raw.String(), "*pc", "pc")
var out strings.Builder
for _, line := range strings.Split(src, "\n") {
if m := returnRe.FindStringSubmatch(line); m != nil {
indent, r0, r1 := m[1], strings.TrimSpace(m[2]), strings.TrimSpace(m[3])
// The error and halt path must overwrite res and err. Otherwise a
// halting inlined op (JUMPI on an invalid jump, say) returns stale
// res from an earlier res-setting op such as a DELEGATECALL. The
// fuzzer caught exactly that bug. The success path advances pc and
// continues without writing res or err: stale res on a continuing
// op is always overwritten by whichever op ends the run, and err
// stays nil through normal iteration. Keeping these stores off the
// success path avoids growing every inlined case, which regresses
// tiny, layout-sensitive benchmarks.
if r1 == "nil" {
out.WriteString(indent + "pc++\n")
out.WriteString(indent + "continue mainLoop\n")
} else {
out.WriteString(indent + "res, err = " + r0 + ", " + r1 + "\n")
out.WriteString(indent + "break mainLoop\n")
}
continue
}
out.WriteString(line + "\n")
}
return out.String()
}
// ---------------------------------------------------------------------------
// Metadata derivation (from the per-fork tables, via vm.GenForks)
// ---------------------------------------------------------------------------
func (g *generator) deriveMeta(forks []vm.GenFork) {
for code := 0; code < 256; code++ {
for _, fork := range forks {
o := fork.Ops[code]
if !o.Defined {
continue
}
g.meta[code] = opMeta{
defined: true,
name: o.Name,
introF: fork.RuleField,
constGas: o.ConstantGas,
minStack: o.MinStack,
maxStack: o.MaxStack,
}
break // first fork that defines it wins (its intro fork)
}
}
// Sanity: every inlined opcode must be defined and have fork-stable static
// gas / stack bounds across all forks where it appears (that is what makes
// it safe to bake as a constant). Bail loudly otherwise.
for code, handler := range inlineHandler {
g.checkStable(byte(code), handler, forks)
}
for code := 0x62; code <= 0x7f; code++ { // PUSH3-32
g.checkStable(byte(code), "makePush", forks)
}
for code := 0x80; code <= 0x8f; code++ { // DUP1-16
g.checkStable(byte(code), "makeDup", forks)
}
}
func (g *generator) checkStable(code byte, what string, forks []vm.GenFork) {
m := g.meta[code]
if !m.defined {
fatalf("opcode %#x (%s) selected for inlining but never defined", code, what)
}
for _, fork := range forks {
o := fork.Ops[code]
if !o.Defined {
continue
}
if o.ConstantGas != m.constGas || o.MinStack != m.minStack || o.MaxStack != m.maxStack || o.HasDynamicGas {
fatalf("opcode %#x (%s) is not fork-stable (fork %s): cannot inline", code, what, fork.Name)
}
}
}
// ---------------------------------------------------------------------------
// Case emission
// ---------------------------------------------------------------------------
// emitStackChecks emits the underflow/overflow guards for a baked opcode,
// mirroring the legacy loop's order (stack validated before gas).
func (g *generator) emitStackChecks(m opMeta) {
under := m.minStack > 0
over := m.maxStack < stackLimit
switch {
case under && over:
g.p("if sLen := stack.len(); sLen < %d {\nreturn nil, &ErrStackUnderflow{stackLen: sLen, required: %d}\n} else if sLen > %d {\nreturn nil, &ErrStackOverflow{stackLen: sLen, limit: %d}\n}\n", m.minStack, m.minStack, m.maxStack, m.maxStack)
case under:
g.p("if sLen := stack.len(); sLen < %d {\nreturn nil, &ErrStackUnderflow{stackLen: sLen, required: %d}\n}\n", m.minStack, m.minStack)
case over:
g.p("if sLen := stack.len(); sLen > %d {\nreturn nil, &ErrStackOverflow{stackLen: sLen, limit: %d}\n}\n", m.maxStack, m.maxStack)
}
}
func (g *generator) emitGasCheck(m opMeta) {
if m.constGas == 0 {
return
}
g.p("if contract.Gas.RegularGas < %d {\nreturn nil, ErrOutOfGas\n}\ncontract.Gas.RegularGas -= %d\n", m.constGas, m.constGas)
}
// emitWork emits the stack/gas guards and the opcode body (the portion that runs
// when the opcode is active for the current fork).
func (g *generator) emitWork(code byte) {
m := g.meta[code]
g.emitStackChecks(m)
g.emitGasCheck(m)
// PUSH1-PUSH32 swap their execute function under EIP-4762 (verkle) to charge
// code-chunk gas on the immediate bytes. Defer to the table handler there.
// The baked static gas and stack guard above already match.
if code >= 0x60 && code <= 0x7f {
g.p("if isEIP4762 {\nres, err = table[op].execute(&pc, evm, scope)\nif err != nil {\nbreak mainLoop\n}\npc++\ncontinue mainLoop\n}\n")
}
switch {
case code >= 0x62 && code <= 0x7f: // PUSH3-PUSH32: inline makePush(n,n)
g.emitPushFixed(int(code) - 0x5f)
case code >= 0x80 && code <= 0x8f: // DUP1-DUP16: inline makeDup(n)
g.p("scope.Stack.dup(%d)\npc++\ncontinue mainLoop\n", int(code)-0x7f)
default:
g.p("%s", g.inlineBody(inlineHandler[code]))
}
}
// emitPushFixed inlines makePush(n, n) for PUSH<n> (n = 3..32).
func (g *generator) emitPushFixed(n int) {
g.p("codeLen := len(scope.Contract.Code)\n")
g.p("start := min(codeLen, int(pc+1))\n")
g.p("end := min(codeLen, start+%d)\n", n)
g.p("a := scope.Stack.get()\n")
g.p("a.SetBytes(scope.Contract.Code[start:end])\n")
g.p("if missing := %d - (end - start); missing > 0 {\na.Lsh(a, uint(8*missing))\n}\n", n)
g.p("pc += %d\npc++\ncontinue mainLoop\n", n)
}
func (g *generator) emitInlineCase(code byte) {
m := g.meta[code]
g.p("case %s:\n", m.name)
if m.introF == "" {
g.emitWork(code)
return
}
// Fork-gated: run the inlined body only when the opcode is active for the
// current fork. Otherwise mirror the legacy loop's undefined-opcode handling.
g.p("if rules.%s {\n", m.introF)
g.emitWork(code)
g.p("}\n")
g.p("res, err = opUndefined(&pc, evm, scope)\nbreak mainLoop\n")
}
// emitDirectCold emits a cold opcode case identical to the default case, except
// the handler, dynamic-gas, and memory-size functions are called by name
// rather than through the indirect operation.* table pointers. Valid only for
// fork-invariant ops (see directCold).
func (g *generator) emitDirectCold(code byte) {
m := g.meta[code]
fns := directCold[code]
g.p("case %s:\n", m.name)
g.emitStackChecks(m)
g.emitGasCheck(m)
g.p("var memorySize uint64\n{\n")
g.p("memSize, overflow := %s(stack)\n", fns[2])
g.p("if overflow {\nreturn nil, ErrGasUintOverflow\n}\n")
g.p("if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow {\nreturn nil, ErrGasUintOverflow\n}\n}\n")
g.p("var dynamicCost GasCosts\n")
g.p("dynamicCost, err = %s(evm, contract, stack, mem, memorySize)\n", fns[1])
// WriteString: keep %w/%v literal (not generator format verbs).
g.buf.WriteString("if err != nil {\nreturn nil, fmt.Errorf(\"%w: %v\", ErrOutOfGas, err)\n}\n")
g.p("if contract.Gas.RegularGas < dynamicCost.RegularGas {\nreturn nil, ErrOutOfGas\n}\n")
g.p("contract.Gas.RegularGas -= dynamicCost.RegularGas\n")
g.p("if memorySize > 0 {\nmem.Resize(memorySize)\n}\n")
g.p("res, err = %s(&pc, evm, scope)\n", fns[0])
g.p("if err != nil {\nbreak mainLoop\n}\npc++\ncontinue mainLoop\n")
}
func (g *generator) emitDefault() {
// WriteString, not p(): this template contains %w/%v that must reach the
// output verbatim (they are not generator format verbs).
g.buf.WriteString(`default:
operation := table[op]
if sLen := stack.len(); sLen < operation.minStack {
return nil, &ErrStackUnderflow{stackLen: sLen, required: operation.minStack}
} else if sLen > operation.maxStack {
return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack}
}
cost := operation.constantGas
if contract.Gas.RegularGas < cost {
return nil, ErrOutOfGas
}
contract.Gas.RegularGas -= cost
var memorySize uint64
if operation.dynamicGas != nil {
if operation.memorySize != nil {
memSize, overflow := operation.memorySize(stack)
if overflow {
return nil, ErrGasUintOverflow
}
if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow {
return nil, ErrGasUintOverflow
}
}
var dynamicCost GasCosts
dynamicCost, err = operation.dynamicGas(evm, contract, stack, mem, memorySize)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrOutOfGas, err)
}
if contract.Gas.RegularGas < dynamicCost.RegularGas {
return nil, ErrOutOfGas
}
contract.Gas.RegularGas -= dynamicCost.RegularGas
}
if memorySize > 0 {
mem.Resize(memorySize)
}
res, err = operation.execute(&pc, evm, scope)
if err != nil {
break mainLoop
}
pc++
continue mainLoop
`)
}
// ---------------------------------------------------------------------------
// File emission
// ---------------------------------------------------------------------------
func (g *generator) emitFile() {
g.p("// Code generated by core/vm/gen; DO NOT EDIT.\n\n")
g.p("package vm\n\n")
g.p("import (\n")
g.p("\t\"fmt\"\n\n")
g.p("\t\"github.com/ethereum/go-ethereum/common/math\"\n")
g.p("\t\"github.com/ethereum/go-ethereum/core/tracing\"\n")
g.p(")\n\n")
g.buf.WriteString(`// execUntraced is the generated, tracing-free interpreter fast path. Hot,
// fork-stable opcodes are inlined with their static gas and stack bounds baked
// in. Fork-invariant cold ops (KECCAK256/MLOAD/MSTORE/MSTORE8) call their
// handler and gas functions directly by name. Everything fork-varying is
// dispatched through the active per-fork table in the default case. EVM.Run
// selects this path when no tracer is configured.
func (evm *EVM) execUntraced(scope *ScopeContext) (ret []byte, err error) {
var (
contract = scope.Contract
mem = scope.Memory
stack = scope.Stack
table = evm.table
rules = evm.chainRules
isEIP4762 = rules.IsEIP4762
pc = uint64(0)
res []byte
)
_ = mem
_ = rules
_ = isEIP4762
_ = table
mainLoop:
for {
if isEIP4762 && !contract.IsDeployment && !contract.IsSystemCall {
contractAddr := contract.Address()
consumed, wanted := evm.TxContext.AccessEvents.CodeChunksRangeGas(contractAddr, pc, 1, uint64(len(contract.Code)), false, contract.Gas.RegularGas)
contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk)
if consumed < wanted {
return nil, ErrOutOfGas
}
}
op := contract.GetOp(pc)
switch op {
`)
// Inlined hot cases, in opcode order for readability.
for code := 0; code < 256; code++ {
b := byte(code)
_, named := inlineHandler[b]
isPushFixed := code >= 0x62 && code <= 0x7f
isDup := code >= 0x80 && code <= 0x8f
if named || isPushFixed || isDup {
g.emitInlineCase(b)
} else if _, dc := directCold[b]; dc {
g.emitDirectCold(b)
}
}
g.emitDefault()
g.p("}\n") // switch
g.p("}\n") // for
g.p("if err == errStopToken {\nerr = nil\n}\n")
g.p("return res, err\n")
g.p("}\n") // func
}
// ---------------------------------------------------------------------------
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "gen: "+format+"\n", args...)
os.Exit(1)
}
func main() {
_, self, _, ok := runtime.Caller(0)
if !ok {
fatalf("cannot resolve generator source path")
}
vmDir := filepath.Dir(filepath.Dir(self)) // .../core/vm/gen -> .../core/vm
fset, handlers := parseHandlers(vmDir)
g := &generator{fset: fset, handlers: handlers, buf: new(bytes.Buffer)}
g.deriveMeta(vm.GenForks())
g.emitFile()
formatted, err := format.Source(g.buf.Bytes())
if err != nil {
dbg := filepath.Join(vmDir, "interp_gen.go.broken")
os.WriteFile(dbg, g.buf.Bytes(), 0644)
fatalf("gofmt failed (%v); wrote unformatted output to %s", err, dbg)
}
// INTERP_GEN_OUT lets the CI-match test (interp_gen_test.go) regenerate to a
// temporary file and diff it against the committed one, without clobbering it.
out := filepath.Join(vmDir, "interp_gen.go")
if env := os.Getenv("INTERP_GEN_OUT"); env != "" {
out = env
}
if err := os.WriteFile(out, formatted, 0644); err != nil {
fatalf("write %s: %v", out, err)
}
fmt.Fprintf(os.Stderr, "gen: wrote %s (%d bytes)\n", out, len(formatted))
}

106
core/vm/genspec.go Normal file
View file

@ -0,0 +1,106 @@
// 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 <http://www.gnu.org/licenses/>.
package vm
//go:generate go run ./gen
// This file exposes the interpreter's opcode metadata to the code generator in
// core/vm/gen. It is not used at runtime. It exists so the generator can derive
// the per-opcode spec (static gas, stack bounds, the fork an opcode first
// appears in, and whether it carries dynamic gas or memory sizing) from the
// existing per-fork instruction sets, rather than restating that metadata.
//
// The fork-varying dynamic-gas / memory-size / execute *functions* are not
// surfaced here: several are closures (gasCall, the memoryCopierGas family,
// makeGasLog) that cannot be recovered by name. The generated switch instead
// reaches those volatile opcodes through the active per-fork JumpTable at
// runtime (see interp_gen.go), so they need no generator-side restatement.
// GenOp is the generator-facing scalar metadata for one opcode slot in one fork.
type GenOp struct {
Name string // opcode mnemonic, e.g. "ADD" (valid only if Defined)
Defined bool // false if the slot is undefined/invalid in this fork
ConstantGas uint64
MinStack int
MaxStack int
HasDynamicGas bool
HasMemorySize bool
}
// GenFork bundles a fork's name, the params.Rules bool field that activates it
// (empty for Frontier, which is always active), and its per-opcode metadata.
type GenFork struct {
Name string
RuleField string
Ops [256]GenOp
}
// genForkOrder is the canonical fork progression for code generation, oldest to
// newest, each paired with the params.Rules field that activates it.
//
// Petersburg is omitted: it shares Constantinople's opcode set and only changes
// SSTORE dynamic gas, which flows through the shared gas function. Verkle/UBT is
// omitted: over its Shanghai base it adds no new opcodes (it only swaps gas and
// execute on existing opcodes), which the generated switch picks up from the
// active table at runtime.
var genForkOrder = []struct {
name string
rule string
set *JumpTable
}{
{"Frontier", "", &frontierInstructionSet},
{"Homestead", "IsHomestead", &homesteadInstructionSet},
{"TangerineWhistle", "IsEIP150", &tangerineWhistleInstructionSet},
{"SpuriousDragon", "IsEIP158", &spuriousDragonInstructionSet},
{"Byzantium", "IsByzantium", &byzantiumInstructionSet},
{"Constantinople", "IsConstantinople", &constantinopleInstructionSet},
{"Istanbul", "IsIstanbul", &istanbulInstructionSet},
{"Berlin", "IsBerlin", &berlinInstructionSet},
{"London", "IsLondon", &londonInstructionSet},
{"Merge", "IsMerge", &mergeInstructionSet},
{"Shanghai", "IsShanghai", &shanghaiInstructionSet},
{"Cancun", "IsCancun", &cancunInstructionSet},
{"Prague", "IsPrague", &pragueInstructionSet},
{"Osaka", "IsOsaka", &osakaInstructionSet},
{"Amsterdam", "IsAmsterdam", &amsterdamInstructionSet},
}
// GenForks returns per-fork opcode metadata for the interpreter code generator
// (core/vm/gen). It is exported solely for that purpose.
func GenForks() []GenFork {
out := make([]GenFork, len(genForkOrder))
for i, f := range genForkOrder {
gf := GenFork{Name: f.name, RuleField: f.rule}
for code := 0; code < 256; code++ {
op := f.set[code]
if op == nil || op.undefined {
continue
}
gf.Ops[code] = GenOp{
Name: OpCode(code).String(),
Defined: true,
ConstantGas: op.constantGas,
MinStack: op.minStack,
MaxStack: op.maxStack,
HasDynamicGas: op.dynamicGas != nil,
HasMemorySize: op.memorySize != nil,
}
}
out[i] = gf
}
return out
}

365
core/vm/interp_diff_test.go Normal file
View file

@ -0,0 +1,365 @@
// 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 <http://www.gnu.org/licenses/>.
package vm
// Differential test comparing the table loop against the generated dispatch.
//
// This file proves that the generated dispatch (execUntraced) is bit-identical
// to the table-walking loop (execTraced, run here without a tracer via
// EVM.forceTableLoop) for the observable surface of an EVM execution: return
// data, gas left, error/halt, refund counter, emitted logs, and the resulting
// state root. It runs the same program through both interpreters over freshly
// built, identical state across several forks, plus a fuzz target over
// arbitrary bytecode.
//
// execTraced is also the production tracing path, and
// if it drifted from the generated dispatch then traced re-execution would
// disagree with what consensus executed. Hook emission itself is covered by
// the tracer test suites instead.
import (
"bytes"
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/params"
"github.com/holiman/uint256"
)
// diffForks is the set of fork configurations the diff test runs every program
// under. Spanning forks exercises the baked runtime fork gates (e.g. SHL from
// Constantinople, PUSH0 from Shanghai, CLZ from Osaka) in both the active and
// the not-yet-activated states.
var diffForks = func() []struct {
name string
cfg *params.ChainConfig
merged bool
} {
// preConstantinople: Byzantium active, Constantinople and later not.
preCon := *params.TestChainConfig
preCon.ConstantinopleBlock = nil
preCon.PetersburgBlock = nil
preCon.IstanbulBlock = nil
preCon.MuirGlacierBlock = nil
preCon.BerlinBlock = nil
preCon.LondonBlock = nil
preCon.ArrowGlacierBlock = nil
preCon.GrayGlacierBlock = nil
return []struct {
name string
cfg *params.ChainConfig
merged bool
}{
{"Frontier", params.NonActivatedConfig, false},
{"Byzantium", &preCon, false},
{"London", params.TestChainConfig, false},
{"Merged", params.MergedTestChainConfig, true},
}
}()
var (
diffContractAddr = common.HexToAddress("0x000000000000000000000000000000000000c0de")
diffCalleeAddr = common.HexToAddress("0x000000000000000000000000000000000000ca11")
diffCaller = common.HexToAddress("0x000000000000000000000000000000000000face")
)
// diffCalleeCode is deployed at diffCalleeAddr as a CALL/CREATE target: it
// writes a storage slot, logs, and returns 32 bytes of memory.
//
// PUSH1 0x2a PUSH1 0x07 SSTORE // sstore(7, 42)
// PUSH1 0xbb PUSH1 0x00 MSTORE // mem[0..32] = 0xbb
// PUSH1 0x20 PUSH1 0x00 LOG0 // log0(mem[0:32])
// PUSH1 0x20 PUSH1 0x00 RETURN // return mem[0:32]
var diffCalleeCode = []byte{
byte(PUSH1), 0x2a, byte(PUSH1), 0x07, byte(SSTORE),
byte(PUSH1), 0xbb, byte(PUSH1), 0x00, byte(MSTORE),
byte(PUSH1), 0x20, byte(PUSH1), 0x00, byte(LOG0),
byte(PUSH1), 0x20, byte(PUSH1), 0x00, byte(RETURN),
}
// asm is a tiny helper to build bytecode from opcodes/immediates.
func asm(parts ...any) []byte {
var b []byte
for _, p := range parts {
switch v := p.(type) {
case OpCode:
b = append(b, byte(v))
case byte:
b = append(b, v)
case int:
b = append(b, byte(v))
case []byte:
b = append(b, v...)
default:
panic("asm: bad part")
}
}
return b
}
// diffPrograms is a curated set of bytecode snippets covering the inlined hot
// opcodes, the volatile call-through opcodes, fork-gated opcodes, control flow,
// and the principal error paths.
var diffPrograms = []struct {
name string
code []byte
gas uint64
}{
{"arith", asm(PUSH1, 0x07, PUSH1, 0x03, ADD, PUSH1, 0x02, MUL, PUSH1, 0x04, SUB, PUSH1, 0x03, DIV, PUSH1, 0x05, MOD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"signed-arith", asm(PUSH1, 0x07, PUSH1, 0xfd, SDIV, PUSH1, 0x03, SMOD, PUSH1, 0x02, SIGNEXTEND, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"addmod-mulmod-exp", asm(PUSH1, 0x07, PUSH1, 0x05, PUSH1, 0x03, ADDMOD, PUSH1, 0x09, PUSH1, 0x04, PUSH1, 0x02, MULMOD, PUSH1, 0x03, PUSH1, 0x02, EXP, ADD, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"cmp", asm(PUSH1, 0x07, PUSH1, 0x03, LT, PUSH1, 0x01, GT, PUSH1, 0x01, SLT, PUSH1, 0x01, SGT, PUSH1, 0x01, EQ, ISZERO, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"bitwise", asm(PUSH1, 0xf0, PUSH1, 0x0f, AND, PUSH1, 0xaa, OR, PUSH1, 0x55, XOR, NOT, PUSH1, 0x01, BYTE, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"shifts-clz", asm(PUSH1, 0xff, PUSH1, 0x04, SHL, PUSH1, 0x02, SHR, PUSH1, 0x01, SAR, CLZ, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"dup-swap", asm(PUSH1, 0x01, PUSH1, 0x02, PUSH1, 0x03, DUP3, SWAP2, DUP1, SWAP1, POP, ADD, ADD, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"push0-push32", asm(PUSH0, PUSH3, 0x01, 0x02, 0x03, ADD, PUSH5, 0x01, 0x02, 0x03, 0x04, 0x05, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"keccak", asm(PUSH1, 0x20, PUSH1, 0x00, KECCAK256, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"memory", asm(PUSH1, 0xab, PUSH1, 0x00, MSTORE8, PUSH1, 0xcd, PUSH2, 0x00, 0x40, MSTORE, MSIZE, PUSH1, 0x60, MSTORE, PUSH1, 0x80, PUSH1, 0x00, RETURN), 100000},
{"mcopy", asm(PUSH1, 0xff, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, PUSH1, 0x20, MCOPY, PUSH1, 0x40, PUSH1, 0x00, RETURN), 100000},
{"storage", asm(PUSH1, 0x63, PUSH1, 0x07, SSTORE, PUSH1, 0x07, SLOAD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"transient", asm(PUSH1, 0x63, PUSH1, 0x07, TSTORE, PUSH1, 0x07, TLOAD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"loop", asm(PUSH1, 0x00, JUMPDEST, PUSH1, 0x01, ADD, DUP1, PUSH1, 0x05, LT, PUSH1, 0x02, JUMPI, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"jump", asm(PUSH1, 0x06, JUMP, INVALID, INVALID, JUMPDEST, PUSH1, 0x2a, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"env", asm(ADDRESS, CALLER, CALLVALUE, ORIGIN, GASPRICE, CODESIZE, GAS, PC, ADD, ADD, ADD, ADD, ADD, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"block", asm(NUMBER, TIMESTAMP, COINBASE, GASLIMIT, CHAINID, SELFBALANCE, BASEFEE, DIFFICULTY, ADD, ADD, ADD, ADD, ADD, ADD, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"calldata", asm(PUSH1, 0x00, CALLDATALOAD, CALLDATASIZE, PUSH1, 0x00, PUSH1, 0x00, CALLDATACOPY, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"codecopy", asm(PUSH1, 0x10, PUSH1, 0x00, PUSH1, 0x00, CODECOPY, PUSH1, 0x10, PUSH1, 0x00, RETURN), 100000},
{"log", asm(PUSH1, 0x11, PUSH1, 0x00, MSTORE, PUSH1, 0x22, PUSH1, 0x33, PUSH1, 0x20, PUSH1, 0x00, LOG2, STOP), 100000},
// Fuzz-found regression (the stale-res bug): a res-setting DELEGATECALL
// followed by a halting inlined op (JUMPI to an invalid destination). The
// buggy build returned the DELEGATECALL output instead of nil.
{"delegatecall-then-invalid-jumpi", asm(
PUSH1, 0x30, PUSH1, 0x30, PUSH1, 0x30, PUSH1, 0x30,
PUSH20, diffCalleeAddr.Bytes(),
PUSH2, 0x30, 0x30, DELEGATECALL,
PC, PC, JUMPI), 100000},
{"extaccess", asm(PUSH20, diffCalleeAddr.Bytes(), EXTCODESIZE, PUSH20, diffCalleeAddr.Bytes(), BALANCE, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 100000},
{"call", asm(
PUSH1, 0x20, PUSH1, 0x00, PUSH1, 0x00, PUSH1, 0x00, PUSH1, 0x00,
PUSH20, diffCalleeAddr.Bytes(), PUSH2, 0xff, 0xff, CALL,
PUSH1, 0x20, PUSH1, 0x00, RETURN), 200000},
{"staticcall", asm(
PUSH1, 0x20, PUSH1, 0x00, PUSH1, 0x00, PUSH1, 0x00,
PUSH20, diffCalleeAddr.Bytes(), PUSH2, 0xff, 0xff, STATICCALL,
PUSH1, 0x20, PUSH1, 0x00, RETURN), 200000},
{"delegatecall", asm(
PUSH1, 0x20, PUSH1, 0x00, PUSH1, 0x00, PUSH1, 0x00,
PUSH20, diffCalleeAddr.Bytes(), PUSH2, 0xff, 0xff, DELEGATECALL,
PUSH1, 0x20, PUSH1, 0x00, RETURN), 200000},
{"create", asm(
// store init code that returns empty, then CREATE
PUSH1, 0x00, PUSH1, 0x00, MSTORE,
PUSH1, 0x00, PUSH1, 0x00, PUSH1, 0x00, CREATE,
PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 200000},
{"revert", asm(PUSH1, 0xaa, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, REVERT), 100000},
{"selfdestruct", asm(PUSH20, diffCaller.Bytes(), SELFDESTRUCT), 100000},
{"stop", asm(PUSH1, 0x01, STOP), 100000},
{"invalid-opcode", asm(PUSH1, 0x01, INVALID), 100000},
{"undefined-opcode", asm(PUSH1, 0x01, 0x0c), 100000},
{"stack-underflow", asm(ADD), 100000},
{"oog", asm(PUSH1, 0x07, PUSH1, 0x03, ADD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN), 7},
{"invalid-jump", asm(PUSH1, 0x03, JUMP, STOP), 100000},
}
// diffResult captures the observable outcome of running a program.
type diffResult struct {
ret []byte
gasLeft uint64
errStr string // "" if no error
refund uint64
root common.Hash
logs []*types.Log
}
func (r diffResult) equal(o diffResult) (string, bool) {
if !bytes.Equal(r.ret, o.ret) {
return "return data", false
}
if r.gasLeft != o.gasLeft {
return "gas left", false
}
if r.errStr != o.errStr {
return "error", false
}
if r.refund != o.refund {
return "refund", false
}
if r.root != o.root {
return "state root", false
}
if len(r.logs) != len(o.logs) {
return "log count", false
}
for i := range r.logs {
a, b := r.logs[i], o.logs[i]
if a.Address != b.Address || !bytes.Equal(a.Data, b.Data) || len(a.Topics) != len(b.Topics) {
return "log content", false
}
for j := range a.Topics {
if a.Topics[j] != b.Topics[j] {
return "log topic", false
}
}
}
return "", true
}
func newDiffState(t testing.TB) *state.StateDB {
t.Helper()
statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
if err != nil {
t.Fatalf("state.New: %v", err)
}
// Main contract: balance + a pre-set storage slot.
statedb.CreateAccount(diffContractAddr)
statedb.SetBalance(diffContractAddr, uint256.NewInt(1000), tracing.BalanceChangeUnspecified)
statedb.SetState(diffContractAddr, common.Hash{31: 0x07}, common.Hash{31: 0x07})
// Callee target for CALL/STATICCALL/DELEGATECALL.
statedb.CreateAccount(diffCalleeAddr)
statedb.SetBalance(diffCalleeAddr, uint256.NewInt(500), tracing.BalanceChangeUnspecified)
statedb.SetCode(diffCalleeAddr, diffCalleeCode, tracing.CodeChangeUnspecified)
// Caller EOA with a balance.
statedb.CreateAccount(diffCaller)
statedb.SetBalance(diffCaller, uint256.NewInt(1<<62), tracing.BalanceChangeUnspecified)
statedb.Finalise(true)
return statedb
}
func diffBlockCtx(merged bool) BlockContext {
ctx := BlockContext{
CanTransfer: func(StateDB, common.Address, *uint256.Int) bool { return true },
Transfer: func(StateDB, common.Address, common.Address, *uint256.Int, *params.Rules) {},
GetHash: func(uint64) common.Hash { return common.Hash{0xde, 0xad} },
Coinbase: common.HexToAddress("0xc01ba5e"),
BlockNumber: big.NewInt(8),
Time: 1234,
Difficulty: big.NewInt(0x20000),
GasLimit: 30_000_000,
BaseFee: big.NewInt(7),
BlobBaseFee: big.NewInt(3),
}
if merged {
h := common.HexToHash("0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")
ctx.Random = &h
}
return ctx
}
// TestExtraEIPs checks that EIPs enabled via Config.ExtraEips take effect even
// when they touch opcodes the generated dispatch inlines. PUSH0 (EIP-3855) on
// a pre-Shanghai config is the canary: the runtime table has it enabled but
// the baked fork gate does not, so execution must route through the table loop.
func TestExtraEIPs(t *testing.T) {
code := asm(PUSH0, STOP)
statedb := newDiffState(t)
statedb.SetCode(diffContractAddr, code, tracing.CodeChangeUnspecified)
statedb.Finalise(true)
evm := NewEVM(diffBlockCtx(false), statedb, params.TestChainConfig, Config{ExtraEips: []int{3855}})
evm.SetTxContext(TxContext{
Origin: diffCaller,
GasPrice: uint256.NewInt(1),
})
_, _, err := evm.Call(diffCaller, diffContractAddr, nil, NewGasBudget(100000), new(uint256.Int))
if err != nil {
t.Fatalf("PUSH0 enabled via ExtraEips failed: %v", err)
}
}
// runOne executes code at diffContractAddr with the given interpreter selection
// and returns the observable result.
func runOne(t testing.TB, cfg *params.ChainConfig, merged, useTableLoop bool, code, input []byte, gas uint64) diffResult {
t.Helper()
statedb := newDiffState(t)
statedb.SetCode(diffContractAddr, code, tracing.CodeChangeUnspecified)
statedb.Finalise(true)
evm := NewEVM(diffBlockCtx(merged), statedb, cfg, Config{})
evm.SetTxContext(TxContext{
Origin: diffCaller,
GasPrice: uint256.NewInt(1),
BlobHashes: []common.Hash{{0xb1, 0x0b}},
})
evm.forceTableLoop = useTableLoop
ret, leftOver, err := evm.Call(diffCaller, diffContractAddr, input, NewGasBudget(gas), new(uint256.Int))
errStr := ""
if err != nil {
errStr = err.Error()
}
return diffResult{
ret: ret,
gasLeft: leftOver.RegularGas,
errStr: errStr,
refund: statedb.GetRefund(),
root: statedb.IntermediateRoot(true),
logs: statedb.Logs(),
}
}
func TestInterpreterDiff(t *testing.T) {
for _, fk := range diffForks {
for _, prog := range diffPrograms {
t.Run(fk.name+"/"+prog.name, func(t *testing.T) {
input := common.FromHex("0xdeadbeef00000000000000000000000000000000000000000000000000000042")
table := runOne(t, fk.cfg, fk.merged, true, prog.code, input, prog.gas)
gen := runOne(t, fk.cfg, fk.merged, false, prog.code, input, prog.gas)
if where, ok := gen.equal(table); !ok {
t.Fatalf("divergence in %s:\n table: ret=%x gas=%d err=%q refund=%d root=%x logs=%d\n gen: ret=%x gas=%d err=%q refund=%d root=%x logs=%d",
where,
table.ret, table.gasLeft, table.errStr, table.refund, table.root, len(table.logs),
gen.ret, gen.gasLeft, gen.errStr, gen.refund, gen.root, len(gen.logs))
}
})
}
}
}
// FuzzInterpreterDiff fuzzes arbitrary bytecode + calldata + gas and asserts the
// generated dispatch matches the table-walking loop on every observable axis.
func FuzzInterpreterDiff(f *testing.F) {
for _, prog := range diffPrograms {
f.Add(prog.code, []byte{0x01, 0x02, 0x03, 0x04}, uint64(100000))
}
// A couple of structurally-interesting seeds.
f.Add(asm(PUSH1, 0x00, JUMPDEST, PUSH1, 0x01, ADD, DUP1, PUSH1, 0xff, GT, PUSH1, 0x02, JUMPI, STOP), []byte{}, uint64(50000))
f.Add(bytes.Repeat([]byte{byte(PUSH1), 0x01}, 64), []byte{}, uint64(100000))
f.Fuzz(func(t *testing.T, code, input []byte, gas uint64) {
if len(code) > 24576 { // max contract code size, keep cases realistic
return
}
if gas > 5_000_000 {
gas = 5_000_000 // bound execution time
}
for _, fk := range diffForks {
table := runOne(t, fk.cfg, fk.merged, true, code, input, gas)
gen := runOne(t, fk.cfg, fk.merged, false, code, input, gas)
if where, ok := gen.equal(table); !ok {
t.Fatalf("divergence in %s (fork %s): code=%x input=%x gas=%d\n table: ret=%x gas=%d err=%q refund=%d root=%x logs=%d\n gen: ret=%x gas=%d err=%q refund=%d root=%x logs=%d",
where, fk.name, code, input, gas,
table.ret, table.gasLeft, table.errStr, table.refund, table.root, len(table.logs),
gen.ret, gen.gasLeft, gen.errStr, gen.refund, gen.root, len(gen.logs))
}
}
})
}

1995
core/vm/interp_gen.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
// 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 <http://www.gnu.org/licenses/>.
package vm
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// TestGeneratedDispatchUpToDate asserts that the committed interp_gen.go matches
// what `go generate` (core/vm/gen) produces from the current opcode/gas/fork
// definitions. It is the CI guard against hand-edits to the generated file and
// against the generator drifting from the committed output.
func TestGeneratedDispatchUpToDate(t *testing.T) {
if testing.Short() {
t.Skip("skipping generator round-trip in -short mode")
}
tmp := filepath.Join(t.TempDir(), "interp_gen.go")
cmd := exec.Command("go", "run", "./gen")
cmd.Env = append(os.Environ(), "INTERP_GEN_OUT="+tmp)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("running generator: %v\n%s", err, out)
}
got, err := os.ReadFile(tmp)
if err != nil {
t.Fatalf("reading regenerated output: %v", err)
}
want, err := os.ReadFile("interp_gen.go")
if err != nil {
t.Fatalf("reading committed interp_gen.go: %v", err)
}
if string(got) != string(want) {
t.Fatalf("interp_gen.go is out of date; run `go generate ./core/vm/...` and commit the result")
}
}

View file

@ -92,6 +92,9 @@ func (ctx *ScopeContext) ContractCode() []byte {
// It's important to note that any errors returned by the interpreter should be
// considered a revert-and-consume-all-gas operation except for
// ErrExecutionReverted which means revert-and-keep-gas-left.
//
// Run sets up the per-call scope and picks the interpreter loop. Without a
// tracer it runs the generated execUntraced, otherwise execTraced.
func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
// Increment the call depth which is restricted to 1024
evm.depth++
@ -112,17 +115,48 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte
if len(contract.Code) == 0 {
return nil, nil
}
contract.Input = input
mem := NewMemory() // bound memory
stack := evm.arena.stack() // local stack
scope := &ScopeContext{
Memory: mem,
Stack: stack,
Contract: contract,
}
// The exit hooks in execTraced still need the stack and memory, so this
// teardown must run after them. Defers in execTraced fire before this one,
// so the ordering holds.
defer func() {
stack.release()
mem.Free()
}()
// The generated fast path bakes its fork gates at generate time, so it
// cannot see table changes made by ExtraEips. Those configs run the table
// loop, as do tracing and the differential test via forceTableLoop.
if evm.forceTableLoop || len(evm.Config.ExtraEips) > 0 || evm.Config.Tracer != nil {
return evm.execTraced(scope)
}
return evm.execUntraced(scope)
}
// execTraced is the old table-walking interpreter loop. It dispatches every
// opcode through the per-fork operation table and emits the tracing hooks
// exactly as before. It stays hand-written so trace output does not change.
//
// The differential test also runs this loop without a tracer and compares it
// against the generated execUntraced, which keeps the two from drifting
// apart.
func (evm *EVM) execTraced(scope *ScopeContext) (ret []byte, err error) {
var (
op OpCode // current opcode
jumpTable *JumpTable = evm.table
mem = NewMemory() // bound memory
stack = evm.arena.stack() // local stack
callContext = &ScopeContext{
Memory: mem,
Stack: stack,
Contract: contract,
}
mem = scope.Memory
stack = scope.Stack
contract = scope.Contract
callContext = scope
// For optimisation reason we're using uint64 as the program counter.
// It's theoretically possible to go above 2^64. The YP defines the PC
// to be uint256. Practically much less so feasible.
@ -136,15 +170,6 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte
debug = evm.Config.Tracer != nil
isEIP4762 = evm.chainRules.IsEIP4762
)
// Don't move this deferred function, it's placed before the OnOpcode-deferred method,
// so that it gets executed _after_: the OnOpcode needs the stacks before
// they are returned to the pools
defer func() {
stack.release()
mem.Free()
}()
contract.Input = input
if debug {
defer func() { // this deferred method handles exit-with-error
if err == nil {

View file

@ -0,0 +1,106 @@
// 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 <http://www.gnu.org/licenses/>.
// Whole-contract interpreter benchmarks from the evm-bench suite
// (github.com/ziyadedher/evm-bench). Each contract exposes a Benchmark()
// entrypoint (selector 0x30627b7c) that performs the whole workload internally
// (e.g. 5000 mints), so the benchmark is a single Call per iteration. These
// complement the opcode/loop micro-benchmarks in runtime_test.go with realistic
// workloads (a ray tracer, ERC-20 transfer/mint/approval, a hashing loop) and
// use the same names as evm-bench / gevm for cross-comparison.
//
// Run: go test ./core/vm/runtime/ -run '^$' -bench 'Benchmark(Snailtracer|TenThousandHashes|ERC20)' -benchmem -count=10
// Compare A/B: ./core/vm/runtime/testdata/evm-bench/compare.sh
//
// The committed runtime bytecode in testdata/*.hex is regenerated by
// testdata/evm-bench/gen.sh (solc via docker). See that script for provenance
// and exact compiler versions.
package runtime
import (
_ "embed"
"encoding/hex"
"strings"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
"github.com/holiman/uint256"
)
//go:embed testdata/snailtracer.hex
var benchSnailtracerHex string
//go:embed testdata/tenthousandhashes.hex
var benchTenThousandHashesHex string
//go:embed testdata/erc20transfer.hex
var benchERC20TransferHex string
//go:embed testdata/erc20mint.hex
var benchERC20MintHex string
//go:embed testdata/erc20approval.hex
var benchERC20ApprovalHex string
// benchmarkSelector is keccak256("Benchmark()")[:4], the evm-bench entrypoint.
var benchmarkSelector = []byte{0x30, 0x62, 0x7b, 0x7c}
func benchHexToBytes(s string) []byte {
b, err := hex.DecodeString(strings.TrimSpace(s))
if err != nil {
panic(err)
}
return b
}
// runEVMBench deploys the contract's runtime bytecode and repeatedly invokes its
// Benchmark() entrypoint. The contract's constructor is not run, so storage
// starts empty. The evm-bench contracts set up whatever state they need inside
// Benchmark(), which matches the work the evm-bench harness measures.
func runEVMBench(b *testing.B, codeHex string) {
code := benchHexToBytes(codeHex)
statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
if err != nil {
b.Fatal(err)
}
contract := common.BytesToAddress([]byte{0x10})
caller := common.BytesToAddress([]byte{0x01})
statedb.CreateAccount(caller)
statedb.AddBalance(caller, uint256.NewInt(1_000_000_000), tracing.BalanceChangeUnspecified)
statedb.SetCode(contract, code, tracing.CodeChangeUnspecified)
cfg := &Config{Origin: caller, State: statedb, GasLimit: 10_000_000_000}
// Sanity: the workload must run to completion, not revert/out-of-gas.
if _, _, err := Call(contract, benchmarkSelector, cfg); err != nil {
b.Fatalf("Benchmark() did not complete: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
Call(contract, benchmarkSelector, cfg)
}
}
func BenchmarkSnailtracer(b *testing.B) { runEVMBench(b, benchSnailtracerHex) }
func BenchmarkTenThousandHashes(b *testing.B) { runEVMBench(b, benchTenThousandHashesHex) }
func BenchmarkERC20Transfer(b *testing.B) { runEVMBench(b, benchERC20TransferHex) }
func BenchmarkERC20Mint(b *testing.B) { runEVMBench(b, benchERC20MintHex) }
func BenchmarkERC20ApprovalTransfer(b *testing.B) { runEVMBench(b, benchERC20ApprovalHex) }

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
608060405234801561001057600080fd5b50600436106100b45760003560e01c80633950935111610071578063395093511461013857806370a082311461014b57806395d89b4114610174578063a457c2d71461017c578063a9059cbb1461018f578063dd62ed3e146101a257600080fd5b806306fdde03146100b9578063095ea7b3146100d757806318160ddd146100fa57806323b872dd1461010c57806330627b7c1461011f578063313ce56714610129575b600080fd5b6100c16101b5565b6040516100ce919061079c565b60405180910390f35b6100ea6100e5366004610806565b610247565b60405190151581526020016100ce565b6002545b6040519081526020016100ce565b6100ea61011a366004610830565b610261565b610127610285565b005b604051601281526020016100ce565b6100ea610146366004610806565b6102b1565b6100fe61015936600461086c565b6001600160a01b031660009081526020819052604090205490565b6100c16102d3565b6100ea61018a366004610806565b6102e2565b6100ea61019d366004610806565b610362565b6100fe6101b036600461088e565b610370565b6060600380546101c4906108c1565b80601f01602080910402602001604051908101604052809291908181526020018280546101f0906108c1565b801561023d5780601f106102125761010080835404028352916020019161023d565b820191906000526020600020905b81548152906001019060200180831161022057829003601f168201915b5050505050905090565b60003361025581858561039b565b60019150505b92915050565b60003361026f8582856104bf565b61027a858585610539565b506001949350505050565b60005b6113888110156102ae5761029c33826106dd565b806102a681610911565b915050610288565b50565b6000336102558185856102c48383610370565b6102ce919061092a565b61039b565b6060600480546101c4906108c1565b600033816102f08286610370565b9050838110156103555760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b60648201526084015b60405180910390fd5b61027a828686840361039b565b600033610255818585610539565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6001600160a01b0383166103fd5760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b606482015260840161034c565b6001600160a01b03821661045e5760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b606482015260840161034c565b6001600160a01b0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b60006104cb8484610370565b9050600019811461053357818110156105265760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e6365000000604482015260640161034c565b610533848484840361039b565b50505050565b6001600160a01b03831661059d5760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b606482015260840161034c565b6001600160a01b0382166105ff5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b606482015260840161034c565b6001600160a01b038316600090815260208190526040902054818110156106775760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b606482015260840161034c565b6001600160a01b03848116600081815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3610533565b6001600160a01b0382166107335760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640161034c565b8060026000828254610745919061092a565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b600060208083528351808285015260005b818110156107c9578581018301518582016040015282016107ad565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461080157600080fd5b919050565b6000806040838503121561081957600080fd5b610822836107ea565b946020939093013593505050565b60008060006060848603121561084557600080fd5b61084e846107ea565b925061085c602085016107ea565b9150604084013590509250925092565b60006020828403121561087e57600080fd5b610887826107ea565b9392505050565b600080604083850312156108a157600080fd5b6108aa836107ea565b91506108b8602084016107ea565b90509250929050565b600181811c908216806108d557607f821691505b6020821081036108f557634e487b7160e01b600052602260045260246000fd5b50919050565b634e487b7160e01b600052601160045260246000fd5b600060018201610923576109236108fb565b5060010190565b8082018082111561025b5761025b6108fb56fea2646970667358221220fa5dc8b94ca8266a9f37b573a3ac8993380ce8da74caa7732abd6e7ed31cd37e64736f6c63430008110033

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
# evm-bench whole-contract interpreter benchmarks
Realistic-workload benchmarks for the `core/vm` interpreter, driven by contracts
from the [evm-bench](https://github.com/ziyadedher/evm-bench) suite. They
complement the opcode/loop micro-benchmarks in `runtime_test.go`.
- Benchmark code: [`core/vm/runtime/evm_bench_test.go`](../../evm_bench_test.go)
- Runtime bytecode: `core/vm/runtime/testdata/*.hex` (committed, `//go:embed`-ed)
Each contract exposes a `Benchmark()` entrypoint (selector `0x30627b7c`) that
performs the entire workload internally, so one `runtime.Call` = one full
workload:
| benchmark | workload |
|---|---|
| `Snailtracer` | a ray tracer, compute and memory heavy |
| `ERC20Transfer` | ERC-20 transfers in a loop |
| `ERC20Mint` | 5000 `_mint`s (SSTORE/keccak/LOG-heavy) |
| `ERC20ApprovalTransfer` | 1000 approve+transferFrom cycles |
| `TenThousandHashes` | a keccak loop |
## Running
```sh
# all of them, current build
go test ./core/vm/runtime/ -run '^$' -bench 'Benchmark(Snailtracer|TenThousandHashes|ERC20)' -benchmem -count=10
# A/B vs a baseline (default master), with benchstat
core/vm/runtime/testdata/evm-bench/compare.sh # vs master
core/vm/runtime/testdata/evm-bench/compare.sh HEAD~1 8 # vs another ref, count=8
```
`compare.sh` benches the current working tree and a baseline ref (checked out in
a throwaway worktree with this suite copied in, so it works whether or not the
interpreter change is committed) and runs `benchstat`.
## Regenerating the bytecode
[`gen.sh`](gen.sh) regenerates the ERC-20 and ten-thousand-hashes `.hex` from
the evm-bench Solidity sources (solc 0.8.17 via docker). These contracts set up
their state inside `Benchmark()`, so the runtime bytecode is callable directly.
`snailtracer.hex` is **vendored, not regenerated by gen.sh**: evm-bench's
`SnailTracer` initializes its scene in the constructor, so its runtime bytecode
(no constructor) renders an empty scene. The committed file is a runtime-callable
build (scene encoded in code) vendored from
[Giulio2002/gevm](https://github.com/Giulio2002/gevm)'s gethbench testdata.
The `.hex` files are committed, so running the benchmarks needs no toolchain.
## Notes
- The contract constructor is not executed, so storage starts empty. The
evm-bench contracts set up whatever state they need inside `Benchmark()`,
matching the work the evm-bench harness measures.
- Names match evm-bench / gevm for cross-comparison.

61
core/vm/runtime/testdata/evm-bench/compare.sh vendored Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# A/B benchmark of the codegen interpreter vs a baseline: runs the evm-bench
# contract workloads (core/vm/runtime/evm_bench_test.go) plus the synthetic
# dispatch loops (BenchmarkSimpleLoop/loop*) on the current working tree and on
# a baseline ref, then benchstats them.
#
# The baseline is checked out in a throwaway git worktree and this suite is
# copied into it, so the comparison works whether or not the interpreter changes
# are committed yet. Requires: go, git, and benchstat
# (go install golang.org/x/perf/cmd/benchstat@latest).
#
# Usage: core/vm/runtime/testdata/evm-bench/compare.sh [baseref] [count]
# baseref defaults to "master", count to 10.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../../../.." && pwd)" # repo root
cd "$ROOT"
BASEREF="${1:-master}"
COUNT="${2:-10}"
# Each benchmark runs a FIXED iteration count instead of the default 1s of
# benchtime. With time-based benchtime the faster side runs more iterations,
# so one-time costs (map growth, pool warmup) amortize over a different N and
# B/op picks up phantom deltas, and GC pacing can do the same to sec/op. Fixed
# N makes both sides do identical work. The counts target roughly one second
# per count on a fast box. Each entry is pattern:iterations, and SimpleLoop
# needs the /^loop element to select only its loop variants (go test splits
# -bench on / and benchmarks without sub-benchmarks cannot match a two-element
# pattern, so the loops run as their own invocation anyway).
BENCHES=(
'^BenchmarkSnailtracer$:20x'
'^BenchmarkTenThousandHashes$:200x'
'^BenchmarkERC20Transfer$:100x'
'^BenchmarkERC20Mint$:150x'
'^BenchmarkERC20ApprovalTransfer$:120x'
'^BenchmarkSimpleLoop$/^loop:7x'
)
NEW="$(mktemp)"; OLD="$(mktemp)"
run_suite() { # run_suite <dir> <outfile>
local dir="$1" out="$2" entry pat n
for entry in "${BENCHES[@]}"; do
pat="${entry%:*}"
n="${entry##*:}"
( cd "$dir" && go test ./core/vm/runtime/ -run '^$' -bench "$pat" -benchmem -benchtime "$n" -count="$COUNT" ) | tee -a "$out"
done
}
echo "==> current working tree"
run_suite "$ROOT" "$NEW"
echo "==> baseline: $BASEREF (throwaway worktree, suite copied in)"
WT="$(mktemp -d)"
git worktree add --quiet --detach "$WT" "$BASEREF"
cp core/vm/runtime/evm_bench_test.go "$WT/core/vm/runtime/"
mkdir -p "$WT/core/vm/runtime/testdata"
cp core/vm/runtime/testdata/*.hex "$WT/core/vm/runtime/testdata/"
run_suite "$WT" "$OLD"
git worktree remove --force "$WT"
echo "==> benchstat: $BASEREF (left) vs working tree (right)"
benchstat "$OLD" "$NEW"

43
core/vm/runtime/testdata/evm-bench/gen.sh vendored Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Regenerates core/vm/runtime/testdata/*.hex, the runtime bytecode for the
# whole-contract interpreter benchmarks (see core/vm/runtime/evm_bench_test.go).
#
# Source: the evm-bench suite (github.com/ziyadedher/evm-bench). Each contract
# exposes Benchmark() (selector 0x30627b7c) which performs the whole workload.
# Compiled with solc via docker (no local solc needed). solc versions match each
# contract's pragma: 0.8.17 for the ERC-20/hashing contracts, 0.4.26 for the
# legacy SnailTracer.
#
# Usage: core/vm/runtime/testdata/evm-bench/gen.sh
set -euo pipefail
TD="$(cd "$(dirname "$0")/.." && pwd)" # .../core/vm/runtime/testdata
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
git clone --depth 1 https://github.com/ziyadedher/evm-bench "$WORK/evm-bench" >/dev/null 2>&1
B="$WORK/evm-bench/benchmarks"
# ERC-20 (transfer/mint/approval) + ten-thousand-hashes: pragma ^0.8.17. These
# set up all their state inside Benchmark(), so the runtime bytecode is callable
# directly with no constructor, which is how the benchmark drives them.
docker run --rm -v "$B":/src -w /src ethereum/solc:0.8.17 \
--optimize --bin-runtime --overwrite -o /src/out \
erc20/transfer/ERC20Transfer.sol \
erc20/mint/ERC20Mint.sol \
erc20/approval-transfer/ERC20ApprovalTransfer.sol \
ten-thousand-hashes/TenThousandHashes.sol
cp "$B/out/TenThousandHashes.bin-runtime" "$TD/tenthousandhashes.hex"
cp "$B/out/ERC20Transfer.bin-runtime" "$TD/erc20transfer.hex"
cp "$B/out/ERC20Mint.bin-runtime" "$TD/erc20mint.hex"
cp "$B/out/ERC20ApprovalTransfer.bin-runtime" "$TD/erc20approval.hex"
# NOTE: snailtracer.hex is not regenerated here. evm-bench's SnailTracer
# initializes its scene in the constructor, so its --bin-runtime (no constructor)
# renders an empty scene. The committed snailtracer.hex is a runtime-callable
# build (scene encoded in code, Benchmark() self-contained) vendored from
# Giulio2002/gevm's gethbench testdata. Leave it as-is.
echo "regenerated (snailtracer.hex left vendored):"
ls -l "$TD"/*.hex

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
6080604052348015600f57600080fd5b506004361060285760003560e01c806330627b7c14602d575b600080fd5b60336035565b005b60005b614e20811015606a5760408051602081018390520160408051601f198184030190525280606381606d565b9150506038565b50565b600060018201608c57634e487b7160e01b600052601160045260246000fd5b506001019056fea2646970667358221220e499ea6d1ccd5fce28e8cd2507f6fef33f0d005543e04580f290bbd01df0655464736f6c63430008110033