BitrootBlog
Back to website ↗
© 2026 Bitroot · Content is for general information only and is not financial, investment, legal, or tax advice.
Editorial StandardsBack to website
← All articles
EVM foundations·2026/10/09·About 15 min

The EVM Inside Clients: Interpreters, JIT, and a Look at revm/evmone

Execution clients treat the EVM as a swappable executor behind a state-access interface. go-ethereum's built-in interpreter, evmone, and revm occupy different niches; multi-implementation consistency is guaranteed by spec test vectors, while the interpreter's optimization headroom concentrates on dispatch, gas metering, and memory management.

By Bitroot Core Team · Editorial standards

An execution client handles block sync, the transaction pool, state storage, JSON-RPC, and consensus integration at the same time, and the Ethereum Virtual Machine (EVM) is just one piece of it. The EVM does not know where blocks come from, nor does it decide which rule sets a transaction's gas price; it takes in bytecode, calldata, the execution environment, and gas, and returns return data, remaining gas, an exceptional status, and a set of state changes.

Only by drawing that boundary clearly can you see why go-ethereum, evmone, and revm sit in different places: one is an interpreter built into a client, one is a standalone cross-language library, and one is a Rust framework imported as a dependency. What follows covers only how the EVM is embedded in general-purpose clients and how multi-implementation consistency is guaranteed; parallel scheduling and multi-engine design are out of scope, and that part is covered in the further reading at the end.

The boundary between the EVM and the rest of the client

Whichever implementation you look at, the interaction between the EVM and its host converges on the same set of operations: read an account's balance, nonce, and code; read a storage slot; read a historical block hash; write back storage changes, emit logs, create or delete accounts; and account for gas spent and refunded. The only difference is the form in which this set of operations is exposed.

go-ethereum's core/vm implements the EVM as a struct plus an interpreter loop. The interpreter selects, per fork, a jump table with 256 entries, each holding an execution function, constant gas, a dynamic gas calculation function, stack height constraints, and memory requirements. The loop's order is: fetch the opcode, validate the stack, deduct constant gas, compute dynamic gas, expand memory if needed, and call the execution function; insufficient gas uniformly returns ErrOutOfGas. State access goes through StateDB, and block and transaction context go through BlockContext and TxContext. Intrinsic gas, nonce validation, fee deduction, and refund calculation are not in the interpreter but in the state transition layer.

revm's structure is more explicit: Evm consists of a Context and a builder, and Context in turn has three parts — the environment (Transaction, Block, Cfg), the Journal, and the Database. Database is an interface with only four required methods: basic reads an account, code_by_hash reads code, storage reads a storage slot, and block_hash reads a historical block hash. Data read in is cached in the Journal, which also records changes made during execution and hands back the state diff when execution ends. The interpreter never touches the database directly; it goes through the Host trait, which is grouped by block, transaction, config, database, and Journal, and covers sload, sstore, tload, tstore, account loading, logs, and self-destruct.

evmone turns that boundary into a C ABI. It implements EVMC (Ethereum Client-VM Connector API), an interface defined as the low-level ABI between the EVM and the client, with the client side specifying how the EVM accesses the environment and state. Because the boundary is a C ABI, evmone can be loaded by clients written in other languages; Erigon's retrospective notes that it plugged evmone into its own execution layer through EVMC and wrapped an extra layer of C++ code around it to reduce interface overhead.

How the three implementations differ in positioning

ImplementationLanguage and licenseFormState interfaceTypical reuse
go-ethereum core/vmGo, library code LGPL-3.0, GPL-3.0 under cmd/Interpreter built into the clientStateDBForked as an L2 execution client, e.g. OP Stack's op-geth
evmoneC++20, Apache-2.0Standalone module exposed through EVMCEVMC host interfaceLoaded as an execution module by clients such as Silkworm
revmRust, MITLibrary and framework, split into cratesDatabase and Host traitsUsed directly as a dependency by Reth, Foundry, Helios, and others; also a common choice for L2s and zkVMs

The three forms correspond to three engineering trade-offs. A built-in interpreter can be optimized together with its own state layer, jump table, and tracer, at the cost of being hard to reuse in other clients. A standalone library can be reused across languages, but it has to cross a generic interface, and the call overhead on both sides of that interface is the price. The same retrospective notes that EVMC was designed to let the host call the EVM and also let the EVM call back into the host; the latter brought considerable interface overhead when integrating with Erigon through CGo, and C++ code had to be added to eliminate it. The lesson is that plugging the fastest EVM into a client and making the client immediately faster are two different things.

The library form's payoff is more visible at the ecosystem level. revm's documentation lists users including block builders, clients such as Reth and Helios, tools such as Foundry and Hardhat, several L2s, and a number of zkVM projects. Conversely, the fork-go-ethereum route is showing signs of contraction on the L2 side: Optimism's documentation shows that op-geth ended support on May 31, 2026, and does not support the currently active Karst hard fork, with the recommended execution client now op-reth, built on Reth and revm.

Consistency is not proven by self-testing: state tests and multi-client testing

Consensus requires every node to arrive at exactly the same state for the same bytecode. Self-testing within a single client can only prove internal consistency, not agreement across implementations.

The cost of historical inconsistency can be quantified. On November 24, 2016, Ethereum forked at block 2686351: when transactions that deleted empty accounts ended out-of-gas, go-ethereum did not roll back those deletions while Parity did. The minority chain was abandoned around block 2686516, and roughly 165 blocks of output were voided; go-ethereum fixed its logging mechanism in v1.5.3 to match Parity's behavior, and the official note also pointed out that Parity had an inconsistency in the narrower case of an out-of-gas call to a precompiled contract. That revision added a clarification to EIP-161 that empty-account deletions should also roll back when state rolls back. Another trace of the same kind remains in client code: go-ethereum's state journal keeps an explicit touch flag for the RIPEMD160 precompile address 0x03, and a source comment says it reproduces the empty-account touch/revert special case at block 1714175. Hard-coded compatibility like this between specification and implementation will persist for a long time, and it is also why multi-implementation consistency has to be checked case by case.

The industry's response is to make the specification and the test vectors share a source. The execution-layer specification is maintained as a Python reference implementation (the Execution Layer Specification, usually called EELS), and the test framework generates test vectors (fixtures) from that specification. execution-spec-tests was originally a Python framework plus a case collection for generating test vectors; in November 2025 it moved wholesale into ethereum/execution-specs (the old repository was archived shortly after), and the official note said the fixture release location for clients was unchanged, so spec and tests now share one source.

A state test's verdict is straightforward: given a pre-execution state pre, an environment env, a transaction transaction, and per-fork expected results post, the client applying that transaction must match the state root hash and log digest logs recorded in post; for transactions expected to fail, expectException describes the expected exception type. Block tests cover block-level processing on top of that. These vectors can be run directly through each client's own test entry point — go-ethereum uses evm statetest, Besu uses evmtool state-test, Nethermind uses nethtest, and evmone uses evmone-statetest and evmone-blockchaintest — or they can be put into Hive, sending block payloads to clients over the Engine API and validating responses and state sync, which is the test form closest to production behavior. The ethpandaops hive-tests repository turns this kind of testing into a daily pipeline covering Besu, Erigon, EthereumJS, Ethrex, go-ethereum, Nethermind, Nimbus-EL, and Reth.

The limits of testing deserve the same clarity. It can only cover behavior that has already been written down, and every new fork adds another window that the vectors have not yet pinned; it constrains observable semantics, not performance, and not internal structure.

Instruction dispatch: jump tables, generated switch statements, and inlining

Every instruction the interpreter's main loop executes first has to locate its handler. The jump-table approach looks up a 256-entry array and then calls through a function pointer. Go cannot inline across function values, so every instruction pays for one array load plus one indirect call.

In 2026 go-ethereum submitted a change to replace dispatch with a generated switch (PR #35144 and its replacement, PR #35638). A dense switch on the opcode byte compiles into a jump table, and the compiler can inline hot handlers into the loop. The change has three layers: hot opcodes whose behavior is stable across forks get their own case, with constant gas and stack bounds written directly as constants; the few opcodes with dynamic gas that do not vary across forks still go through table metering but call their handlers by name; and any opcode that varies with the fork (CALL, CREATE, SSTORE, SLOAD, logs, and copy-style opcodes) continues to dispatch through the current fork's table. The original interpreter loop is kept as a reference implementation, and the tests run both loops and compare output, gas, errors, refunds, logs, and state root, plus differential fuzzing over arbitrary bytecode.

The A/B benchmark inside PR #35638 reports the following numbers, under the conditions of 2000 mainnet blocks (block numbers 25677501 to 25679500), the geth-benchmark-1 machine, go1.27.0, and 3 runs per side: throughput rises from 391.2 MGas/s to 424.9 MGas/s, average newPayload time drops from 71.6 ms to 65.1 ms, and the execution phase p50 drops from 37.47 ms to 31.58 ms. As of September 18, 2026, the PR is still open on GitHub and has not been merged, so these numbers are internal to the PR rather than a release measurement and should be treated as unverified when cited.

evmone took a different path. Its default baseline interpreter does only the most basic JUMPDEST analysis; the optional advanced interpreter uses indirect call threading, representing the loaded EVM program as a table of pointers to virtual instruction implementation functions and precomputing gas and stack requirements per basic block, validating them once when execution reaches the block entry. The cost is heavier bytecode analysis before execution.

Gas metering: constant and dynamic costs separated, cold/warm access priced per slot

The interpreter has to work out the cost before executing an opcode. go-ethereum splits it into a constant part and a dynamic part: constant gas is deducted directly, while dynamic gas is computed by the handler from the stack, memory, and state; memory expansion cost grows superlinearly with the number of words required, so the interpreter must first work out the memory size the opcode demands before metering.

After EIP-2929, gas metering and the state access layer are bound together. The proposal requires each transaction to maintain two sets, accessed addresses and accessed storage slots, with the storage-slot element type Set[Tuple[Address, Bytes32]]; a first access to a slot costs COLD_SLOAD_COST, or 2100 gas, a repeat access costs WARM_STORAGE_READ_COST, or 100 gas, and a first access to an address costs COLD_ACCOUNT_ACCESS_COST, or 2600 gas. The access sets belong to the transaction context and are physically held by the client's execution environment, so every SLOAD or call the interpreter makes has to come back and query and update them. This is also why changing the interpreter is hard to do in isolation from the state layer.

Precomputing gas per block has observable side effects. evmone's documentation states plainly that because the requirements of the whole basic block are checked up front, externally visible operations can occur that would have executed under per-instruction metering but do not under upfront metering; the documentation argues this is not a consensus problem, because execution terminates with a hard exception and all effects are rolled back, but it can produce different execution traces or end with a different exception type. Such differences fall exactly within the scope that the test vectors constrain.

Memory and call frames: moving allocation off the hot path

Every contract call needs a stack and memory. The EVM's stack is capped at 1024 items, memory expands on demand and is metered, and return data needs a buffer too. These objects are short-lived and created frequently, so implementations generally favor reuse over allocating each time: go-ethereum creates a single Memory object at the interpreter entry point and expands it word by word when an opcode demands more memory; revm's context layer holds an item-pooling FrameStack and reuses call frames by index.

Precompiles: native implementations and dependency trade-offs

Precompiled contracts are native implementations at a handful of fixed addresses; the host computes them directly at execution time without interpreting bytecode, and the interpreter only handles routing and gas metering. Some precompiles' costs also depend on input length.

A standalone library's trade-offs at this layer deserve a separate look. evmone's documentation describes two compromises: ecrecover is implemented by evmone itself and is slower; expmod uses a stub implementation by default that responds correctly only for known inputs, and getting the full implementation requires turning on EVMONE_PRECOMPILES_GMP=1 at build time and pulling in GMP as both a build and a runtime dependency. This reflects a general problem: precompile correctness must hold for arbitrary inputs, and bringing native code for big-integer arithmetic, elliptic-curve recovery, and Keccak hashing into bit-for-bit agreement with other implementations is itself a place where divergence readily hides.

Why JIT never became the mainstream choice

JIT (just-in-time compilation) in EVM clients has had exactly one official attempt: ethereum/evmjit, based on LLVM, compiled contract code to machine code at runtime to replace the interpreter-based EVM in a client. The repository is now archived, and its README states that the project is no longer maintained and should not be used for anything important.

The README does not say why maintenance stopped, but from a technical standpoint there are several obstacles. The EVM's jump targets come from values on the stack, code can be read at runtime, and the program structure is not known until execution, so a compiler has a hard time obtaining a stable control-flow graph at compile time the way it can on the JVM; the computation available per call is also bounded by the block gas limit, so the total payoff has a ceiling. The more mature alternative is to move analysis into a static phase, and EOF (EVM Object Format, EIP-7692) is exactly an attempt in that direction, covering items such as a container format, static relative jumps, stack validation, and separation of code and data sections. But that route is not active right now: EOF was removed from the Fusaka upgrade scope on April 28, 2025, with EIPs PR #9703 merged the same day covering only the Fusaka-stage removal; EIP-7692 itself is marked stagnant, and it does not appear in the official Glamsterdam scope list, EIP-7773. The change log of the third-party tracking site eipsinsight shows EIP-7692 being moved out of the candidate bucket during the August 27, 2026 Glamsterdam scope cleanup, though that item comes only from a third-party source. So in the near term the main battleground for general-client optimization is still the interpreter, and compiled execution has no room to land.

When these optimizations do not pay off

When execution is no longer the dominant cost, returns diminish. In the go-ethereum benchmark cited above, the execution phase p50 dropped from 37.47 ms to 31.58 ms, while total block time p50 dropped from 62.1 ms to 56.2 ms: by the same table's accounting, state reads, state hashing, and commit together take about a third of total time, approaching half once engine-layer overhead is included, and that ratio sets the ceiling for interpreter optimization. The more a workload leans toward state access and disk I/O, the lower the marginal payoff of optimizing the interpreter.

Fork differences keep optimizations from applying globally. Jump tables are generated per fork, and a generated switch can only inline opcodes whose behavior is stable across forks; an opcode whose metadata varies by fork must stay on the table-dispatch path, or it would bake in the wrong constant.

Semantic constraints draw the boundary of optimization. An optimization must not change log order, tracer hook order, exception type, or gas metering, all of which the test vectors pin down. That evmone example, where block-level upfront checks can terminate early, is exactly a place where optimization and observable semantics require an explicit trade-off.

The cost of consistency rises with the number of implementations. Each additional implementation adds another place on the consensus boundary where divergence is possible; test vectors can shrink that surface but not eliminate it. The redundancy value that multiple implementations bring and this cost are the two ends of the same trade-off.

Sources

  • revm documentation, Introduction: https://bluealloy.github.io/revm/
  • revm documentation, Architecture: https://bluealloy.github.io/revm/architecture.html
  • revm, the Database trait: https://docs.rs/revm/latest/revm/context/trait.Database.html
  • revm, the Host trait: https://docs.rs/revm-interpreter/latest/revm_interpreter/trait.Host.html
  • revm repository and user list: https://github.com/bluealloy/revm
  • evmone repository README (baseline and advanced interpreters, precompile trade-offs): https://github.com/ethereum/evmone
  • evmone, Efficient gas calculation algorithm for EVM: https://github.com/ethereum/evmone/blob/master/docs/efficient_gas_calculation_algorithm.md
  • EVMC, Ethereum Client-VM Connector API: https://github.com/ethereum/evmc
  • Silkworm project origin retrospective (EVMC and CGo interface overhead): https://erigon.substack.com/p/staged-sync-and-short-history-of
  • go-ethereum interpreter loop: https://github.com/ethereum/go-ethereum/blob/master/core/vm/interpreter.go
  • go-ethereum jump table definition: https://github.com/ethereum/go-ethereum/blob/master/core/vm/jump_table.go
  • go-ethereum PR #35638 (generated switch and PGO inlining, including benchmark conditions): https://github.com/ethereum/go-ethereum/pull/35638
  • go-ethereum repository and license notes: https://github.com/ethereum/go-ethereum
  • go-ethereum state transition layer (intrinsic gas, nonce validation, refunds): https://github.com/ethereum/go-ethereum/blob/master/core/state_transition.go
  • Ethereum Yellow Paper (memory expansion cost function): https://ethereum.github.io/yellowpaper/paper.pdf
  • EIP-2929, Gas cost increases for state access opcodes: https://eips.ethereum.org/EIPS/eip-2929
  • execution-specs repository (specification and test vectors): https://github.com/ethereum/execution-specs
  • The Weld is Complete (execution-spec-tests merge announcement): https://steel.ethereum.foundation/blog/2025-11-04_weld_final/
  • State test format documentation: https://steel.ethereum.foundation/docs/execution-specs/running_tests/test_formats/state_test/
  • Running test vectors directly and each client's entry point: https://steel.ethereum.foundation/docs/execution-specs/running_tests/consume/direct/
  • ethpandaops/hive-tests (daily multi-client consistency testing): https://github.com/ethpandaops/hive-tests
  • Ethereum Foundation security alert, the 2016-11-24 consensus bug: https://blog.ethereum.org/2016/11/25/security-alert-11242016-consensus-bug-geth-v1-4-19-v1-5-2
  • go-ethereum v1.5.3 release notes: https://github.com/ethereum/go-ethereum/releases/tag/v1.5.3
  • ethereum/evmjit repository (archived): https://github.com/ethereum/evmjit
  • EIP-7692, EVM Object Format (EOFv1) Meta: https://eips.ethereum.org/EIPS/eip-7692
  • EIP-7607 note on removing EOF (EIPs PR #9703): https://github.com/ethereum/EIPs/pull/9703
  • EIP-7773, Hardfork Meta - Glamsterdam (official scope list, without EOF): https://eips.ethereum.org/EIPS/eip-7773
  • Optimism documentation, op-geth end-of-support notice: https://docs.optimism.io/notices/op-geth-deprecation
  • Third-party tracking site eipsinsight, Glamsterdam upgrade scope and change log: https://eipsinsight.com/upgrade/glamsterdam

Further reading

Bitroot Multi-Engine Parallel Execution: Scheduling, Sharding, and the Conflict SurfaceRelatedBitroot Parallel EVM Architecture Overview: How Consensus, Execution, and State Work TogetherRelatedWhat EVM Compatibility Actually Means: Bytecode, Precompiles, JSON-RPC, and ToolingCompatibility
← PreviousStorage Layout: How Solidity State Variables Land in Slots
Contents
The boundary between the EVM and the rest of the clientHow the three implementations differ in positioningConsistency is not proven by self-testing: state tests and multi-client testingInstruction dispatch: jump tables, generated switch statements, and inliningGas metering: constant and dynamic costs separated, cold/warm access priced per slotMemory and call frames: moving allocation off the hot pathPrecompiles: native implementations and dependency trade-offsWhy JIT never became the mainstream choiceWhen these optimizations do not pay offSourcesFurther reading
Reading settings