Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

perf(state): avoid full header decode in StateAtBlockNumber#3808

Open
0xsamalt wants to merge 3 commits into
NethermindEth:mainNethermindEth/juno:mainfrom
0xsamalt:perf/state-at-block-number-skip-header-decode0xsamalt/juno:perf/state-at-block-number-skip-header-decodeCopy head branch name to clipboard
Open

perf(state): avoid full header decode in StateAtBlockNumber#3808
0xsamalt wants to merge 3 commits into
NethermindEth:mainNethermindEth/juno:mainfrom
0xsamalt:perf/state-at-block-number-skip-header-decode0xsamalt/juno:perf/state-at-block-number-skip-header-decodeCopy head branch name to clipboard

Conversation

@0xsamalt

@0xsamalt 0xsamalt commented Jul 9, 2026

Copy link
Copy Markdown

Summary

Closes #3784.

StateAtBlockNumber only needs a block's global state root to check retention and construct a StateHistory, but it was going through pruner.HeaderByNumberIfStateRetained, which fully decodes the block header including fields like EventsBloom that this path never uses.

Background

#3782 already introduced partial-decode accessors for this exact purpose:

  • core.GetGlobalStateRootByBlockNumber
  • core.GetBlockHeaderHashByNumber

and pruner.RequireStateRetainedByBlockNumber already used the hash-based retention check pattern. The only remaining gap was that StateAtBlockNumber in blockchain/statebackend/statebackend.go hadn't been updated to use them.

Changes

  • pruner/retention.go: renamed HeaderByNumberIfStateRetainedStateRootByNumberIfStateRetained. It now returns *felt.Felt (the state root) instead of *core.Header, built from: GetGlobalStateRootByBlockNumberGetBlockHeaderHashByNumberGetBlockHeaderNumberByHash (retention check).
  • blockchain/statebackend/statebackend.go: StateAtBlockNumber now calls StateRootByNumberIfStateRetained directly and passes the state root straight to state.NewStateHistory, avoiding the header decode.
  • pruner/retention_test.go: renamed TestHeaderByNumberIfStateRetainedTestStateRootByNumberIfStateRetained; the "fully retained" case now asserts on the state root instead of a header field. The pruned/missing-block error cases only needed the function name updated no logic changes.

Testing

Next

Following up with #3785 (StateAtBlockHash), same pattern.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.16%. Comparing base (210d7af) to head (5c771bf).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pruner/retention.go 71.42% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3808      +/-   ##
==========================================
- Coverage   75.30%   75.16%   -0.15%     
==========================================
  Files         438      438              
  Lines       39380    39318      -62     
==========================================
- Hits        29655    29552     -103     
- Misses       7641     7687      +46     
+ Partials     2084     2079       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@RafaelGranza RafaelGranza 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.

This is a good possible optimization, but we still need to see the benchmarks (similar to the ones here) so we actually know this is an optimization.

I know you are not implicitly claiming this to be a performance improvement, but this PR is of the perf type.
And I cannot approve performance without measurements.

I'm not sure if you are just starting your career and looking for "first PRs in open source", but if that is the case, and you are interested in performance, I recommend this very small guide. It is focused in C++, but the principles are good and any modern compiled or lower-level language should behave similarly.

Also, there is another possible performance issue you need to address.

Comment thread pruner/retention.go Outdated
Comment on lines +65 to +69
state, err := core.GetGlobalStateRootByBlockNumber(r, blockNumber)
if err != nil {
return nil, err
}
if _, err := core.GetBlockHeaderNumberByHash(r, header.Hash); err != nil {
hash, err := core.GetBlockHeaderHashByNumber(r, blockNumber)

@RafaelGranza RafaelGranza Jul 10, 2026

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.

This needs measurements and fixes

Since this is a perf PR, so I'm being more picky related to performance stuff.

Both GetBlockHeaderHashByNumber and GetGlobalStateRootByBlockNumber internally call:

err := r.Get(db.BlockHeaderByNumberKey(blockNum), func(data []byte) error {
		return encoder.Unmarshal(data, &header)
	})

so now we are querying the DB and marshaling twice.

One way to fix this is creating a new accessor.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I have now included this in the following commit.

@0xsamalt

Copy link
Copy Markdown
Author

Summary

Consolidates the header hash and global state root reads used by StateAtBlockNumber (and StateRootByNumberIfStateRetained) into a single accessor.

Previously this path called two separate reads — GetGlobalStateRootByBlockNumber and GetBlockHeaderHashByNumber — each doing its own r.Get and unmarshal.

This adds GetStateRootAndHashByBlockNumber, which does a single r.Get + unmarshal and returns both GlobalStateRoot and Hash, and uses it in StateAtBlockNumber / StateRootByNumberIfStateRetained.

Benchmarks

I ran local benchmarks (rpc/v10/state_read_bench_test.go) exercising the public RPC handler paths with Pebble DB, following the format from #3782 / #3789.

Runs were interleaved (base/new/base/new) with CPU pinning (taskset -c 0-3, GOMAXPROCS=4) and -count=20 to control for local hardware noise:

Path Base New Latency B/op allocs/op
Nonce/ByNumber ~12.32µs ~12.48µs ~ (p=0.292, not significant) 2.815Ki → 2.815Ki (+0%) 60 → 60 (+0%)
ClassHashAt/ByNumber ~13.03µs ~12.15µs −6.75% (p=0.000) 2.815Ki → 2.815Ki (+0%) 60 → 60 (+0%)
StorageAt/ByNumber ~20.54µs ~21.09µs ~ (p=0.531, not significant) 4.505Ki → 4.505Ki (+0%) 89 → 89 (+0%)

Only ClassHashAt/ByNumber shows a statistically significant improvement; Nonce/ByNumber and StorageAt/ByNumber which exercise the same consolidated accessor are flat. B/op and allocs/op show no measurable change on any path, which is inconsistent with removing a full extra DB read + unmarshal.

I also saw a significant delta on ClassHashAt/Latest (−6.22%, p=0.000), a path that routes through HeadState() rather than StateAtBlockNumber() and shouldn't be affected by this change at all. That, combined with the lack of any B/op or allocs/op movement, points to local hardware variance (thermal/turbo scaling) rather than a real effect from this PR.

I don't have a reliable perf number to report from local testing, despite several rounds of interleaved, CPU-pinned runs. Happy to have this re-benchmarked on other hardware or in CI if a hard number is needed before merge otherwise this PR should stand on correctness/code-clarity grounds (one DB read + unmarshal instead of two).

Comment thread pruner/retention.go Outdated
func HeaderByNumberIfStateRetained(r db.KeyValueReader, blockNumber uint64) (*core.Header, error) {
header, err := core.GetBlockHeaderByNumber(r, blockNumber)
func StateRootByNumberIfStateRetained(r db.KeyValueReader, blockNumber uint64) (*felt.Felt, error) {
state, hash, err := core.GetStateRootAndHashByBlockNumber(r, blockNumber)

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.

The return values are swapped. GetStateRootAndHashByBlockNumber returns (hash, stateRoot), but it's bound here as state, hash. So the retention check runs on the state root instead of the hash → ErrKeyNotFound for every block, and StateAtBlockNumber breaks. TestStateRootByNumberIfStateRetained fails on this branch because of it.

Fix: hash, state, err := core.GetStateRootAndHashByBlockNumber(r, blockNumber)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for pointing it out! Fixing it to hash, state, err := core.GetStateRootAndHashByBlockNumber(r, blockNumber) and re-running TestStateRootByNumberIfStateRetained locally before pushing.

@0xsamalt

Copy link
Copy Markdown
Author

Fixed the swapped binding bug you flagged, Ehsan confirmed TestStateRootByNumberIfStateRetained passes now.

@RafaelGranza , re: the benchmarks I ran them properly interleaved/CPU-pinned but couldn't get a clean signal beyond noise, wrote up the details in the PR. I only have the one machine to test on, so I can't rule out local noise myself. If there's a way to get this benchmarked somewhere more controlled, happy to do that. Otherwise I'd lean on the correctness/clarity argument (one read+unmarshal instead of two) rather than force a number that isn't really there.

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.

perf(state): avoid full header decode in StateAtBlockNumber

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.