Skip to content

exec: stop re-hashing bytecode on every state-object load - #22956

Merged
AskAlexSharov merged 8 commits into
mainfrom
alex/code_rehash_37
Aug 3, 2026
Merged

AskAlexSharov merged 8 commits into
mainfrom
alex/code_rehash_37

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

getStateObject and reconstructCellFlags recomputed keccak(code) on every rebuild, to keep the rebuilt stateObject's CodeHash in step with the code cells. The hash was already available at every source that produced the bytes.

Why it is hot

Erigon's CodeDomain is keyed by address, not by code hash (geth/reth/gevm all key their code store by hash, so they never re-derive it on a load). Two independent cells — the account record's CodeHash and the code bytes — can therefore disagree, e.g. when a prior tx in the block sets an EIP-7702 delegation whose AddressPath record was published with the old hash. The recompute was a blunt way to make obj.code and obj.data.CodeHash agree.

That was affordable while getStateObject cached its result. Under noMaterialize (#22409) it no longer does — the object is rebuilt on every call — so a per-load hash became a per-read hash of the entire contract, reached from balance, nonce and code-hash reads alike.

In a 300s mainnet parallel-exec profile:

cum share
getStateObject -> Keccak256Hash 15.01s 83.6% of all keccak in the process
opKeccak256 (the KECCAK256 opcode) 2.73s —

The internal resync cost 5.5x the opcode it exists to implement.

Fix

refreshCode now returns the writer's hash alongside the bytes, and the caller resolves it per source:

  • write-set / version-map hit — the cell stores accounts.Code, which already carries the hash.
  • read-set hit recorded from committed storage — the account record's CodeHash is authoritative; the bytes cannot be newer than it. This produces exactly the pairing stateObject.Code() builds on its lazy path (accounts.Code{Hash: so.data.CodeHash, Bytes: code}).
  • read-set hit recorded from the version map — the probe that produced the hit already returned the cell, so its hash is carried through rather than re-derived. Version equality with the recorded read pins the same cell, so the hash pairs with the recorded bytes; the tiers that return before the probe leave it Nil and fall through to the rules above.

One case still hashes on every rebuild: a dirty address reading committed code. journal.dirties bypasses the read-once gate, so the rebuild re-probes, misses the version map, and resolves through the matching-header branch with source ReadSetRead — which does not carry the account record's authority the way StorageRead does. Unimproved rather than regressed; resolving it means widening the account-record rule to a read-set source, which is a behaviour change of its own and belongs in a separate PR.

refreshCode cannot just return accounts.Code: that type documents INVARIANT: Hash == Keccak256(Bytes), and the read-set source stores bytes only. The new refreshedCode carries no such promise, so a caller cannot silently take a bogus hash for a real one.

Numbers

BenchmarkGetStateObjectAfterCodeRead — the state-object rebuild every account-field read falls through to. n0 (EPYC 4344P), interleaved binaries, benchstat n=8, all p=0.000:

code size ns/op before ns/op after delta B/op before B/op after allocs/op before allocs/op after
32 B 530.3 ± 2% 228.1 ± 2% -56.98% 528 528 5 5
1024 B 2,236.0 ± 1% 207.5 ± 4% -90.72% 528 528 5 5
24576 B 36,977.5 ± 0% 204.3 ± 3% -99.45% 528 528 5 5
geomean 3.526us 213.1ns -93.96% 528 528 5 5

Before scales with bytecode length; after is flat. Allocations identical — this removes compute, not garbage.

Behaviour

One deliberate change: when the CodeDomain entry disagrees with the account record, the account record now wins. The CodeDomain is keyed by address, so it can hold bytes the account no longer owns — a cleared 7702 delegation leaves them behind — and the old code let those bytes overwrite obj.data.CodeHash / obj.original.CodeHash. stateObject.Code()'s lazy path already preferred the account record.

TestCommittedCodeHashComesFromAccountRecord, TestPriorTxCodeWriteHashComesFromTheCell and TestPriorTxCodeWriteHashSurvivesReadSetHit all fail on main. Each gives its cell a hash that disagrees with keccak(bytes), which is what makes the source of the hash observable — a real cell never lies, so on real input this changes cost, not values.

getStateObject and reconstructCellFlags recomputed keccak(code) to keep
the rebuilt stateObject's CodeHash in step with the code cells. The hash
was already known: the version map and the write set store accounts.Code,
which carries it, and a value read back from committed storage is covered
by the account record's own CodeHash.

Under noMaterialize (#22409) the state object is rebuilt on every
getStateObject call, so a per-load hash became a per-read hash of the
whole contract. In a 300s mainnet parallel-exec profile this was 15.01s
-- 83.6% of all keccak in the process, 5.5x the KECCAK256 opcode itself
(2.73s) -- reached from balance, nonce and code-hash reads alike.

refreshCode now returns the writer's hash alongside the bytes. It cannot
return accounts.Code: that type documents Hash == Keccak256(Bytes), and
the read-set source stores bytes only. The new refreshedCode carries no
such invariant and resolves the hash per source.

BenchmarkGetStateObjectAfterCodeRead (ns/op):

  code size     before    after
     32 B          331      175    -47%
   1024 B        1_255      171    -86%
  24576 B       23_620      171    -99.3%

Before scales with code length; after is flat.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes IntraBlockState state-object reconstruction by avoiding repeated keccak256(code) hashing when code bytes are already available together with a trustworthy hash (or when the committed account’s CodeHash is authoritative). This fits into Erigon’s execution/state fast-paths for version-map backed (parallel/BAL) execution and noMaterialize operation.

Changes:

  • Extend CodePath reads to carry an optional “known hash” alongside code bytes, and add refreshedCode + hash-resolution logic to reuse it.
  • Update getStateObject and reconstructCellFlags to use the resolved hash instead of always hashing bytecode.
  • Add a benchmark and a regression-style test covering the state-object rebuild path after a prior code read.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
execution/state/read_paths.go Introduces refreshedCode and resolves code hashes without re-hashing when a known hash or authoritative committed CodeHash is available.
execution/state/intra_block_state.go Switches state-object reconstruction to use refreshedCode.codeHash(...) rather than unconditional bytecode hashing.
execution/state/code_rehash_bench_test.go Adds a benchmark and a test around noMaterialize state-object rebuild behavior after warming CodePath.

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

Comment thread execution/state/code_rehash_bench_test.go Outdated
Comment thread execution/state/read_paths.go
@AskAlexSharov
AskAlexSharov marked this pull request as ready for review August 3, 2026 04:23
@AskAlexSharov
AskAlexSharov marked this pull request as draft August 3, 2026 04:56
The previous test seeded an account whose CodeHash already equalled
keccak(bytes), so it passed with or without the change. Replaced with a
subtest that seeds a CodeDomain entry disagreeing with the account
record: the account hash must win. It fails on main (the rebuilt object
takes keccak(stale bytes)) and passes here.

Also assign mapCodeKnownHash wherever mapCodeVal is assigned, so the two
cannot drift, and switch the benchmark to b.Loop().
…mment

Both discriminating tests now exist: the committed path (account record
wins over stale CodeDomain bytes) and the version-map path (the cell's
own hash wins over keccak of its bytes). The second one guards the
mapCodeKnownHash wiring -- a mis-plumbed field there would hand back a
wrong hash, not just a slow one. Both fail on main.

The comment above the resync still described re-deriving the hash from
the bytes, which is no longer what the code does.
@AskAlexSharov
AskAlexSharov marked this pull request as ready for review August 3, 2026 05:17
@AskAlexSharov AskAlexSharov changed the title execution/state: stop re-hashing bytecode on every state-object load exec: stop re-hashing bytecode on every state-object load Aug 3, 2026
@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

before: #22955 (comment)

after:
Screenshot 2026-08-03 at 12 42 13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

execution/state/code_read_parallel_test.go:127

  • Benchmark timing currently includes the one-time setup cost (committedCodeIBS + warm GetCode), which can skew ns/op and makes results less comparable to other benchmarks in this repo that call b.ResetTimer() before the measured loop.
		b.Run(fmt.Sprintf("code=%dB", codeLen), func(b *testing.B) {
			ibs, addr := committedCodeIBS(b, codeLen, nil)
			b.ReportAllocs()
			for b.Loop() {
				if _, err := ibs.getStateObject(addr, false); err != nil {
					b.Fatal(err)
				}
			}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (2)

execution/state/read_paths.go:1116

  • The comment says "KnownHash is Nil", but this field is an accounts.CodeHash, so the sentinel value is NilCodeHash. Using the constant name here avoids confusion with Go's nil.
// refreshedCode is refreshCode's result. Unlike accounts.Code it promises no
// Hash == Keccak256(Bytes): KnownHash is Nil when the source stored bytes only.
type refreshedCode struct {
	Bytes     []byte
	KnownHash accounts.CodeHash
}

execution/state/code_read_parallel_test.go:128

  • The benchmark includes setup work (committedCodeIBS + warming GetCode) in the timed section, which can skew ns/op—especially for small code sizes. Most benchmarks in this package reset the timer after setup (e.g. execution/state/reset_bench_test.go:45).
		b.Run(fmt.Sprintf("code=%dB", codeLen), func(b *testing.B) {
			ibs, addr := committedCodeIBS(b, codeLen, nil)
			b.ReportAllocs()
			for b.Loop() {
				if _, err := ibs.getStateObject(addr, false); err != nil {
					b.Fatal(err)
				}
			}
		})

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified locally: both new tests fail on main and pass here; the benchmark reproduces (flat ~225ns vs scaling to ~27us at 24KB); execution/state suite green.

Two non-blocking nits:

  1. The residual-keccak list in the description misses one case: for a dirty address (journal.dirties) the read-once gate is bypassed, so a committed code read resolves through the MVReadResultNone matching-header branch with source ReadSetRead and still re-hashes on every rebuild. Unimproved rather than regressed — maybe worth a sentence in the description.

  2. In the MVReadResultDone read-set hit (prHeader.Version == hdr.Version in versionedReadCore), the just-probed cell hash is already in r.hashOfMapCodeVal, but refreshCode discards it and the caller re-hashes. Returning it for outcomeReadSetHit is safe for every source of that outcome (the other read-set-hit branches leave the field Nil, and version equality pins the same CodePath cell), so it would shave part of the remaining keccak without the CodePath/CodeHashPath pairing assumption of the deferred suggestion. Follow-up material.

A dirty address bypasses the read-once gate, so the rebuild re-probes the
version map and resolves through outcomeReadSetHit. The probed cell's hash
was discarded there and the caller re-derived it from the bytes.
@AskAlexSharov
AskAlexSharov added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 990e5fc Aug 3, 2026
115 checks passed
@AskAlexSharov
AskAlexSharov deleted the alex/code_rehash_37 branch August 3, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants