You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue presents two things — separable for review:
Measurements of where time is actually spent during the canonical SSTORE-bloated commitment workload. These are useful as input to the broader debate about whether and where to rewrite the commitment trie, independent of the proposed solution below.
A proposed solution path drawn from those measurements: cross-block branch caching with decoded payload, shipped as 10 incremental low-risk PRs.
Linked to #20920 (the user-facing perf gap). #20920 closes when measurements confirm the gap is materially closed, by whatever path. This issue closes when the proposed PR sequence is merged. If after that the gap isn't closed, reopen the architecture debate with fresh measurements.
TL;DR
For the canonical SSTORE-bloated workload from #20920, the bottleneck is data retrieval during unfold — bloom filter + decompression + shortened-key expansion + decode for ~39k branches per block. Fold parallelization would not buy much on this workload because fold is not on the critical path (does not appear in the top 30 CPU consumers).
The proposed fix is to reduce data retrieval, not parallelize compute: extend caching so the same hot-account branches don't get re-loaded every block.
Caveat: this is one extreme workload (single-account, dense storage, cold cache). The conclusions are scoped to "fix this use case." See Part 1 "Workload character" for what we did NOT measure.
Part 1: Measurements
Setup
Workload: perf-devnet-3 block 24358306, 30M gas, 4,200 cold SSTOREs into one EOA's storage trie
This benchmark is one extreme, chosen as canonical from EthPandaOps' benchmarkoor suite because it stresses cold-cache + dense-storage. It is NOT representative of all commitment workloads:
Single-account, dense storage subtree. All 4,200 SSTOREs go into one EOA, so all keys share a 64-nibble account-hash prefix. The 16-way concurrent-commitment root fanout is unhelpful here (1 of 16 subtries does 100% of the work), but on cross-account workloads it matters.
Cold cache. Multi-block hot-account workloads (Uniswap router, USDT, etc. on mainnet) have very different repeat-rates and cache dynamics.
Per-block, not batch. Initial-sync batch commitment has a different cost profile (deferred writes, larger touched-key sets, different I/O amortization).
No reorgs. Reorg-heavy periods stress invalidation paths that this bench doesn't touch.
The measurements above tell us about the bottleneck on THIS workload specifically. They are evidence about one corner of the design space, not a verdict on the trie overall.
Part 2: What the measurements rule out
These four hypotheses for the bottleneck are all falsified by the runs above:
Fold work is NOT the bottleneck. Doesn't appear in the top 30 CPU consumers. Stage F (parallel tree-reduce fold) would deliver no measurable win on this workload.
Read I/O queue depth is NOT the bottleneck. 8 → 48 → 256 warmer goroutines moves mgas/s by 7% in a flat-then-regressing curve. NVMe is not queue-saturated; adding workers doesn't help.
In-memory cache misses are NOT the bottleneck. We took the storage cache hit rate from 0% to 100% (from the trie's perspective) without moving mgas/s. The work moved earlier in time but total cycles spent are the same.
Unfold parallelism is NOT the bottleneck. Touch-time warmer runs unfold reads concurrent to EVM execution. Wall time unchanged. Stage E (parallel pre-unfold into trie cell state) would deliver no measurable win on this workload either.
Part 3: Conclusions
For this workload specifically:
Fix data retrieval during unfold. The 39k branch reads × ~80 µs each per block (bloom + decompress + decode) are what we have to address. The smallest change that reduces this is keeping decoded branches cached across blocks so hot accounts don't re-load every time.
Fold parallelization won't buy much. Fold work isn't even visible in the top 30 CPU consumers. Stage E (parallel pre-unfold) and Stage F (parallel tree-reduce fold) — both architectural options we'd considered — would not deliver a measurable win on this workload. The 3.1 CPU-seconds of unfold-side decode is the floor regardless of how compute is scheduled.
More warmer goroutines won't help. Worker scaling is flat-then-regressing (8 → 7.08, 48 → 7.16, 256 → 6.75). Default already saturates what NVMe + MDBX can deliver in parallel.
Implications for the trie-rewrite debate (scoped)
These conclusions bear on the rewrite debate, but the scope of what they actually demonstrate is narrow:
They DO rule out parallelism-of-existing-algorithm as a way to close the gap on THIS workload. Stage E/F would not deliver here.
They DO NOT generalize to other workloads. A cross-account block where 16-way root fanout actually engages might benefit from parallelism in ways this bench can't show. Not measured.
They DO NOT rule out a different file format (smaller branches, no shortened-key indirection, no bloom filter). A different format might lower the unfold floor. That's a legitimate rewrite motivation independent of these measurements.
They DO NOT rule out a different trie shape (different account boundary, sub-fanout). The bloat block's single-account shape would specifically benefit from sub-fanout — that's a distinct angle this bench can't evaluate.
They DO suggest that any rewrite motivated PURELY by parallelism would be ineffective on this workload. A rewrite motivated by format or shape change is not addressed here at all.
A complete rewrite-or-not decision would need measurements across more of the workload space — at minimum: a cross-account block, a multi-block hot-account window, a batch-commitment fixture, and a reorg-heavy fixture. This issue does not attempt to settle the rewrite question — it only justifies the conservative caching path for the immediate gap on this specific workload.
Part 4: Proposed solution
Given the conclusion that the lever is data-retrieval reduction during unfold, the proposed fix is cross-block branch caching with decoded payload — shipped as 10 incremental PRs.
A decoded cache directly addresses the measured bottleneck:
Cache hit on hot accounts skips the bloom filter check, skips the seg decompression, skips the shortened-key expansion, skips the cell decode.
Decoded payload (vs encoded bytes) saves the decode CPU on every hit.
Cross-block persistence skips re-loading branches that haven't changed.
Caching is also the lowest-blast-radius option: cache miss = current behavior. No structural change to the trie algorithm, the file format, or the read path beyond a lookup-then-fallthrough.
Two-tier BranchCache (root-pinned + LRU) — still ephemeral
low-medium
small
~400
7
Cross-block persistence for BranchCache (feature-flagged)
medium-high
large on hot-account workloads
~300
8
Wire prefetcher + touch hooks (BAL + EVM-time)
medium
improves cache fill
~500
9
Centralize encode-side invalidation via BranchEncoder hook
low-medium
0
~150
10
Drop extractBranchCellAddresses (now redundant)
low
0
-200
PRs 1, 2, 9, 10 consolidate fragmented decode + traversal logic (4 partial decode paths, 2 traversals today) into single primitives — this pays back when caching lands on top because there's only one decoder to keep correct.
PR 7 is the perf-delivering PR. Lands behind COMMITMENT_BRANCH_CACHE_PERSIST=true for one release.
This issue presents two things — separable for review:
Linked to #20920 (the user-facing perf gap). #20920 closes when measurements confirm the gap is materially closed, by whatever path. This issue closes when the proposed PR sequence is merged. If after that the gap isn't closed, reopen the architecture debate with fresh measurements.
TL;DR
For the canonical SSTORE-bloated workload from #20920, the bottleneck is data retrieval during unfold — bloom filter + decompression + shortened-key expansion + decode for ~39k branches per block. Fold parallelization would not buy much on this workload because fold is not on the critical path (does not appear in the top 30 CPU consumers).
The proposed fix is to reduce data retrieval, not parallelize compute: extend caching so the same hot-account branches don't get re-loaded every block.
Caveat: this is one extreme workload (single-account, dense storage, cold cache). The conclusions are scoped to "fix this use case." See Part 1 "Workload character" for what we did NOT measure.
Part 1: Measurements
Setup
130a6e1b50Per-run results on block 24358306
EnableTrieWarmupremoved)WaitForDrainfix (cancel→wait)WarmupConfig.PreWarmedPreloadStoragedirect leaf readsKey invariant: total disk reads constant
/proc/PID/io read_bytesfor block 24358306 across all 7 runs: 1.272 GB ± 200 KB. The warmer just shifts WHEN reads happen, not WHETHER.CPU profile (Run P, bloat block dominates the 30s window)
Worker scaling
Flat-then-regressing curve.
Per-block branch read count
Run P combined cache stats post-Process:
39k × ~30 KB per branch ≈ 1.17 GB → matches measured 1.27 GB read_bytes.
Workload character — important caveat
This benchmark is one extreme, chosen as canonical from EthPandaOps' benchmarkoor suite because it stresses cold-cache + dense-storage. It is NOT representative of all commitment workloads:
The measurements above tell us about the bottleneck on THIS workload specifically. They are evidence about one corner of the design space, not a verdict on the trie overall.
Part 2: What the measurements rule out
These four hypotheses for the bottleneck are all falsified by the runs above:
Part 3: Conclusions
For this workload specifically:
Implications for the trie-rewrite debate (scoped)
These conclusions bear on the rewrite debate, but the scope of what they actually demonstrate is narrow:
A complete rewrite-or-not decision would need measurements across more of the workload space — at minimum: a cross-account block, a multi-block hot-account window, a batch-commitment fixture, and a reorg-heavy fixture. This issue does not attempt to settle the rewrite question — it only justifies the conservative caching path for the immediate gap on this specific workload.
Part 4: Proposed solution
Given the conclusion that the lever is data-retrieval reduction during unfold, the proposed fix is cross-block branch caching with decoded payload — shipped as 10 incremental PRs.
A decoded cache directly addresses the measured bottleneck:
Caching is also the lowest-blast-radius option: cache miss = current behavior. No structural change to the trie algorithm, the file format, or the read path beyond a lookup-then-fallthrough.
PR sequence
decodeBranchInto(refactor)unfoldKeyPathorchestrator patternWaitForDrainbug fixWarmupCacheto decoded payloadBranchCache(root-pinned + LRU) — still ephemeralextractBranchCellAddresses(now redundant)PRs 1, 2, 9, 10 consolidate fragmented decode + traversal logic (4 partial decode paths, 2 traversals today) into single primitives — this pays back when caching lands on top because there's only one decoder to keep correct.
PR 7 is the perf-delivering PR. Lands behind
COMMITMENT_BRANCH_CACHE_PERSIST=truefor one release.Validation discipline per PR
Sequencing
commitment/decoded-warmup-cacheoff post-parallel commitment calculations implemented #20805 main, ship PRs 1-10 in orderClosing criteria for THIS issue
All 10 PRs above merged.
Closing criteria for #20920 (separate)
If after the 10 PRs land the gap isn't closed, reopen the architecture debate (rewrite, sub-fanout, format change) with fresh measurements.
Detailed design
agentspecs/commitment-decoded-cache-pr-next.md— full design with PR #19954 commits to carry vs replace, file:line refactor targets, decode boundary analysis.