erigontech / erigontech/erigon

gevm benches - to get erigon closer

Open
#22,749 1 comment 0 reactions 1 assignee Claimed by @AskAlexSharov View on GitHub
performance type:feature
Dominant language
Go
Stars
3.6k
Forks
1.5k
Avg merge
1d 16h
Merged PRs (30d)
455

Description

Tracking the `alex/evm_37_dbg` EVM-performance work — goal: get erigon's EVM closer to **gevm** (`github.com/Giulio2002/gevm`). **geth master** included as a second reference.

## Setup / methodology
- Box: n0 dev (`dev-bm-e3-ethmainnet-n0`, AMD EPYC 4344P, 16 threads), go 1.26.5.
- **All impls reuse the EVM** across txs (mirrors production `StateProcessor`/parallel worker — `NewEVM` once, `Reset`/`SetTxContext` per tx — not `runtime.Call`-per-tx), so the comparison is fair.
- Erigon pinned to a **Cancun** chain config (single-dimensional gas) to match gevm (`spec.Cancun`) and geth's default.
- **ERC20** = one `transfer()` (27,069 gas); **Snailtracer** = compute-heavy interpreter workload. Medians over `-count=8..10`; profiles via 60s `-cpuprofile`.
- Harness: `tests/bench/erigonbench` + `gethbench` (mirror gevm's `tests/bench` names).

## Head-to-head — erigon main vs branch vs gevm vs geth (n0)

**ERC20 transfer** (EVM-reuse):

| metric | erigon main | erigon **branch** | gevm (target) | geth master |
|--------|------------:|------------------:|--------------:|------------:|
| allocs/op | 10 | **3** | 3 | 33 |
| B/op | 570 | **224** | 241 | 2668 |
| ns/op | ~4600 | ~4100 | ~3000 | ~5200 |

**Snailtracer** (ms, lower is better):

| erigon main | erigon **branch** | gevm (target) | geth master |
|------------:|------------------:|--------------:|------------:|
| 86.7 | **70.3** | 39.8 | 76.2 |

Takeaways: erigon reached **allocation parity with gevm** on ERC20 (3 allocs, fewer bytes), is ~6.6× fewer allocs / ~20% faster than geth, and on Snailtracer the branch now **beats geth** (70.3 < 76.2) and closed ~40% of the main→gevm gap. Remaining gap to gevm is **raw interpreter dispatch** (gevm uses a code-generated `switch`; erigon a jump-table + indirect dispatch).

## Per-PR impact (measured this session)

| PR | what it does | measured effect |
|----|--------------|-----------------|
| #22561 `journal_36` (merged) | alloc-free compact journal entries (no interface) | journal entries are now alloc-free tagged structs (like gevm) — removes the per-mutation `journalEntry` interface heap alloc; baseline for the rest |
| #22739 `journal_clear_37` | remove `journal.append`, drop redundant clears | trims remaining journal work on the hot path |
| #22723 (+ #22738, #22737) `logs_37` | store EVM logs by value; `[]*Log`→`[]Log` | LOG path `makeLog`+`AddLog` → consolidated `AllocLogFunc`; **ERC20 4→3 allocs, 256→224 B** (gevm parity) |
| #22721 `accessOptions_37` | non-ptr `accessOptions` (AccessSet value map) | removed per-address `*accessOptions` heap alloc in `MarkAddressAccess` |
| #22720 `rules_37` | precompute `BlockContext.Rules` | `NewRules` no longer per-tx (rules reused per block) |
| _isCode key-escape_ (PR TBD, maybe #22741 `copy_no_escape_37`) | stop `codeHash[:]` escaping in `Contract.isCode` | **ERC20 5→4 allocs, 288→256 B**; jumpdest cache now hits with 0 alloc/call |
| #22740 `pop2_37` | pointer-based stack pops + `pop2uint64` | opcode/stack operand handling |
| #22742 `access_l_sload_once_36` | memoize access-list slot set per address | **`AddSlotToAccessList` 2.01s→0.96s (−52%)**; Snailtracer −3.3% |
| #22743 `vm_run_trace_36` | keep disabled tracing out of the hot loop | **Snailtracer 75.9→70.3 ms (−7.4%)**, ERC20 −4.5% |
| #22744 `peek_intern_memo_36` | 1-entry top-of-stack intern memo | ~0 on Snailtracer (distinct keys thrash a 1-entry memo) — null result on this workload |
| #22748 `intern_key_cache_36` | per-EVM `InternKey` cache | **`gasSLoadEIP2929` 5.27s→2.86s (−46%)**, `unique.Make` off hot path (wall-time within noise) |

Cumulative (erigon main → branch): **ERC20 allocs 10→3 (−70%), B/op 570→224 (−61%), ns ~−10%; Snailtracer ~86→70 ms (−18%); `gasSLoadEIP2929` ~6.5s→2.86s (−56%)**.

## Where the remaining time goes (Snailtracer, branch)
1. **Interpreter dispatch + stack ops (~60%)** — `EVM.Run` ~38%, `Stack.dup` ~6.5% (biggest single non-Run fn, inlinable), stack/opcode handlers ~15%.
2. **Versioned-state reads (~13%)** — `GetState`→`readStateForSet`→`mapaccess2_fast64` (Block-STM read path / MVHashMap lookups).
3. uint256 math ~5%, keccak ~2% (both inherent).

Next concrete levers: inline `Stack.dup`/`swap`/`pop` (gevm inlines these in its generated switch), and reduce the versioned-state map lookups on the read side.

## Caveats
- PR↔measurement mapping is inferred from push timing + PR titles; `4814c5d` bundled several `_37` PRs, so their individual splits within the 10→5 alloc drop are approximate.
- Two rows are **profile-level, not wall-time**: #22748 cut the function ~46% but end-to-end Snailtracer stayed within its ~1–2% noise band; #22744 was a genuine null on my workloads (needs a same-slot SLOAD loop to show).
- The `isCode` PR is not clearly identified in the open PR list — flagged TBD.

Benchmarks produced with Claude Code on the n0 dev box.

## Full benchmark suite — GEVM vs Erigon (n0, count=8, Cancun)

Standard suite (gevm `Transact` vs erigon `runtime.Call`; state-mutating rows noisier — see the EVM-reuse ERC20 table above for the apples-to-apples per-tx number). **+% = erigon slower than gevm, −% = faster.** geomean **+42%**.

Full table (sec/op)

| Benchmark | gevm | erigon | erigon vs gevm |
|---|--:|--:|--:|
| Snailtracer | 38.3 ms | 71.6 ms | +87% |
| ERC20Transfer | 2.98 µs | 5.69 µs | +91% |
| Analysis | 3.73 µs | 6.62 µs | +77% |
| TenThousandHashes | 1.04 µs | 1.70 µs | +63% |
| Transfer | 678 ns | 1571 ns | +132% |
| RETURN/1K | 899 ns | 1316 ns | +46% |
| RETURN/10K | 1.54 µs | 2.09 µs | +35% |
| RETURN/100K | 8.61 µs | 7.78 µs | **−10%** |
| RETURN/1M | 105 µs | 69 µs | **−34%** |
| SWAP1/10k | 29.1 µs | 49.9 µs | +71% |
| SimpleLoop/staticcall-identity | 162.8 ms | 145.2 ms | **−11%** |
| SimpleLoop/call-identity | 189.4 ms | 201.3 ms | +6% |
| SimpleLoop/loop | 81.9 ms | 154.6 ms | +89% |
| SimpleLoop/call-nonexist | 231.2 ms | 211.4 ms | **−9%** |
| CREATE_500 | 2.48 ms | 9.07 ms | +265% |
| CREATE_1200 | 4.36 ms | 13.82 ms | +217% |
| CREATE2_500 | 59.0 ms | 59.3 ms | +0.5% |
| CREATE2_1200 | 51.1 ms | 52.7 ms | +3% |
| Opcode/EXP | 6.53 ms | 4.77 ms | **−27%** |
| Opcode/KECCAK256 | 51.5 ms | 53.5 ms | ~ |
| Opcode/AND | 10.6 ms | 13.2 ms | ~ |
| Opcode/MSTORE | 14.3 ms | 15.2 ms | +6% |
| Opcode/ADD | 10.8 ms | 14.3 ms | +33% |
| Opcode/MUL | 9.64 ms | 13.98 ms | +45% |
| Opcode/DIV | 9.53 ms | 13.02 ms | +37% |
| Opcode/LT · EQ · ISZERO | ~8.8 ms | ~13 ms | +44–55% |
| Opcode/MLOAD | 12.3 ms | 16.3 ms | +32% |
| Opcode/CALLDATALOAD | 11.9 ms | 14.9 ms | +25% |
| Opcode/PUSH1_POP | 6.39 ms | 13.38 ms | +110% |
| Opcode/DUP1_POP | 6.26 ms | 14.24 ms | +128% |
| Opcode/SWAP1 | 6.67 ms | 13.67 ms | +105% |

**Reading it:**
- Erigon already **beats/matches gevm** on large `RETURN` (−34%), `SimpleLoop/staticcall-identity` & `call-nonexist` (−9…−11%), `Opcode/EXP` (−27%), and **CREATE2** (~parity) — memory-copy / call-frame / big-int paths.
- The ~2× gap is **dispatch-bound**: `DUP1_POP` +128%, `PUSH1_POP` +110%, `SWAP1` +105%, `SimpleLoop/loop` +89%, `Snailtracer` +87% — the code-generated-switch gap + non-inlined stack ops (`Stack.dup/swap/pop`).

**Two follow-up targets:**
1. **Interpreter dispatch / stack ops** — inline `Stack.dup/swap/pop` (gevm inlines these in its generated switch); this is the +100% micro-loops and most of Snailtracer/ERC20.
2. **CREATE (non-2) +217–265%** — a separate outlier in the init-code / account-creation path, independent of the interpreter.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.