Skip to content

Rationalize IntraBlockState to a 2-Cache Model #19623

@mh0lt

Description

@mh0lt

Summary

We currently cache Ethereum execution state in four separate places, causing significant code duplication, a latent timing race between blocks, and an expensive round-trip in the hot path of parallel execution. The goal of this refactor is to reduce to two authoritative caches:

  • VersionMap / VersionedIO — intra-block, per-transaction, revertable MVCC memory. Workers write here; snapshots and reverts operate here. This is the source of truth during a block's execution.
  • SharedDomains — confirmed inter-block state. After a block is fully validated and ordered, its net writes are applied here. Workers for the next block read from this as their baseline.

Everything else — stateObject, rs.accounts / StateV3Buffered, and BufferedWriter — is redundant scaffolding that can be eliminated once the two-cache model is properly plumbed.


Background: The Current 4-Cache Architecture

During parallel block execution, a state write touches up to four distinct stores:

Cache Type Location Role
VersionedIO / WriteSet Field-granular MVCC map versionmap.go, versionedio.go Per-tx write set; conflict detection; BAL hash
stateObject Per-address struct state_object.go EVM read/write buffer inside IBS; snapshot substrate
StateV3Buffered.accounts (rs.accounts) Address-keyed in-memory map rw_v3.go Cross-tx/cross-block read cache; workaround for timing hole
SharedDomains Persistent in-memory B-tree shared_domains.go Block-to-block state; written to MDBX on commit

The Dual-Path Round-Trip

The most acute form of duplication is in finalize() inside exec3_parallel.go. After a transaction is validated and ordered, the code reconstructs an IntraBlockState from VersionedWrites, then immediately tears it back down into StateUpdates for domain application:

VersionedWrites (field-granular)
  → ApplyVersionedWrites → new IBS (stateObjects rebuilt from scratch)
    → FinalizeTx / MakeWriteSet → StateUpdates (account-granular)
      → BufferedWriter → SharedDomains + rs.accounts

VersionedWrites and StateUpdates carry the same information in different formats. VersionedWrites has BalancePath → uint256.Int, NoncePath → uint64, AddressPath → *accounts.Account, etc. StateUpdates has writeSet BTreeG[Address, *accountWrite] with full *accounts.Account structs. The AddressPath entry in VersionedWrites already carries the full account, so StateUpdates can be derived directly without the IBS round-trip.

The Timing Hole

In execLoop(), block N+1's workers are scheduled as soon as block N's blockExecutor.result is complete:

blockExecutor.applyResults <- blockResult  // async
// block N+1 workers start HERE:
blockExecutor, ok = pe.blockExecutors[blockResult.BlockNum+1]
if ok {
    blockExecutor.scheduleExecution(ctx, pe)
}

Block N+1 workers immediately begin reading state. But applyResults is an async channel — the exec() goroutine may not have applied block N's writes to SharedDomains yet. rs.accounts (StateV3Buffered.accounts) patches this by being populated synchronously in finalize() / nextResult() before applyResults is sent. This is a workaround for the race, not a fix. Eliminating rs.accounts requires making the domain apply synchronous (in execLoop(), before scheduling N+1).

stateObject Eliminability

stateObject currently serves three roles:

  1. Write bufferdata.Balance, data.Nonce, dirtyStorage, dirtyCode, selfdestructed. Fully duplicated by VersionedWrites when versionMap != nil.
  2. Read cachecode []byte, originStorage (pre-tx values for EIP-1283/2929), blockOriginStorage. Replaceable by a lightweight codeCache map[Address][]byte and committedStorage map[Address]map[StorageKey]uint256.Int on IBS.
  3. Snapshot/revert substrate — journal entries already contain prev + wasCommited booleans sufficient to revert VersionedWrites independently of stateObject. The one exception is resetObjectChange (for CreateAccount overwriting an existing account), which currently snapshots the whole *stateObject and needs to be migrated to snapshot map[AccountKey]*VersionedWrite.

Since we are decommissioning the serial execution path, versionMap will always be non-nil in IBS. This makes the stateObject write-buffer role immediately redundant.

The newlyCreated flag (EIP-6780, SELFDESTRUCT semantics) is derivable from VersionedWrites by checking whether IncarnationPath is present with a write from the current transaction.


Phased Refactoring Plan

Phase 1 — Establish a Test Baseline

Before any structural changes, add tests that will catch regressions across all subsequent phases.

New tests in execution/state/intra_block_state_test.go:

  • TestVersionedWritesMatchStateObjects — for a randomized sequence of EVM operations, assert that every field in every stateObject is reflected in VersionedWrites with the same value.
  • TestSnapshotRandomWithVersionMap — extend the existing TestSnapshotRandom to always set versionMap, and verify that revert produces identical state in both stateObjects and VersionedWrites.
  • TestCommittedStateWithVersionMap — verify that GetCommittedState returns pre-tx values (EIP-1283) correctly when reading through versionMap at txIndex-1.
  • TestStateUpdatesEquivalence (initially skipped) — given the same tx sequence, assert that StateUpdatesFromVersionedWrites() (not yet implemented) produces identical StateUpdates to the existing BufferedWriter path. Unskip in Phase 2.

New tests in execution/stagedsync/exec3_parallel_test.go:

  • TestCrossBlockStateReadConsistency — two sequential blocks; block N+1 workers must read block N's committed writes. Assert no stale reads.
  • TestCrossBlockTimingRace — stress test: inject artificial delay in exec() goroutine's ApplyTxState; verify block N+1 workers still see correct state. This test should be designed to fail on the current code without rs.accounts, to demonstrate the hole.
  • TestDomainApplyFromVersionedWrites — assert that applying StateUpdates derived from VersionedWrites to a test SharedDomains instance produces the same domain state as the existing path.

Phase 2 — Derive StateUpdates Directly from VersionedWrites

Goal: Eliminate the VersionedWrites → IBS → StateUpdates round-trip in finalize().

Changes:

  • Add StateUpdatesFromVersionedWrites(writes VersionedWrites, rules *chain.Rules) StateUpdates in versionedio.go (or a new file). Iterate writes, reconstruct *accounts.Account from AddressPath (full account), BalancePath, NoncePath, IncarnationPath, CodePath, StoragePath, SelfDestructPath.
  • In finalize(), replace ApplyVersionedWrites → IBS → FinalizeTx/MakeWriteSet → BufferedWriter with a direct call to StateUpdatesFromVersionedWrites.
  • Keep rs.accounts update in place (still needed for timing hole workaround — removed in Phase 3).

Tests to un-skip/add:

  • TestStateUpdatesEquivalence — un-skip; must pass.
  • TestStateUpdatesFromVersionedWritesSelfDestruct — verify selfdestruct is represented correctly (account deletion vs. zeroing).
  • TestStateUpdatesFromVersionedWritesIncarnation — verify that a recreated contract (new incarnation) produces correct domain writes.

Phase 3 — Fix the Timing Hole; Eliminate rs.accounts

Goal: Make domain apply synchronous so SharedDomains is always up-to-date before block N+1 workers start.

Changes:

  • In execLoop(), after blockExecutor.result is complete, call applyBlockWritesToDomains(blockResult) synchronously before scheduleExecution(blockResult.BlockNum+1). This replaces the async applyResults channel send for the domain-apply portion (the channel may still be used for other coordination).
  • Remove StateV3Buffered.accounts (the rs.accounts map) and bufferedReader. Workers now read from SharedDomains directly (via ReaderV3) for cross-block state.
  • Remove BufferedWriter's rs.accounts update side effect; it is now dead code.

Tests:

  • TestCrossBlockTimingRace — must now pass without rs.accounts.
  • TestCrossBlockApplyOrdering — assert that applying block N synchronously before scheduling N+1 means N+1 workers never see stale state, even under goroutine scheduling pressure.
  • TestDomainConsistencyAfterBlockApply — after applyBlockWritesToDomains, assert the SharedDomains snapshot matches what VersionedWrites predicted.
  • TestNoRsAccountsRace — run the race detector (-race) over a multi-block parallel execution scenario; confirm no data races on the eliminated rs.accounts map.

Phase 4 — Journal Reverts via VersionedWrites Only; Migrate resetObjectChange

Goal: Decouple journal revert from stateObject, so Phase 5 can remove stateObject entirely.

Changes:

  • In all journal entries (balanceChange, nonceChange, storageChange, addSlotToAccessListChange, etc.), remove the stateObject field mutations from the revert() method. The VersionedWrites revert path (already present in the if s.versionMap != nil block) is the only one that matters.
  • Migrate resetObjectChange: instead of storing *stateObject, store map[AccountKey]*VersionedWrite (a snapshot of the pre-CreateAccount writes for that address). Revert restores those entries.
  • Remove balanceInc map from IBS (only used in serial path).
  • Verify that newlyCreated derivation from VersionedWrites is correct for EIP-6780.

Tests:

  • TestJournalRevertWithVersionMap — revert every journal entry type and assert VersionedWrites contains the correct previous value.
  • TestResetObjectChangeMigrateWrites — create an account, write to it, then CreateAccount over it, then revert; assert the pre-creation writes are fully restored in VersionedWrites.
  • TestSnapshotRandomWithVersionMapExtended — extend Phase 1 test with more operations including CreateAccount over existing accounts, code sets, self-destructs. Must pass with journal reverts driven solely by VersionedWrites.

Phase 5 — Remove stateObject

Goal: IntraBlockState holds no stateObjects map. All reads go through versionMap/storage reader; all writes go to VersionedWrites.

Changes:

  • Add codeCache map[Address][]byte and committedStorage map[Address]map[StorageKey]uint256.Int to IntraBlockState for the read-cache role of stateObject.
  • GetCommittedState(addr, key) reads from committedStorage if present; otherwise reads versionMap at txIndex-1 (pre-tx value for EIP-1283 gas calculation); populates committedStorage on first access.
  • GetCode(addr) reads from codeCache; populates from storage reader on miss.
  • Remove stateObjects, stateObjectsDirty, nilAccounts, dirtyStorage, originStorage, blockOriginStorage from IBS.
  • Remove state_object.go (or reduce to an empty shim for the compilation cycle).

Tests:

  • TestGetCommittedStateNoStateObject — assert EIP-1283 original values are correct when reading through committedStorage/versionMap.
  • TestCreatedContractFlagFromVersionedWrites — assert newlyCreated is correctly derived from presence of IncarnationPath write in current tx.
  • TestSSTOREGasCalcNoStateObject — end-to-end SSTORE gas calculation for cold/warm/original value cases, with no stateObject.
  • Full execution-spec-tests (TestExecutionSpecBlockchain) — run the full EEST fixture suite; all tests must pass.
  • TestDeleteRecreateSlots, TestDeleteCreateRevert — existing chain-level tests; must continue to pass.

Summary Table

Phase Goal Key Change Gate Tests
1 Baseline Add tests that will catch regressions TestVersionedWritesMatchStateObjects, TestCrossBlockTimingRace, TestSnapshotRandomWithVersionMap
2 Eliminate round-trip StateUpdatesFromVersionedWrites, remove IBS reconstruction in finalize() TestStateUpdatesEquivalence, TestStateUpdatesFromVersionedWritesSelfDestruct
3 Fix timing hole Synchronous domain apply in execLoop, remove rs.accounts TestCrossBlockTimingRace (now passing), TestNoRsAccountsRace
4 Decouple journal from stateObject Revert only via VersionedWrites; migrate resetObjectChange TestJournalRevertWithVersionMap, TestResetObjectChangeMigrateWrites
5 Remove stateObject codeCache, committedStorage on IBS; delete state_object.go TestGetCommittedStateNoStateObject, TestSSTOREGasCalcNoStateObject, EEST suite

Files Affected

  • execution/state/intra_block_state.go — major (Phases 2–5)
  • execution/state/state_object.go — removed in Phase 5
  • execution/state/journal.go — Phase 4
  • execution/state/versionedio.go — Phase 2 (StateUpdatesFromVersionedWrites)
  • execution/state/rw_v3.go — Phase 3 (remove StateV3Buffered.accounts, bufferedReader, BufferedWriter side-effect)
  • execution/stagedsync/exec3_parallel.go — Phases 2 and 3 (simplify finalize(), synchronize domain apply in execLoop)
  • execution/exec/state.go — Phase 3 (worker state reader simplified)

Metadata

Metadata

Assignees

Type

No fields configured for EPIC.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions