← 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 →

We ran the same EEST bloatnet benchmarks against two Nethermind databases that hold the same state. The one generated from scratch came out up to 17× slower. It wasn't. Five things about how the two stores were built and measured made the gap. Fix them one at a time and the tests that run over a second land at a median of 1.01, 122 of 144 inside ±10%. Here's each one, what it did to the numbers, and what's left.

The problem: the same state, up to 17× apart

Two databases with the same accounts: a mainnet shadowfork snapshot at block 24,402,727 (we call it jochemnet), and the same state written from scratch by state-actor. Same fixtures, same machine, page cache dropped between tests. Every number here is state-actor's throughput divided by jochemnet's: 1.00 is parity, 0.10 means the generated store is ten times slower.

throughput, state-actor / jochemnet, one dot per test (band = ±10%)tests over a secondmedian bandaccount row, EOA0.05 0/4account row, minimal code0.06 0/4account row, reused contract0.07 0/4account row, jumpdest contract0.06 0/4account row, distinct contract0.06 0/4call into an EOA0.06 0/16call, minimal code0.07 0/16call, reused contract0.07 0/16call, jumpdest scan0.39 0/16call, distinct contract0.10 0/16ether transfer0.38 1/36storage slot1.00 7/8tests under a second: shown, not countedcontrol, no state work0.72 3/80absent account1.02 8/20warm query0.84 4/14same slot, reloaded0.99 3/4storage slot1.55 1/40.050.10.250.512untreated paircategory median
The untreated pair, the 266 tests at 160M and 240M gas. Over a second, 8 of 144 tests sit inside the band; the account reads sit at 0.05 to 0.07.
testsnmedianinside ±10%
over a second
account row, EOA40.0510/4
account row, minimal code40.0590/4
account row, reused contract40.0650/4
account row, jumpdest contract40.0600/4
account row, distinct contract40.0580/4
call into an EOA160.0570/16
call, minimal code160.0700/16
call, reused contract160.0660/16
call, jumpdest scan160.3870/16
call, distinct contract160.1040/16
ether transfer360.3811/36
storage slot80.9997/8
under a second
control, no state work800.7203/80
absent account201.0218/20
warm query140.8354/14
same slot, reloaded40.9923/4
storage slot41.5511/4
Median ratio per category, untreated pair. The full suite of 1,461 tests says the same thing, and gives the 17× in the title.

Each dot in the figure is one test. The white tick is the category's median, and the numbers on the right are that median and how many of the category's tests land inside ±10%. The same rows and the same axis come back at the end, after the fixes.

The tests that read accounts are 15 to 19 times slower on the generated store. The controls run the same loop and touch no state, and they sit at 0.72. That's the first clue. Whatever makes account reads that slow isn't the whole client being slow.

The second clue is storage. The storage tests that run over a second read far more data than any corner of a store could hold, and they sit at 1.00. Ether transfers, which touch accounts too, sit at 0.38. Whatever this is, it's about reading accounts that already exist.

Two kinds of test. A test that runs for ten seconds and one that runs for a tenth of a second aren't measuring the same thing. Every test boots a fresh client, and for its first fraction of a second the client is still warming up. So every view here is split at one second of measured time. Under a second, one store measured twice lands only 41% to 61% of its tests within ±10% of itself; over a second, 96% to 100% do. Sub-second tests are always shown and never counted as evidence.

tests inside ±10%, by how long the test runs0%25%50%75%100%< 0.2 ssame store twice: 36%the two stores: 33%33% of 108, floor 36%0.2 - 1 ssame store twice: 79%the two stores: 29%29% of 14, floor 79%1 - 5 ssame store twice: 100%the two stores: 85%85% of 13, floor 100%> 5 ssame store twice: 95%the two stores: 85%85% of 131, floor 95%state-actor, measured twicethe two stores, all fixes applied
Amber is state-actor measured twice, the best any comparison can do. Green is the two stores with every fix applied. Above a second the two nearly meet; below it, neither gets far.

Five things caused the gap:

  1. the benchmark's accounts were the newest files in the snapshot;
  2. every test boots a fresh client, and the two stores warm it up differently;
  3. two storage columns nobody had compacted;
  4. a contract's code block is shared with whatever the generator packed next to it;
  5. we had repacked the top of the snapshot's trie into blocks a quarter of the client's size.

1. The benchmark's accounts were the newest files in the snapshot

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 account lookups against each store's own copy of the fixtures' accounts. At 500 lookups the two cost the same, 1.88 against 2.00 blocks each. By 50,000, jochemnet's read volume has stopped growing: it has run out of new blocks to read.

Only jochemnet runs a pre-run: 7,736 blocks that create the accounts the benchmark then reads. The harness promotes the result into the image every test restores from. So each of those accounts has its newest copy in a handful of fresh files at the top of the LSM tree, and a lookup stops at the newest file holding the key. On jochemnet the benchmark reads a small, hot corner of the store; on state-actor it reads all of it.

It isn't caching, and it isn't a worse store. The page cache is dropped between tests, so the advantage is on disk. And on keys sampled at random from each store's own accounts, jochemnet is the more expensive one: 2.51 blocks per lookup against 1.99. What the figure shows is that the benchmark's keys are special. The cost per lookup is the same on both stores; what runs out on jochemnet is the supply of blocks it hasn't read yet.

The fix is one compaction of the promoted image, into the bottom level, with the client's own table options. No value changes and the state root doesn't move; only which file holds each key's newest copy. Then the pre-run comes out of the arm's config: its writes are already in the promoted image, and replaying it would rebuild the corner we just flattened. The snapshot's account column went from 364 files on 4 levels to 37 files on the bottom one, rewritten in 185 s. What it did, across the full suite of 1,461 tests: existing EOAs went from 0.059 to 0.979, existing contracts from 0.089 to 0.968, and the share of tests inside the band from 13% to 53%. That's almost all of the 17×.

2. Every test boots a fresh client, and the two stores warm it up differently

throughput, state-actor / jochemnet, before and after taking the JIT outcontrol, no state work (<1 s)control, no state work, JIT on: 0.726control, no state work, JIT off: 1.1430.73 → 1.14absent account, call (<1 s)absent account, call, JIT on: 0.922absent account, call, JIT off: 1.1070.92 → 1.11warm query (<1 s)warm query, JIT on: 0.896warm query, JIT off: 1.4680.90 → 1.47call, reused contractcall, reused contract, JIT on: 0.980call, reused contract, JIT off: 0.9960.98 → 1.00call, distinct contractcall, distinct contract, JIT on: 0.642call, distinct contract, JIT off: 0.9440.64 → 0.940.50.7511.251.5as publishedtiered compilation off, both arms
The same tests with .NET tiered compilation on, as published, and off on both arms. The sub-second rows swing hardest, which is the reason they aren't counted.

The harness restarts Nethermind for every test so tests can't leak into each other. That means every measured block runs in a process a few seconds old, and a young .NET process is still compiling itself: hot methods start on an unoptimised tier and get promoted in the background once they've been called enough.

Why the arms differ. The first block after boot is slow on both arms, whatever it holds. jochemnet's fixtures spend it on a block of real EVM work, so the interpreter is promoted before the measurement starts. state-actor's fixtures spend it on an empty block, a fork-activation block they need because that chain starts at genesis. So state-actor enters the measured block with an interpreter that's still being compiled underneath it. One arm measures warm code, the other measures the JIT catching up.

The compiler isn't a rounding error. Inside the measured steps of 40 control tests, the JIT thread burned 22.6 CPU-seconds on jochemnet and 21.1 on state-actor, more than the threads executing the blocks (8.9 and 10.9).

What it isn't. Turning off the page-cache drops took state-actor's control reads from 96 MB to 0.0 MB and made it no faster (30.9 to 27.8 MGas/s). Turning off parallel execution made the gap wider (0.56). Taking the I/O away didn't remove the gap. Taking the JIT away did.

The test: switch tiered compilation off on both arms, so both run optimised code from the first block. It's a diagnostic, not a production setting. What it did: the controls went from 0.73 to 1.14, and distinct-contract calls from 0.64 to 0.94. That's the tell: state-actor barely moved, and jochemnet got slower (9.7 to 13.6 s). With tiering on it had reached promoted code partway through the test.

The fix that belongs in the harness is a discarded burn-in block before the measured one. The harness doesn't have one yet, so every number after this section is measured with tiered compilation off on both arms. That over-corrects the short tests, which now land above parity, and it's one more reason they aren't counted.

3. Two storage columns nobody had compacted

files per level (L0 newest, L6 bottom)jochemnet storage rows2115916148489 GB after compaction10888 GBjochemnet storage trie21482761634195 GB after compaction801188 GBstate-actor storage rows331157 GBstate-actor storage trie1136279 GBL0L1L2L3L4L5L6as foundsettled: one level
Where the files sit. Amber is the snapshot as we found it, green is the same column after one compaction, and the generated store as it was written.

The snapshot's storage rows sat in 807 files over 6 LSM levels, and its storage trie in 1,961 over 5. The generated store keeps each in one. A lookup for a slot that doesn't exist has to be turned away by every level, so on one test the snapshot read 3,770 blocks where the generated store read 900. This one cut the other way: the generated store was the fast one. It hid in the first comparison because the storage tests over a second sat at parity, and the absent-slot test runs under one. Finding 1 compacted the columns the slow tests read, and storage wasn't one of them.

throughput ratio, state-actor / jochemnet (band = ±10%)absent slot, no write160M, before: 2.027160M, rows settled: 1.151160M, + trie settled: 0.938240M, before: 2.137240M, rows settled: 1.217240M, + trie settled: 1.1152.03 → 0.94new value, existing slot160M, before: 1.117160M, rows settled: 1.110160M, + trie settled: 0.998240M, before: 1.097240M, rows settled: 1.107240M, + trie settled: 0.8651.12 → 1.00new value, absent slot160M, before: 1.088160M, rows settled: 1.048160M, + trie settled: 1.002240M, before: 1.485240M, rows settled: 1.099240M, + trie settled: 1.0351.09 → 1.00overwrite, existing slot160M, before: 0.995160M, rows settled: 0.984160M, + trie settled: 0.999240M, before: 0.993240M, rows settled: 1.007240M, + trie settled: 0.9920.99 → 1.000.911.251.522.5beforeeach stepsettled (two rows: 160M, 240M)
Each storage test through both compactions, 160M and 240M. The overwrite test touches no trie node and never left the band: each step moved what it was aimed at.

The fix is the same compaction as in finding 1, one column at a time. What it did: the absent-slot test went from 2.03× to 0.94, and new-value writes from 1.12 to 1.00. Both absent-slot tests run under a second, so their timings are shown, not counted. The reads are what closed: storage-row reads on the absent-slot test are 848 against 896, and storage-trie reads on the new-value test 61,457 against 61,739.

The lesson isn't about storage. A generated store is written once and compacted once. A real one is a stack of levels, and on operations that ask for keys that aren't there that difference was worth up to 2.1×. Compacting the snapshot makes the two stores comparable. It doesn't make the generated one more like mainnet.

4. A contract's code block is shared with whatever the generator packed next to it

bytes read per code fetch, one test traced on both storesjochemnet (mainnet code)1,603 B, 1.35 pages, 1,978 µsstate-actor, old pool2,165 B, 1.50 pages, 2,127 µs4 KB pagesame number of fetches (49,790 and 49,720); the account row reads are identical
One distinct-contract test, traced on both stores. Same fetches, same account reads; each code fetch is bigger on the generated store, and more of them cross a page.

After the first three fixes, one kind of test was still slow: calls into a different maximum-size contract every time. Both stores key code by its hash, so a contract's neighbours in a data block are random. What differs is who they are. On the snapshot, 19% of accounts have code, median 45 bytes and a wide spread. On the generated store, 31% do, almost all of it a distinct 23-byte stub that doesn't compress. Every code fetch is one block, and each one is 562 bytes bigger on the generated store, so more of them cross a 4 KB page and pay a second physical read.

Why the obvious answer is wrong. The generated store's code database is 45 GB against 7.6 GB, so the natural guess is that code lookups cost more there. Measured alone, they cost less. One random cold lookup reads 10,513 bytes in 196 µs against 14,208 bytes in 346 µs, and a sweep of 3,000 distinct maximum-size contracts reads 4,186 bytes per contract against 9,705. The two stores even hold about the same number of maximum-size contracts, 442 and 433 per 400,000 accounts. The cost isn't in the lookup. It's in what the lookup drags in with it.

Each code read takes 149 µs longer on average, and 28% of them land in the 1–2 ms bucket against 14%. The traced test runs about a second slower: 14.10–14.49 s against 13.24–13.48 s, five runs each.

throughput ratio, state-actor / jochemnet (band = ±10%)call, distinct contractold pool: 0.938one contract per block: 1.008#141, pair A: 1.092#141, pair B: 1.0970.94 → 1.09 / 1.10call, jumpdest scanold pool: 0.943one contract per block: 1.015#141, pair A: 1.099#141, pair B: 1.0950.94 → 1.10 / 1.09call, reused contractold pool: 1.000one contract per block: 0.996#141, pair A: 0.997#141, pair B: 1.0011.00 → 1.00 / 1.000.850.911.1old poolone contract per block#141, pair A#141, pair B
The two code cells through the diagnostic repack and the generator fix. The reused contract never paid for its neighbours and doesn't move.

The test: rewrite the generated store's code with one contract per block. Every key and value stays the same, and the traced test then ran in 13.18, 13.12, 13.26 s, on jochemnet's side of the gap. Both cells closed (0.938 to 1.008, 0.943 to 1.015) and the reused-contract control stayed put. The fix belongs in the generator's code pool, not in a block size: state-actor#141 slices it from real mainnet bytecode. On a store regenerated with it the two cells read 1.09 and 1.10. That's the gap closed and a little past it.

Besu sees the same split on the store its own study generated: calls into a distinct contract at 0.83 against 0.94 for a reused one. Different client, different block size, same neighbours.

5. We had repacked the top of the snapshot's trie into 4 KB blocks

a level-4 node and its 16 children, in key order4 KB blocks, as we left it3 blocks; 1.07 top-node reads per touched account16 KB blocks, the client's1 block; 0.73 top-node reads per touched accountfirst square: the parent; one gap marks a block boundary
Nodes are keyed by path, so a parent and its children are neighbours. The walk to a touched account reads the parent and one child: one block when they share it, two when they don't.

A transfer writes two accounts, so every transfer re-hashes both paths up to the state root. Nethermind keeps the top of that trie, every node on a path of up to five nibbles, in one column, where a node and its sixteen children sit side by side. The client packs them into 16 KB blocks, 29 nodes each, so a family usually shares one read. The snapshot's column held 7 nodes per 4 KB block: 1.07 top-node reads per touched account against 0.73.

The tries are the same shape. Both tops are the complete sixteen-way tree five nibbles deep, 1,118,481 nodes. The generated trie holds 20% more accounts and is marginally deeper, 8.90 nodes on a cold root-to-leaf walk against 8.82. Shape can't make it read fewer nodes. The packing can.

That layout wasn't the snapshot's. We wrote it. RocksDB numbers files in order, and the 4 KB files (045338–045524) came just before our rebuild of the account column (045615+). The tool that did it opened the store with the client's options for that one column and RocksDB's defaults for the rest, and a pending compaction rewrote the top-of-trie column under those defaults. The test: give each store the other's packing. Across 94 transfer test pairs the cell went from 1.074 to 1.014, the read counts swapped, and each arm moved about half the gap (jochemnet ×1.034, state-actor ×0.975). The snapshot now carries the client's packing.

Also found and fixed, and moving nothing: the generator's closing compaction left files the client rewrote on every boot, 1,962 MB in 60 idle seconds (state-actor#139). The dead ends and our own measurement mistakes are in the README.

Where it stands

throughput, state-actor / jochemnet, one dot per test (band = ±10%)tests over a secondmedian bandaccount row, EOA1.00 4/4account row, minimal code1.00 4/4account row, reused contract0.98 4/4account row, jumpdest contract1.00 4/4account row, distinct contract0.98 4/4call into an EOA1.00 16/16call, minimal code1.00 16/16call, reused contract1.00 16/16call, jumpdest scan1.10 8/16call, distinct contract1.09 9/16ether transfer1.05 29/36storage slot1.00 8/8tests under a second: shown, not countedcontrol, no state work1.11 28/80absent account1.15 7/20warm query1.27 1/14same slot, reloaded1.26 1/4storage slot0.99 3/40.050.10.250.512pair Apair Bcategory median
The same tests after all five fixes, measured as two independent pairs. Compare it with the first figure: same rows, same axis.
testsnbeforepair Apair Binside ±10%same store twice
over a second
account row, EOA40.0510.9970.999 4/44/4
account row, minimal code40.0590.9990.996 4/44/4
account row, reused contract40.0650.9751.004 4/43/4
account row, jumpdest contract40.0600.9950.980 4/44/4
account row, distinct contract40.0580.9840.991 4/44/4
call into an EOA160.0571.0020.998 16/1616/16
call, minimal code160.0701.0010.996 16/1616/16
call, reused contract160.0660.9971.001 16/1616/16
call, jumpdest scan160.3871.0991.095 8/1616/16
call, distinct contract160.1041.0921.097 9/1616/16
ether transfer360.3811.0541.060 29/3631/36
storage slot80.9991.0010.997 8/88/8
under a second
control, no state work800.7201.1111.121 28/8026/80
absent account201.0211.1521.130 7/2016/20
warm query140.8351.2701.180 1/144/14
same slot, reloaded40.9921.2601.406 1/42/4
storage slot41.5510.9851.065 3/42/4
Median ratio per category. Pair A and pair B are separate runs of both stores; the last column is state-actor measured twice, the best any row can do.

Over a second, 122 of 144 tests now sit inside the band (median 1.012, and 1.012 in the second pair), against 138 of 144 when state-actor is measured twice. The account reads that started at 0.05 are at 0.975 to 0.999. Calls into an EOA, a minimal contract or a reused one sit at 0.997 to 1.002, and storage at 1.00. Three rows are still further from parity than the floor explains: ether transfers at 1.05, and the distinct-contract and jumpdest calls at 1.09 and 1.10. They're the first two items below.

Pair B repeats the whole comparison with fresh runs of both stores, so neither arm's number is shared between the two pairs. Over a second it lands at 1.012 against pair A's 1.012. That's how we know the table isn't one lucky run of either store.

Under a second, 40 of 122 tests land in the band, and the same store measured twice manages only 50 (75 for jochemnet). The controls read 1.11 and the warm queries 1.27, above parity because tiering is off. Those rows measure the harness, not the stores.

What's left, and why

The generated state was never 17× slower. Three things are left, and each has a name:

None of the three is a reason to distrust the generated store on tests over a second. The first is a generator fix, the second is a question for the client, and the third is a harness fix.

Every number on this page is computed from the collected run data when the page is built, and the build fails if the data stops supporting a sentence.

← all articlessource & data →