Skip to content

Cut per-read allocations on the flatkv EVM state view hot path - #4163

Open
masih wants to merge 1 commit into
mainfrom
masih/1789403573-flatkv-read-allocs
Open

masih wants to merge 1 commit into
mainfrom
masih/1789403573-flatkv-read-allocs

Conversation

@masih

@masih masih commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Every typed read on the flatkv StateView (GetStorage, GetBalance, GetNonce, GetCodeHash, AccountExists) made four heap allocations even on a cache hit: two to build the physical key (StorageKey then EVMPhysicalKey), one for the string(key) conversion passed into shard.lookupVersionedRLocked (the compiler only elides that conversion when it is used directly as a map index), and one for the *AccountData/*StorageData wrapper returned by the deserializer; compact account rows paid a fifth for the 81-byte expansion copy. GetCode additionally copied the whole bytecode out of the row, so an 8 KiB contract cost ~8.3 KB and ~2.4 µs per read.

The shard lookup now takes the key as []byte and indexes the map with string(key) inline. The view builds the physical key with ktype.AppendEVMPhysicalKey into a fixed-size stack buffer, and reads rows through the new vtype.AccountRow/vtype.StorageRow value types, which alias the row bytes (accepting both compact and full account rows) and return fields by value instead of allocating a mutable wrapper. DeserializeCodeData no longer copies the bytecode; the returned slice aliases the row, which is consistent with the existing contract that values returned by the store and its iterators may point at store memory. On the cache-hit benchmarks added here, GetStorage goes from 210 ns / 216 B / 4 allocs to 88 ns / 64 B / 1 alloc, GetBalance from 169 ns / 176 B / 4 allocs to 80 ns / 64 B / 1 alloc, and GetCode (8 KiB) from 2393 ns / 8299 B / 4 allocs to 102 ns / 104 B / 3 allocs. The remaining allocation on the fixed-width paths is the key buffer escaping through the view.View interface call.

scripts/ramtest.sh ./sei-db/state_db/sc/flatkv/... ./sei-db/db_engine/view/... ./sei-db/state_db/ss/composite/... passes unchanged; golangci-lint run and golangci-lint fmt --diff are clean.

benchstat, -count 10, origin/main vs this branch:

                      │ main             │      PR #4163               │
                      │           sec/op           │    sec/op     vs base                │
StateViewGetStorage-8                 169.40n ± 2%   88.28n ± 11%  -47.89% (p=0.000 n=10)
StateViewGetBalance-8                 159.65n ± 2%   80.96n ±  4%  -49.29% (p=0.000 n=10)
StateViewGetCode-8                    2295.5n ± 3%   102.3n ±  2%  -95.54% (p=0.000 n=10)
geomean                                396.0n        90.09n        -77.25%

                      │ main             │     PR #4163              │
                      │            B/op            │    B/op     vs base                │
StateViewGetStorage-8                  216.00 ± 0%   64.00 ± 0%  -70.37% (p=0.000 n=10)
StateViewGetBalance-8                  176.00 ± 0%   64.00 ± 0%  -63.64% (p=0.000 n=10)
StateViewGetCode-8                     8299.0 ± 0%   104.0 ± 0%  -98.75% (p=0.000 n=10)
geomean                                 680.8        75.24       -88.95%

                      │ main             │     PR #4163              │
                      │         allocs/op          │ allocs/op   vs base                │
StateViewGetStorage-8                   4.000 ± 0%   1.000 ± 0%  -75.00% (p=0.000 n=10)
StateViewGetBalance-8                   4.000 ± 0%   1.000 ± 0%  -75.00% (p=0.000 n=10)
StateViewGetCode-8                      4.000 ± 0%   3.000 ± 0%  -25.00% (p=0.000 n=10)
geomean                                 4.000        1.442       -63.94%

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 14, 2026, 4:43 PM

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.26316% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.85%. Comparing base (09fc34b) to head (e067bec).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/state_db/sc/flatkv/vtype/rows.go 68.96% 9 Missing ⚠️
sei-db/state_db/sc/flatkv/state_view.go 87.87% 4 Missing ⚠️
sei-db/state_db/sc/flatkv/ktype/ktype.go 75.00% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4163      +/-   ##
==========================================
- Coverage   66.59%   65.85%   -0.74%     
==========================================
  Files        2196     2111      -85     
  Lines      169208   160704    -8504     
==========================================
- Hits       112676   105828    -6848     
+ Misses      56391    54735    -1656     
  Partials      141      141              
Flag Coverage Δ
sei-chain-pr 91.15% <100.00%> (?)
sei-db 74.50% <ø> (-0.25%) ⬇️
sei-db-state-db ?
sei-db-state-db-pr 84.72% <78.87%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/db_engine/view/shard.go 88.75% <100.00%> (ø)
sei-db/state_db/sc/flatkv/vtype/code_data.go 100.00% <100.00%> (ø)
sei-db/state_db/sc/flatkv/ktype/ktype.go 89.79% <75.00%> (-2.89%) ⬇️
sei-db/state_db/sc/flatkv/state_view.go 79.43% <87.87%> (-1.03%) ⬇️
sei-db/state_db/sc/flatkv/vtype/rows.go 68.96% <68.96%> (ø)

... and 88 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@masih
masih marked this pull request as ready for review September 14, 2026 17:06
@cursor

cursor Bot commented Sep 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes zero-copy aliasing and read-path parsing for live EVM state; semantics should match prior behavior but callers must respect view-lifetime for returned byte slices.

Overview
This PR reduces heap allocations on cache-hit reads through the flatkv StateView EVM APIs (GetStorage, GetBalance, GetNonce, GetCodeHash, AccountExists, and GetCode).

Shard lookups now accept []byte keys and perform the string(key) map index inside lookupVersionedRLocked, so callers no longer pay an extra string allocation when they already hold the key as bytes.

Physical keys are built with new ktype.AppendEVMPhysicalKey into a fixed stack buffer instead of allocating via EVMPhysicalKey / StorageKey. Account and storage hot paths parse rows with zero-copy AccountRow / StorageRow views (including compact account rows) rather than heap-allocated *AccountData / *StorageData. DeserializeCodeData returns bytecode that aliases the stored row; GetCode documents that the slice is valid until the view closes.

Benchmarks in state_view_bench_test.go cover cache-hit GetStorage, GetBalance, and GetCode. The generic Get path still uses the older deserializers.

Reviewed by Cursor Bugbot for commit e067bec. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A well-scoped allocation reduction on the flatkv state-view read path: the new AccountRow/StorageRow value types reproduce the existing row semantics exactly (compact/full account rows, tombstones, EmptyCodeHash substitution), the stack key buffer is sized exactly for the longest physical key, and the DeserializeCodeData aliasing is safe because Pebble reads and view iterators both clone and the write path never mutates a stored row in place. Findings are all non-blocking: duplicated row-parsing logic now living in two places, a couple of readability/reuse cleanups, and gaps in direct test coverage of the new exported functions.

Findings: 0 blocking | 7 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The new exported surface has no direct unit tests: ParseAccountRow/ParseStorageRow error branches (empty, bad version byte, wrong length — including the 49-vs-81 account case) and AppendEVMPhysicalKey are only exercised indirectly through the state-view accessors. An equivalence test asserting AppendEVMPhysicalKey(nil, kind, k) equals EVMPhysicalKey(kind, k) for every kind would also be the only coverage of its codehash/balance canonicalization branch, which no caller in this PR reaches (GetStorage passes EVMKeyStorage, accountRow passes EVMKeyAccount).
  • [suggestion] benchView hand-builds the balance proto.KVPair with keys.BuildEVMKey(keys.EVMKeyBalance, ...) even though balancePair already exists in testutil_test.go alongside the noncePair/storagePair/codePair helpers it does use; reusing it keeps the balance key encoding in one place.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

}

// ParseAccountRow validates data as an account row and returns a view over it.
func ParseAccountRow(data []byte) (AccountRow, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] ParseAccountRow/ParseStorageRow re-implement the validation that DeserializeAccountData/DeserializeStorageData already perform (non-empty, version byte, accepted lengths), and AccountRow.IsDelete/StorageRow.IsDelete restate the tombstone rule a second time. The row format now has two readers per type, and state_view.go keeps accountData/storageData alive solely for the Get path, so both definitions are live. Consider making the old types delegate (AccountData wrapping an AccountRow, or IsDelete forwarding), or moving Get onto the row types, so the tombstone rule and the length/version contract each have one definition.


// AppendEVMPhysicalKey appends the physical key EVMPhysicalKey would build for kind and strippedKey
// to dst and returns the extended slice.
func AppendEVMPhysicalKey(dst []byte, kind keys.EVMKeyKind, strippedKey []byte) []byte {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] AppendEVMPhysicalKey duplicates EVMPhysicalKey's codehash/balance canonicalization, prefix-byte lookup, and layout. EVMPhysicalKey can become a one-liner over it — return AppendEVMPhysicalKey(make([]byte, 0, len(keys.EVMStoreKey)+2+len(strippedKey)), kind, strippedKey) — which keeps its single allocation and its nil-on-unknown-kind return, and leaves one place where the physical key layout is defined. Right now a change to the layout has to be made twice, and the two can diverge silently since nothing asserts they agree.

storage := v.storageData(ktype.StorageKey(ktype.Address(addr), ktype.Slot(key)))
if storage == nil {
var buf [physKeyBufLen]byte
physKey := ktype.AppendEVMPhysicalKey(buf[:0], keys.EVMKeyStorage, addr[:])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] GetStorage inlines key building, row parsing, the panic message, and the tombstone check, while the account read next to it is the named accountRow step. A matching storageRow(addr, key) (vtype.StorageRow, bool) would restore the symmetry and keep GetStorage a single named step, which is the shape the rest of this file uses.


bytecode := make([]byte, len(data)-codeBytecodeStart)
copy(bytecode, data[codeBytecodeStart:])
bytecode := data[codeBytecodeStart:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The returned bytecode now aliases data, so a CodeData is only valid as long as its source bytes are — but neither DeserializeCodeData's nor GetBytecode's doc says so, and DeserializeMiscData still copies, so the package contract is now per-type and only discoverable by reading the bodies. The type comment covers mutation ("not safe to modify without first copying") but not lifetime. Worth stating the aliasing on DeserializeCodeData, since the non-flatkv callers (composite.convertFlatKVNodes, seidb evm_logical_digest) hand the slice on to code that has no view of where it came from.

// 8 KiB of code, so each benchmark measures a cache-hit read.
func benchView(b *testing.B) (gigatypes.StateView, gigatypes.Address) {
b.Helper()
cfg := config.DefaultTestConfig(&testing.T{})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] config.DefaultTestConfig(&testing.T{}) calls TempDir() on a zero-value testing.T: it happens to work today, but the directory it creates is registered with that fake T's cleanup list, which never runs, so each benchmark invocation leaks an empty directory under the system temp dir (the real data dir is then overwritten on the next line). Widening DefaultTestConfig to testing.TB and passing b removes both the leak and the dependency on zero-value testing.T internals.

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.

1 participant