Skip to content

perf(trie): cache root hash with dirty-bit, make Hash() O(1) on unmodified trie#39

Open
simonzg wants to merge 2 commits into
mainnetfrom
perf/trie-hash-cache
Open

perf(trie): cache root hash with dirty-bit, make Hash() O(1) on unmodified trie#39
simonzg wants to merge 2 commits into
mainnetfrom
perf/trie-hash-cache

Conversation

@simonzg

@simonzg simonzg commented May 1, 2026

Copy link
Copy Markdown
Contributor

Why

trie.Hash() performs a full recursive bottom-up traversal of all trie nodes to recompute hashes on every single call. There is no caching: calling Hash() twice on an unchanged trie traverses the entire tree twice, doing identical work.

This matters in practice because state/stage.go calls Hash() in separate code paths on the same unchanged trie — once during state root computation and again during Revert(). For a trie with thousands of nodes (typical for an active account or storage trie during busy block execution), this doubles the hashing work per block with zero benefit.

More broadly, any code that calls Hash() for comparison or logging re-pays the full O(n) traversal even if the trie has not been touched since the last call.

What Changed

Added two fields to Trie:

cachedHash meter.Bytes32  // last computed root hash
hashDirty  bool           // set true on any TryUpdate/TryDelete
  • New() initialises cachedHash = root (already known from the DB key) and hashDirty = false — a freshly loaded trie is clean by definition.
  • TryUpdate and TryDelete set hashDirty = true.
  • Hash() returns cachedHash immediately when !hashDirty, skipping the tree traversal.
  • CommitTo populates cachedHash after commit (the hash is computed there anyway) and clears hashDirty.

Expected Impact

  • Hash() on an unmodified trie: O(n) tree traversalO(1) field read.
  • State root computation no longer re-traverses tries that haven't changed since the previous call.
  • stage.go revert path no longer pays full trie traversal cost.
  • Cumulative savings across all account and storage tries per block are significant, especially for read-heavy blocks where many tries are loaded but not written.

simonzg and others added 2 commits July 10, 2025 09:54
…e is unmodified

trie.Hash() previously called hashRoot() on every invocation, which performs
a full recursive traversal of all trie nodes recomputing hashes bottom-up.
Calling Hash() twice on an unmodified trie traversed the entire tree twice.

In state/stage.go, Hash() is called in separate code paths on the same
unchanged trie — once during state root computation and again during revert —
paying the full traversal cost both times for no reason.

Add two fields to Trie:
  cachedHash meter.Bytes32  — last computed root hash
  hashDirty  bool           — true if trie was modified since last Hash()

TryUpdate and TryDelete set hashDirty = true.
Hash() returns cachedHash immediately when hashDirty is false.
CommitTo populates cachedHash after commit (hash is computed there anyway).
New() initialises cachedHash = root (already known) with hashDirty = false.

Hash() on an unmodified trie: O(n) tree traversal → O(1) field read.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant