← all articlesEXECUTION · STATE DB
// REPORT

How can two Nethermind databases holding the same state differ 17× — and then agree?

Ethereum · nethermind · flat DB · benchmarking · 2026 · reproducible pipeline & data →

1,461 tests matched by exact identity across both stores, zero gas mismatches. Nethermind nethermind:glamsterdam-devnet-7, --FlatDb.Enabled=true, rollback_strategy: container-recreate, drop_memory_caches: steps.

The behaviour

Two Nethermind databases. One is a mainnet shadowfork snapshot taken at block 24,402,727; the other is state generated from scratch by state-actor. The same EEST bloatnet fixtures run against both, on the same machine, with the page cache dropped between every test. Where the tests read accounts, the generated store reports up to 17× less throughput.

what the test doesnthroughput sa/jocbytes sa/jocjochemnet MBstate-actor MB
existing EOA1100.05914.832313907
existing contract4400.0898.812314619
absent account1101.0512.9478218
warm query770.876145.731229
storage slot881.0061.04633626
ether transfer1960.3851.8840157793
control (no state work)4400.73349.54296
The two rows that do not diverge are the tell. Absent accounts (1.051) never read an account record, and the storage sweep (1.006) is far larger than anything a store can hold in a corner. Whatever is happening, it is specific to reading accounts that exist.
EOAMINIMALSAME_MAXJUMPDESTDIFF_MAXabsentBALANCE0.060.060.070.060.061.00CALL0.160.170.150.400.181.03CALLCODE0.080.110.110.410.171.00DELEGATECALL0.050.060.070.380.091.17EXTCODECOPY0.060.060.070.380.091.07EXTCODEHASH0.050.060.060.060.061.02EXTCODESIZE0.050.060.060.390.091.05STATICCALL0.050.060.070.390.091.09throughput ratio per cell; brighter = further from parity
Every opcode against every access mode, before any treatment: 41 of 48 cells are outside ±10% of parity, ranging from 0.05 to 1.17. This is not a few bad tests — it is nearly the whole matrix. The single column that stays dim is the one whose lookups never read an account record at all.
0.0 GB1.4 GB2.9 GB4.3 GB5.8 GB100M140M180M220M260M300Mgas per teststate-actorjochemnet
Read volume against gas for the contract-reading tests. jochemnet stays flat — 227 MB at 100M gas and 232 MB at 300M, triple the work for the same bytes — while state-actor climbs from 1986 MB to 5772 MB. Flat in gas means the arm is re-reading one bounded set of blocks; rising means every access goes somewhere new.

What we found wrong with the measurement

Three things, in the order they matter. Two are properties of how the baseline was prepared; the third is the harness not having the controls this kind of study needs.

  1. The pre-run is promoted into the baseline. Only one arm replays a pre-run before measuring, and the harness is told to promote the result into the image every test restores from. That leaves the benchmark's own accounts as the newest versions in the youngest files of the LSM tree. Diagnosed below, fixed, and the fix accounts for almost all of the gap.
  2. The measured step inherits its own setup step's cache. The page cache is dropped between the two, but the client is not restarted, so its RocksDB block cache is not. Diagnosed below, not fixed — every number here still carries it.
  3. The harness has no compaction control. BENCHMARKOOR_COMPACT_BETWEEN_STEPS is named in a source comment, never implemented, and BENCHMARKOOR_POST_PRERUN_CMD is implemented on branch state-db-journal-drain only; absent from the binary these runs used. The treatment below had to be applied by hand.

The root cause: the benchmark's keys live in a corner of one store

Only one arm runs a pre-run. Before measuring, the jochemnet arm replays 10.06 GB of blocks — 7,736 of them, 64 transactions each, taking the chain from 24,402,728 to 24,410,463 — which create the accounts the benchmark then reads. The harness is then told promote_post_pre_runs: true, which freezes that post-pre-run layout into the golden image every test is restored from.

This is not a caching story. The harness drops the page cache between every test, and the pre-run happens once, before the loop. What survives the drop is not warm memory — it is which files hold the current copy of each key.

The structure that matters here is not the Merkle trie but the LSM tree underneath it: Nethermind keeps the flat state in RocksDB, whose account column family is keyed by keccak256(address)[0:20]. Those keys are hashes, so they are scattered uniformly — nothing about the benchmark's accounts is adjacent in key order. What is concentrated is the set of files holding their newest versions. The pre-run rewrote every one of those accounts, so their current copies landed together in a handful of recently flushed SSTs near the top of the tree, and a levelled store answers a point lookup from the newest level that has the key and stops. The working set is therefore bounded by the size of those few files, not by the number of keys.

Stated that way the mechanism makes a prediction: merging those files down into the bottom level should destroy the advantage entirely, because afterwards no level holds a privileged copy. That is a compaction, it is testable, and the rest of this page is the test.

0.00.51.01.52.04 KiB blocks read per lookupstate-actorjochemnet0 MB202 MB404 MBcumulative bytes pulled from disksaturates at 47 MB5002,0008,00020,00050,000cold lookups performed
Cold lookups against each store's own copy of the fixtures' accounts, fresh process per point, page cache dropped, 100% hits. At 500 lookups the two stores are indistinguishable — 1.88 against 2.00 blocks. By 50,000 jochemnet is at 0.23 because its volume has stopped growing: 45 MB at 20,000 lookups and 47 MB at 50,000. state-actor never saturates, climbing to 404 MB, because there is no corner to exhaust.

The per-lookup cost is not what differs — the number of distinct physical blocks is. Both stores pay about two blocks for a lookup they have not made before. One of them runs out of new blocks to read.

Defect 1: the pre-run is promoted into the baseline — and what compacting it does

The change, concretely. The treatment is a full RocksDB compaction of the affected column families: CompactRange over the whole key range with bottommost_level_compaction=kForce, which is required because a family that already sits entirely in its bottom level is a no-op for an ordinary compaction. The rewrite uses the other store's exact per-family options — filter policy, block size, restart interval, compression — verified knob by knob afterwards, so the only thing that changes is which file holds each key's newest copy. No value is touched and the state root is unchanged.

The pre-run is then removed from the arm's config. That is not a reduction of the workload: its writes are already in the promoted image, so the arm still starts from exactly the state the pre-run produced. Replaying it would simply write those accounts again and rebuild the very thing we just flattened.

The reproducible way to do this is the harness's post-pre-run hook (BENCHMARKOOR_POST_PRERUN_CMD), which runs an operator command after the pre-run and before the snapshot is promoted, aborting the promote if it fails. That hook lives on a branch and is not in the binary these runs used, so the compaction here was applied to the promoted image by hand. Same end state; the hook is the path anyone reproducing this should take.

column familylevels beforelevels afterGBrewrite
flat/AccountL3:4 L4:36 L5:304 L6:20L6:3717.38185 s
flat/StateNodesL0:3 L3:1 L4:4 L5:60 L6:487L6:15838.98708 s
codeL0:3 L5:6 L6:117L6:11562 s
The files at the top of each tree are the pre-run's. Merging them down is the whole treatment: no value changes, only which file holds the newest copy of a key.

Then the full suite again, all 1,461 matched tests:

what the test doesnwith confoundequalisedmiddle half, equalisedfull rangewithin ±10%
existing EOA1100.0590.9790.970–0.9910.883–1.144108/110
existing contract4400.0890.9680.916–0.9840.557–1.138343/440
absent account1101.0510.9040.764–1.0520.386–1.48234/110
warm query770.8760.8960.839–0.9710.552–1.31733/77
storage slot881.0061.0340.967–1.1230.549–2.09447/88
ether transfer1960.3851.0130.980–1.0490.681–1.581160/196
control (no state work)4400.7330.7080.622–0.8200.321–1.39449/440
Tests agreeing within ±10% go from 12.8% to 53.0%. The two spread columns are there because a median can flatter a category: existing EOA really is tight — middle half 0.970–0.991, 108/110 inside the band — whereas storage slot sits on parity at 1.034 while ranging 0.549–2.094 with only 47/88 inside it. Independently reproduced on a 266-test subset that agreed with the uncorrected arm to within a couple of percent, so these ratios are not run-to-run noise.
existing EOAwith confound 0.059equalised 0.9790.059 → 0.979existing contractwith confound 0.089equalised 0.9680.089 → 0.968absent accountwith confound 1.051equalised 0.9041.051 → 0.904warm querywith confound 0.876equalised 0.8960.876 → 0.896storage slotwith confound 1.006equalised 1.0341.006 → 1.034ether transferwith confound 0.385equalised 1.0130.385 → 1.013control (no state work)with confound 0.733equalised 0.7080.733 → 0.7080.050.10.250.512with the pre-run confoundafter compactionthroughput, state-actor / jochemnet (1.0 = parity)
Each row is one kind of test: amber is the confounded measurement, green the equalised one, and the band is ±10% around parity. The two rows that were already inside the band stay inside it — the control that makes the rest credible.
with the pre-run confoundexisting EOA1 test1 test1 test5 tests3 tests9 tests8 tests9 tests12 tests9 tests7 tests5 tests5 tests6 tests2 tests5 tests1 test1 test1 test1 test1 test1 test2 tests1 test2 tests1 test1 test2 tests1 test1 test1 test1 test1 test2 tests0/110existing contract3 tests9 tests13 tests15 tests27 tests19 tests25 tests35 tests25 tests21 tests15 tests20 tests16 tests8 tests13 tests5 tests7 tests4 tests7 tests7 tests3 tests2 tests4 tests5 tests3 tests7 tests5 tests4 tests3 tests3 tests6 tests3 tests35 tests39 tests10 tests6 tests4 tests2 tests1 test1 test0/440absent account1 test1 test5 tests3 tests7 tests9 tests16 tests19 tests17 tests14 tests8 tests3 tests1 test6 tests50/110warm query2 tests1 test4 tests5 tests9 tests7 tests11 tests10 tests11 tests9 tests3 tests3 tests2 tests27/77storage slot1 test1 test1 test3 tests1 test3 tests3 tests31 tests22 tests4 tests2 tests2 tests2 tests4 tests1 test6 tests1 test57/88ether transfer1 test5 tests1 test14 tests14 tests13 tests12 tests19 tests18 tests21 tests11 tests16 tests6 tests9 tests8 tests3 tests5 tests3 tests3 tests2 tests1 test3 tests4 tests3 tests1 test7/196control (no state work)2 tests3 tests1 test8 tests10 tests17 tests16 tests12 tests25 tests47 tests57 tests51 tests64 tests33 tests20 tests12 tests19 tests9 tests17 tests8 tests8 tests1 test46/4400.030.10.312placement equalisedexisting EOA5 tests94 tests10 tests1 test108/110existing contract2 tests20 tests38 tests5 tests8 tests4 tests10 tests67 tests247 tests34 tests5 tests343/440absent account1 test1 test2 tests1 test7 tests7 tests9 tests7 tests17 tests11 tests7 tests14 tests10 tests5 tests4 tests5 tests2 tests34/110warm query1 test3 tests4 tests5 tests3 tests14 tests18 tests13 tests10 tests4 tests1 test1 test33/77storage slot1 test3 tests1 test1 test3 tests2 tests5 tests20 tests19 tests14 tests4 tests1 test3 tests4 tests3 tests2 tests1 test1 test47/88ether transfer2 tests3 tests5 tests9 tests15 tests49 tests95 tests7 tests8 tests1 test1 test1 test160/196control (no state work)1 test5 tests4 tests11 tests9 tests12 tests17 tests10 tests17 tests24 tests50 tests72 tests52 tests45 tests23 tests9 tests19 tests18 tests20 tests14 tests6 tests2 tests49/4400.030.10.312throughput, state-actor / jochemnet (1.0 = parity)
The same 1,461 tests as individual results rather than medians: one dot per cluster of tests at that ratio, sized by how many, with the middle half drawn as a bar and the median as a tick. The count on the right is how many of that category's tests land inside ±10% of parity. Reading the two panels together is the point — the account clouds do not merely shift, they collapse from a smear across the left of the axis onto the parity band.

Defect 2: the measured step begins with a warm client — and that is not what the control gap is

The harness drops the OS page cache between the setup payload and the measured one. It cannot drop the client's own cache: Nethermind is not restarted inside a test — container-recreate rolls back per test, not per step — so the RocksDB block cache carries whatever setup pulled in straight into the measurement. That is a real gap in the method, and the two arms enter the measurement warmed by very different amounts:

testsarmsetup readsmeasured readstotal
control (no account work)jochemnet31.5 MB1.8 MB33.2 MB
control (no account work)state-actor9.4 MB96.1 MB105.5 MB
reads accountsjochemnet31.5 MB3,663.0 MB3,694.4 MB
reads accountsstate-actor9.4 MB4,234.0 MB4,243.4 MB
Read the control rows across the two steps: jochemnet pays 31.5 MB in setup and 1.8 MB when measured, state-actor 9.4 MB then 96.1 MB. The arms are inverted between the steps, and the setup figure is identical across categories on each arm, so it is a fixed payload whose only variable effect is how much of each store it happens to leave cached.
control: no account work31jochemnet setup2jochemnet measured9state-actor setup96state-actor measuredtests that read accounts313,66394,234megabytes read (log scale)green = jochemnet blue = state-actor
The same numbers on a log axis. The arms are inverted between the two steps: jochemnet does its reading during setup, state-actor during the measurement. The obvious reading is that one arm enters the measurement warm and the other does not, which is what the next experiment tests.

Before testing which cache, it is worth asking how much any cache could be worth. container-recreate restarts the client for every test, so the setup step starts with nothing warm at all — cold page cache and a client that booted seconds ago. Whatever the measured step then gets for free must have been put into the client's memory by setup, and there are only two ways in: bytes setup read, or bytes setup wrote. Both are recorded. Setup reads 31.5 MB on jochemnet and writes 0.0 MB — the control payload changes no state, so there is no write channel and nothing sitting in a memtable.

That caps the explanation at 30%. The gap to account for is 104.5 MB, and jochemnet's entire carry-over budget is 31.5 MB. Even if every byte setup pulled off disk were retained and served the measurement for free, two thirds of the difference would remain. The bound does not depend on which cache holds the bytes, so it covers the caches we did not test as well as the one we did.

So we starved the cache. Both arms re-ran the same 266 tests with the flat database's block cache cut from 1 GiB to 8 MiB, a factor of 128. Same stores, same tests, same treatment, and the client's own startup log confirms the reduction reached it. If the control gap were the setup step's leftovers, taking the leftovers away had to close it.

category1 GiB cache8 MiB cachechange
CONTROL0.6860.707+3.0%
existing EOA0.9830.978-0.5%
existing contract0.9670.970+0.3%
absent account0.9400.931-0.9%
Throughput ratio, state-actor over jochemnet, on identical test ids. We had pre-registered the prediction that control would move to 0.85–0.95. It moved +3.0%, to 0.707. Every account category moved by less than 0.9%.

The read volumes say the same thing from the other side. Starving the cache pushed state-actor's control reads up, from 96.4 MB to 106.3 MB, while jochemnet's stayed at 1.8 MB — unmoved to the decimal. A store being fed by a warm cache reads more when you take the cache away. jochemnet did not, because 1.8 MB is what its control payload actually needs.

So the explanation is dead and the defect is not. The harness still cannot flush the client's cache, and it should be able to — restarting the client between steps is affordable, since a container is already recreated per test in seconds. But the cache is worth a few per cent here, not the gap, and the control asymmetry has to be a property of the two stores rather than an artifact of how we measured them. What property, we do not know.

One lever remains untested: this reduced the flat database's block cache, while Nethermind sizes its trie and database budgets from total machine memory independently. That does not rescue the carry-over story — a cache cannot explain reads that do not happen — but it does mean 'cache' is eliminated as the cause, not as a contributor.

What is left, and how much of it we can account for

Open: a different contract per access still costs 1.55×

What we know

One cell does not come back to parity, and it is the same cell the geth study flagged and left unexplained. Laying every opcode against every access mode shows it is a column, not a scatter — whatever is left does not care which opcode reads the account:

EOAMINIMALSAME_MAXJUMPDESTDIFF_MAXabsentBALANCE0.980.970.980.970.970.82CALL0.990.990.990.960.771.04CALLCODE0.980.990.990.930.690.84DELEGATECALL0.980.980.980.920.640.94EXTCODECOPY0.970.980.980.920.640.88EXTCODEHASH0.980.980.980.970.970.88EXTCODESIZE0.970.970.970.920.630.83STATICCALL0.980.970.980.920.630.97throughput ratio per cell; brighter = further from parity
Throughput ratio for each of the 8 opcodes against each access mode, after treatment. Read it by column, not by row: DIFF_MAX sits at 0.67 and JUMPDEST at 0.93, while EOA, MINIMAL and SAME_MAX are all 0.98–0.98. The absent column is the uneven one at 0.88, which is the same dispersion its category showed above. Within the DIFF_MAX column the rows are not uniform, and the split is the whole story: the opcodes that execute the callee's code sit near 0.64 while BALANCE and EXTCODEHASH, which only read the account row, stay at parity.

Sorting the access modes by how much contract code they touch orders it cleanly:

access modewhat it touchesjochemnet MBstate-actor MBbytes sa/jocthroughput sa/joc
EXISTING_EOAno code at all370439071.060.979
EXISTING_CONTRACT_MINIMALminimal code364738731.060.980
EXISTING_CONTRACT_SAME_MAXone max-size contract, reused364738631.060.980
EXISTING_CONTRACT_JUMPDESTcode scanned for jump destinations482755981.180.931
EXISTING_CONTRACT_DIFF_MAXa different max-size contract per access403056391.550.655
Monotonic in code, and the cause is on disk: the code database is 45 GB on the generated store against 7.6 GB on the snapshot. Touch a different maximal contract every time and you pay for the larger database; reuse one contract, or touch no code at all, and the two stores are within 6%.
no code at all1.06×minimal code1.06×one max-size contract, reused1.06×code scanned for jump destinations1.18×a different max-size contract per access1.55×bytes read, state-actor / jochemnet1.01.11.21.31.4code database: 45 GB on state-actor vs 7.6 GB on jochemnet
The ordering is monotonic in how much distinct contract code the mode touches, and it is the incremental step that is lopsided: going from one reused contract to a different one per access costs state-actor 3863→5639 MB (+1775) against jochemnet's 3647→4030 MB (+383) — roughly 4.6× the marginal cost per additional distinct contract.

Where exactly it lives

Splitting the same cells two ways isolates it to a single square. Along one axis, whether the opcode loads the callee's code at all; along the other, whether the contract is a different one each access or the same one reused:

one contract, reuseda different contract each access
opcodes that load the code
CALL, CALLCODE, DELEGATECALL, STATICCALL, EXTCODECOPY, EXTCODESIZE
0.9810.639
opcodes that read only the account row
BALANCE, EXTCODEHASH
0.9770.971
One square out of four. Reusing a contract is fine even for the opcodes that execute it (0.972–0.991 across 6 opcodes), and under DIFF_MAX the two opcodes that never touch code are at parity (0.969, 0.972). Only loading a distinct contract's code is expensive, at 0.639 across 6 opcodes (0.626–0.775). So it is not the account row, and it is not distinctness on its own.

What we ruled out

The obvious answer is the code database: the generated store's is 45 GB against 7.6 GB. Measured three ways, it is wrong in every one of them.

measurementstate-actorjochemnetverdict
cold random code lookup10,513 B14,208 Bgenerated store is cheaper
cold sweep, 3,000 distinct maximum-size contracts4,186 B/read9,705 B/readgenerated store is cheaper
contracts at or above the 24,576-byte maximum442433same population
Per 400,000 accounts scanned. The generated store holds more contracts (31.3% of accounts against 19.1%) but far smaller ones (median 23 B against 45 B), and its maximum-size contracts compress to 4,186 bytes on disk against jochemnet's 9,705. Reading code is cheaper on the generated store at every granularity we can measure, which is the opposite of what the benchmark reports.

What is still open

So the effect is real, it is confined to one square of the table above, and the three explanations that fit that square are each contradicted by a direct measurement. We are leaving it here rather than reaching for a fourth story: the honest statement is that executing a distinct contract costs the generated store 1.56× what it costs the snapshot, and we cannot yet say why. The fixture accounts themselves are not the answer either — all 20,000 addresses in the range the tests use carry no code at all on either arm.

Three clients, one artifact

clientsame state costsgenerated vs snapshot, untreatedgenerated vs snapshot, treated
geth674 GiB2.01–5.81× on account reads, 0.91 on absent1.031–1.117×
Besu532 GiB6.5× on account reads (10.05× the bytes)0.908× (1.23× the bytes)
Nethermind409 GiB17× on account reads (8.81× the bytes)0.968–0.979× (1.08× the bytes)
Three engines, three storage designs, three state sizes for the same logical state — and the same artifact. The Besu and Nethermind columns are computed the same way from each study's own data (48 and 440 account cells respectively, gas matched exactly); treat the geth spread as its published range. Untreated, the generated store looks between 6× and 17× slower. Treated, all three land within a few percent of parity. That is what a methodology artifact looks like, as opposed to a property of any one database.

What to do about it

  1. Never compare a promoted-after-pre-run snapshot against a store that did not get one. It is worth an order of magnitude and it does not announce itself.
  2. Neutralise the pre-run's placement effect on the arm that has one — compact after the pre-run, before the snapshot is promoted. That is a single compaction per baseline, and the harness already has the right hook for it (BENCHMARKOOR_POST_PRERUN_CMD), though it is implemented on branch state-db-journal-drain only; absent from the binary these runs used. Note what not to do: compacting between every test's steps, the lever geth could reach over RPC, costs 185 s per column family here — about 3.1 days across 1,463 tests, six times the runtime of the suite it would be preparing.
  3. Give the harness control of the client's cache, not just the page cache. Dropping /proc/sys/vm/drop_caches between steps leaves the client's own RocksDB block cache warm, so a measured step partly reflects what its own setup payload happened to load. Restarting the client between steps is affordable; the harness already recreates a container per test in seconds. Measure before you assume, though: starving that cache here moved the result by 3%, which is worth having and is not an explanation.
  4. Print a locality diagnostic: read bytes per unit of gas, per arm. Flat in gas means a bounded working set and therefore an artifact — jochemnet read 227 MB at 100M and 232 MB at 300M while state-actor climbed from 1986 to 5772 MB. That check is a few lines, and it would have caught this on the first run rather than the thirtieth.

And the answer to the question in the title: the generated state was never 17× slower. With placement equalised, account reads agree within 2–3%, storage within 3%, ether transfers within 1%, and absent-account lookups resolve from a filter in 2.5 against 2.8 microseconds. Synthetic state is a sound substitute for benchmarking state access.

Two caveats stop that being a blanket endorsement. Executing a distinct contract still costs the generated store 1.56× what it costs the snapshot and we cannot say why, so anything code-execution heavy is not yet covered. And the control gap — tests doing no account work at all, where the generated store reads 106 MB against 1.8 MB — survived having its most likely cause tested and eliminated. It is a real difference between the two stores and we cannot yet name it.

What we got wrong on the way

The numbers in this page are computed from the collected run data at build time; the generator refuses to emit the page if the data stops supporting the sentences above.

← all articlessource & data →