Skip to content

perf(erigon): 4.4-4.9× faster state generation — Direct-Drive Fold + M1 pipeline stack#110

Merged
CPerezz merged 22 commits into
ethereum:mainfrom
CPerezz:perf/erigon-direct-drive
Jul 7, 2026
Merged

perf(erigon): 4.4-4.9× faster state generation — Direct-Drive Fold + M1 pipeline stack#110
CPerezz merged 22 commits into
ethereum:mainfrom
CPerezz:perf/erigon-direct-drive

Conversation

@CPerezz

@CPerezz CPerezz commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

Cuts erigon 100 GB state generation (613 M accounts) from 6-7 h to 1 h 07 m — with zero configuration (no env vars) — and the state root byte-identical (0x31e0bfb1…, cross-client MATCH vs reth, stock daemon boots the output).

Phase Before After
Generation 1 h 38 m ~47 min
Commitment + snapshots 5 h 35 m ~20 min
Total 6-7 h+ 1 h 07 m

Peak RAM at 100 GB: RSS 50.6 GB, of which the committed footprint is small — 3.5 GiB Go heap (under an in-binary 8 GiB ceiling) + ~2-3 GiB off-heap pebble; the rest is kernel-reclaimable mmap page cache from the .kv passes that shrinks automatically on constrained hosts. No GOMEMLIMIT or tuning env needed: RAM is bounded by code defaults.

Why the old fold was slow

The writer drove erigon's incremental commitment engine for a from-empty bulk build. The engine's never-spilled Updates dedup map (~50-67 GB at 613 M keys) forced chunking; chunking re-descends the trie spine every chunk, injecting ~1.5 B random branch prev-reads over a 44 GB store — that was essentially the whole 5 h 35 m fold, against a pure-compute cost of minutes.

The fix — Direct-Drive Fold

  • Vendor the HPH engine @ our pin into internal/erigon/hph/ (same model as the existing seg/btindex/recsplit ports; the bench erigon daemon stays stock upstream). The vendored copy is trimmed to exactly the live surface (~3.7 k lines): Golden A pins it against ground truth — vendored DirectFold == the upstream erigon-module engine, root + every branch row + HPHState byte-identical, side-by-side in one binary.
  • hph.DirectFold: replicates the upstream concurrent engine's choreography, but each worker streams its nibble's already-sorted, already-unique sub-store cursor and calls followAndUpdate(hashedKey, plainKey, &update) with the Update inline — no Updates object, no Touch dedup map, no etl re-sort, no chunking, no prev-reads, no per-key ctx.Account/Storage Gets.
  • Branch rows land in per-worker write-once sinks (counted as emitted → exact key count, no 44 GB count scan) and are k-way-merged straight into the streaming commitment.kv writer.
  • Env-gated (STATE_ACTOR_COMMITMENT_DIRECT, default on). A single-shot fallback on the upstream-module engine remains (DIRECT=0); the old chunked path is removed — DDF made it obsolete as the low-RAM option.

Golden B pins the byte contract: engine/plain == engine/hashed == DIRECT on root + branch count + every row byte + HPHState across adversarial fixtures (all-nibble span with storage, code-bearing accounts, multi-level storage, one-nibble cluster, empty trie).

The tail — snapshot writing

  • The .idt intermediate is gone: seg.CompressFromSource drives the (repeatable) entry source twice — count pass, then encode straight into the .kv — byte-identical by construction and proven by the seg golden running BOTH writer paths against the same upstream bytes. Saves ~90 GB of writes and a 44 GB transient disk peak per 100 GB run.
  • Parallel recsplit .kvi Build (min(NumCPU,8) workers): producer → N workers → in-dispatch-order writer, byte-identical at any worker count (spike golden runs Workers ∈ {0,4}). The 100 GB MPHF build: ~1 min.
  • Verbose output prints per-phase tail timings and the heap/off-heap memory split.

RAM by default (no env vars required)

Pebble memtable arenas and block caches are C-malloc'd under cgo — off the Go heap, invisible to GOMEMLIMIT — so the bound is shipped as code defaults: small write-once memtables (64-128 MiB), an 8 MiB/store commit-input cache under the (sequential-read) default path, and debug.SetMemoryLimit(8 GiB) when GOMEMLIMIT is unset. Env knobs remain as opt-in overrides only.

Supporting commits

  • Channel batching for the gen pipeline (128 rows/send): generation 104 k/s → 164 k/s.
  • WriteCommitment streams the fold's branch source directly (deletes a full 44 GB pebble re-sort), splices the state anchor at its sort position, asserts ascending order, and fails loudly on producer errors/collisions instead of writing silently-truncated files.
  • RNG-goroutine dead-keccak skip for erigon (draw-sequence-neutral, pinned by an RNG-invariance test; other clients unchanged).
  • Five-agent review round (error-path hardening, oracle gaps closed) + a −4 k LOC diet: the PR is +6.8 k lines total, with the vendored package halved and every strip recorded in provenance headers.

Validation

  • Golden A (vendored DirectFold == upstream engine) + Golden B (three-way) byte-equality oracles, full commitment/snap/seg/recsplit/streamsort/autofill suites, TestH4 (erigon == geth MPT root).
  • 5 GB: root 0x7acbe35d… unchanged, 4:08 wall.
  • run-bloatnet: CROSS-CLIENT INVARIANCE PASS (erigon == reth 0x7fa5f442…), stock daemon boots.
  • 100 GB gate on the final commit, end-user posture (zero env vars): root byte-identical, 1:07:09, RSS 50.6 GB (go_heap 3.5 GiB), exit 0.

Notes

https://claude.ai/code/session_01RmVw3kWVHyhTKbVfwWUMs5

CPerezz added 22 commits July 1, 2026 22:18
… at scale)

Generating a large trie (e.g. a 100 GB account-heavy alloc, ~613 M EOAs)
OOM-killed the Erigon snapshot writer. The slot stream already stayed
bounded; the OOM lived in the unlogged post-stream phases, which held
O(total-keys) in RAM. Two distinct causes, the second only visible at scale:

1. Commitment held every branch/key in RAM: an in-memory mergedBranches map
   (Θ(total-keys)) plus upstream's Updates.keys dedup map (~30-65 GB at
   100 GB) plus an etl working set on /tmp (tmpfs = RAM).
2. The snapshot writer os.ReadFile'd each .kv into anonymous heap; Phase 5b
   builds domains concurrently, so the 28 GB accounts + 44 GB commitment .kv
   loaded at once (~72 GB) → a second OOM at ~120 GiB anon-rss.

Fixes:
- commitment: replace mergedBranches with a live read-write Pebble branchStore
  (branchstore.go); chunked first-serial-then-concurrent Process bounds
  Updates.keys per chunk; etl spills to cfg.DBPath (real disk) not tmpfs;
  ComputeGenesisRoot streams branches into a write-once .kv.
- seg.Decompressor: mmap the .kv read-only (munmap on Close) instead of
  os.ReadFile, so the bytes are reclaimable file-backed pages (anon O(1)).
- Phase-5b index builders: btindex offsets → bufio spill; recsplit bucket
  collector → streamsort (seq-suffix preserves collision detection);
  WriteDomain defer-Close leak fix.
- add --autofill-profile=accounts (streamed pure EOAs) so an account-dominated
  large trie is reachable without materializing a sequential_eoas spec.
- env tunables: STATE_ACTOR_{COMMITMENT_CHUNK_KEYS,ERIGON_WORKERS,
  COMMITMENT_CACHE_GB,BRANCH_CACHE_GB}.

Consolidates the 10-commit fix branch. Validated: cgo commitment tests (H4
erigon HPH root == geth MPT root; ChunkedVsSingle root==single) + a 100 GB
account-heavy bench (exit 0, 65.9 GiB peak, root 0x31e0bfb1…).
… dedupe, madvise

Root-preserving perf follow-up (no RNG/content change; cross-client root pinned
by TestH4 + ChunkedVsSingle). None raises RAM; several lower it.

- commitment: cap the SERIAL first chunk at min(firstChunkKeys=128K,
  commitmentChunkKeys). Chunk 0 runs single-core to seed the root branch; at the
  full chunk width it was ~12% of the whole fold (serial is ~16x/key). 128K keys
  already span all 16 first nibbles. Root unchanged (chunk boundaries don't
  affect the fold) — new ChunkedVsSingle assertion with firstChunkKeys=2 < chunk=5.
- snapshot_cgo: Close commitmentInputStore immediately after ComputeGenesisRoot
  (last use) instead of at function scope — frees its block cache + memtable +
  ~tens-of-GB spill dir BEFORE the mmap-heavy Phase-5b write. Idempotent vs the
  deferred Close.
- generation: hash account code ONCE in encodeEntity and thread it into the
  commitment Update via new EncodeAccountUpdateCodeHash — was keccak'd twice
  (snapshot CodeHash + EncodeAccountUpdate) for every code-bearing entity.
- seg.Decompressor: MADV_SEQUENTIAL after mmap — aggressive read-ahead for the
  front-to-back Pass-2 scan and lets the kernel drop read pages sooner, lowering
  file-RSS on a 44 GiB .kv. Advisory; byte output unchanged.
The commitment domain is the LARGEST (~44 GB .kv at 100 GB, plus the only
recsplit MPHF over ~nBranches keys) yet ran SERIALLY after the accounts/storage/
code fan-out, leaving the machine ~1-core-busy during its build. It depends only
on branchesStore + keyStateValue (both ready from the fold), not on the other
domains, so run it as a 4th goroutine in the same fan-out — the Writer is
immutable and each domain writes its own files. Phase-5b wall time drops toward
max(domains) instead of sum. Byte output unchanged.
…old (item 2)

The three account/storage/code domain writes depend only on the finalized
stores + counts, NOT on the commitment fold's result — yet they ran AFTER the
2-4h fold in Phase 5b, on cores the 16-way fold left idle. Start their fan-out
BEFORE ComputeGenesisRoot so ~sequential compression overlaps the fold; only
WriteCommitment (needs branches + keyStateValue) stays queued after. The Writer
is immutable and each domain writes its own files, so no races; output bytes are
unchanged (same inputs, reordered concurrency only).
…(item 6b)

seg.Compressor.Compress rescanned the entire .idt (28-44 GiB per domain at
100 GB) solely to build the word-length histogram (posMap) + emptyWordsCount.
Both depend only on each word's length, which AddWord already sees — accumulate
them incrementally there and drop the Pass-A ForEach. One fewer full pass over
the intermediate per domain. Byte output unchanged (the histogram fully
determines the Huffman tree); seg goldens green.
… 1 foundation)

The commitment-input store is being partitioned into 16 nibble sub-stores to
kill the shared-Pebble-cache random-Get contention (58% of a 5GB run). The Touch
must read those 16 sub-stores ROUND-ROBIN so chunk 0 spans all 16 first-nibbles
(the first-serial-then-concurrent invariant) — which the callback-only Iterate
can't do. Cursor is a caller-driven forward pull-iterator (NewCursor/Next/Key/
Value/Close) mirroring Iterate's finalize-gate + reader-ref lifecycle. Test:
round-robin over 2 stores interleaves + preserves per-store sort order.
…C Phase 1)

Kills the shared-Pebble-cache random-Get contention (58% of a 5GB run: 16
ParallelHashSort workers ping-pong the same block-cache atomic refcount + shard
lock). Split the commit-input store into 16 sub-stores by keccak first-nibble —
exactly the worker sharding — so each worker reads its OWN store → disjoint
caches → no cross-worker contention. Keeps ModeDirect + 16-way; root unaffected
(partition only changes which file holds a value, not the keccak order or fold).

- commitment.go: ComputeGenesisRoot takes []*streamsort.Store (len 16);
  subtreeCtx.Account/Storage route by InputPart(plainKey); the Touch ROUND-ROBINS
  16 Cursors so chunk 0 still spans all 16 first-nibbles (else the concurrent
  unfold reads an empty branch). Exported NumInputParts/InputPart.
- snapshot_cgo.go: 16 sub-stores (cache budget /16); commitIn writes tagged with
  their part on the parallel encode workers (keccak stays off the single writer);
  runCommitInWriter routes by part; finalize/close all 16.

Phase 2 (re-key by hashed key + reused per-worker SeekGE cursor to also kill the
per-Get newIters ~17%) is a follow-up. Unit gate: H4/ChunkedVsSingle/FirstChunkShrink.
…tores (Tier-C Phase 2)

Phase 1 killed the shared-cache contention; Phase 2 kills the per-Get newIters
(~17%). Re-key the 16 commit-input sub-stores by the HASHED key (value =
EncodeInputRow(plainKey, update) so the Touch can still recover the plain key),
so each worker reads its sub-store in hashed-sorted order. Each subtreeCtx then
serves Account/Storage from ONE lazily-opened reused streamsort.Getter (SeekGE on
a long-lived pebble.Iterator) instead of db.Get-per-call — forward SeekGE stays
in-file, skipping the per-Get iterator build. The root/serial ctx spans nibbles,
so it retires the getter and falls back to db.Get (low volume).

- streamsort.Getter (reused SeekGE iterator) + test.
- commitment.go: HashedKey/EncodeInputRow/DecodeInputRow; subtreeCtx.lookup routes
  to the reused getter (or db.Get for the root); Touch recovers plainKey from the
  row value; factory closes the getter; helper keys by HashedKey.
- snapshot_cgo.go: commitIn writes keyed by HashedKey(plainKey), value wrapped.

Root must stay byte-identical: H4/ChunkedVsSingle/FirstChunkShrink.
…nch at scale

This reverts 88b492d. Phase 2 re-keyed the 16 commit-input sub-stores by the HASHED
key so a reused SeekGE Getter could kill per-Get newIters (~17% of commitment). But
hashed keying makes each round-robin Touch chunk a NARROW consecutive hashed slice
per nibble: the serial first chunk builds only a deep narrow skeleton, a later
CONCURRENT chunk descends into an intermediate branch never established -> 'empty
branch data read during unfold' (hex_patricia_hashed.go:1749). Bites once slices are
dense (100GB and 5GB@chunk=250K reproduce; unit tests + 5GB@chunk=5M do not).

Phase 1 (plain-key sub-stores, kept) makes every chunk a BROAD hashed sample =
pre-Tier-C regime that passes at 100GB. Confirmed: 5GB@chunk=250K FAILS on Phase 2,
PASSES (0x7acbe35d) on Phase 1. Phase 1 keeps the partition win (~29%); the Getter
(~17%) needs hashed stores -> restoring it needs a broad Touch over hashed stores,
deferred. Root unaffected.
… goroutine

The single-threaded draw loop keccaks AddrHash for every account and CodeHash
for every ~30% delegated EOA, but the erigon writer never reads either (it keys
stores by plain address and re-derives the code hash on its parallel encode
workers — encodeEntity). Neither keccak consumes RNG draws, so eliding them
cannot change the drawn byte-sequence or any cross-client root.

- entitygen.GenerateEOALean + autofill.GenerateEOAFlavoredLean: same draws,
  derived hashes left zero; existing functions unchanged (other clients keep
  AddrHash).
- Plan.SkipDerivedHashes routes DrawEOA; erigon opts in at its draw loop.
- TestFlavoredDrawRNGSequenceInvariant pins the safety argument: 2000 draws
  byte-identical content + post-draw RNG state aligned, delegation branch
  exercised.
- Refresh the stale commitmentChunkKeys comment (chunked runs chunk 0 serial +
  rest CONCURRENT, not 'serial'; single-shot RAM math + GOMEMLIMIT=20GiB
  guidance instead of the obsolete '240 GB bench' note).
…ranch store

The branch bytes used to be sorted TWICE: the walk's live branchStore (pebble,
already ascending) was iterated and re-Put row-by-row into a second streamsort
(branchesOut), finalized, then iterated again by WriteDomain — a full extra
pebble write+compaction+read of the ~44 GB branch set at 100 GB scale.

- commitment.Result now RETAINS the branch store (ownership transfers on
  success): BranchIterate streams it (repeatably), CloseBranches releases it
  (idempotent). ComputeGenesisRoot drops the branchesOut param; the count pass
  is a pure sequential scan (chunked mode re-folds prefixes, so the count must
  come from a scan, not a set-counter).
- snap.WriteCommitment takes a BranchStream and splices the KeyCommitmentState
  row at its binary sort position (1-key 2-way merge). Producer errors surface
  as the ROOT CAUSE ahead of WriteDomain's downstream symptoms (no silently
  truncated .kv). A branch prefix colliding with 'state' now FAILS LOUDLY —
  the old last-write-wins Put would have silently dropped the branch row.
- snapshot_cgo: branchesStore deleted; WriteCommitment consumes
  Result.BranchIterate via method-value conversion; retained store closed
  after emitWg.Wait via defer.

Tests: TestWriteCommitment_StateRowSplice (mid-stream/last/only + ascending),
TestWriteCommitment_StreamErrorSurfaces, TestWriteCommitment_StateCollisionErrors,
TestResultBranchLifetime (cgo). Output byte-identity pinned by the existing
seg/btindex/recsplit goldens + ChunkedVsSingle branch-bytes oracle.
…s/send)

The M1a-0 profile (5GB, single-shot) showed generation is NOT writer-Put-bound
(pebble arenaskl ~4%) — the cost is the per-item channel machinery itself:
sendDomainWrite 10.15% cum, runtime.selectgo 30% cum, futex/lock2/steal ~35%
scheduler churn across 48 producers. 613M entities × 2-3 sends each ≈ 1.5-2B
channel operations.

Batch them: each producer (encode worker; the PreAlloc drain on main) owns
per-domain batchedSenders accumulating 128 domainWrites per channel send;
writers consume []domainWrite batches. ~99% fewer channel ops, same in-flight
row count (256 batches ≈ old 4096-item buffer). Root-neutral: pebble sorts by
key regardless of arrival order/batching (partition-invariance argument
unchanged). Producers flushAll before exit so no buffered rows are dropped
(worker exit + PreAlloc drain, both before the channels close).
…e-shot-GATED

Re-applies the Phase-2 machinery (88b492d) with the guard that makes its bug
class structurally impossible. KeyingHashed keys the 16 commit-input sub-stores
by KeyToHexNibbleHash(plainKey) (value = EncodeInputRow carrying the plain key),
so each 16-way fold worker reads its nibble in exactly stored order and serves
Account/Storage from ONE reused SeekGE streamsort.Getter — killing the profiled
per-Get newIters/SeekGE chain (16.7% of the whole 5GB run, ~60% of the fold).

THE GUARD: hashed keying is valid ONLY single-shot. Under chunking, hashed
order makes each round-robin Touch chunk a narrow consecutive hashed slice
whose thin serial-chunk skeleton breaks later concurrent chunks ('empty branch
data read during unfold' — the 209af4a revert). HashedInput() (== chunkKeys==0)
is the single source of truth for BOTH the gen-side keying and the fold's
keying parameter, and ComputeGenesisRoot rejects KeyingHashed+chunking at
entry. At 100 GB under the 20 GiB heap budget (chunked), gen stays plain-keyed
— this ships as M2's input layer, active at single-shot scales now.

- streamsort: Getter (reused SeekGE iterator) + TestGetter restored.
- commitment: InputKeying/HashedInput/HashedKey/Encode+DecodeInputRow;
  subtreeCtx.lookup routes plain (db.Get) vs hashed (bound Getter; the
  nibble-spanning root ctx retires to db.Get); keying-aware Touch;
  ComputeGenesisRootFromAccountsKeyed for dual-layout tests.
- snapshot_cgo: commitIn rows keyed per HashedInput() on the encode workers.
- Tests: TestComputeGenesisRoot_HashedVsPlainSingleShot (byte-equal root +
  branch bytes over a 2048-acct all-nibble fixture w/ storage),
  TestComputeGenesisRoot_HashedRejectsChunking (negative guard).
Mechanical vendor of execution/commitment into internal/erigon/hph — the same
maintenance model as internal/erigon/{seg,btindex,recsplit}: pinned provenance
headers, byte-golden-tested against the module (Golden A), bench erigon daemon
stays stock upstream.

- Copied verbatim: hex_patricia_hashed.go, hex_concurrent_patricia_hashed.go,
  commitment.go, keys_nibbles.go, metrics.go, warmup_cache.go, warmuper.go,
  nibbles/nibbles.go. Package commitment -> hph; nibbles import rewritten;
  cgo_erigon_commitment build tag (module deps resolve on the bench only).
- Stripped: the witness/trie surface (GenerateWitness, toWitnessTrie,
  witnessCreateAccountNode, witnessComputeCellHashWithStorage + the trie/
  witnesstypes imports — no kept callers) and the untouched-by-us files
  (bin_patricia_hashed, verify, warmuper... trie_reader, recording_context,
  trie_trace, tests).
- Golden A (golden_parity_test.go): drives the vendored AND module engines
   through the identical 16-way concurrent Process over a 2048-account
  all-nibble fixture (half with storage) and asserts root + EVERY branch row
  byte-identical (upstream only ever asserts root equality). Also pins the
  Update wire-encoding equality across the two packages.

Why: the DDF driver (next commit) calls the engine's own followAndUpdate
directly from sorted cursors — that requires the unexported fold surface,
hence the vendor.
…DDF step 2)

The DDF driver feeds the vendored engine's followAndUpdate straight from the
16 hashed-keyed sub-store cursors, from empty, in one pass:

- hph.DirectFold (state-actor addition inside the vendored package — needs the
  unexported mounts/unfoldRoot/foldNibble): replicates ParallelHashSort's
  engine choreography verbatim; the ONLY change is the feed — a KeyStream per
  nibble instead of an etl collector, with the Update passed inline so the
  ctx.Account/Storage re-fetch branch is never taken.
- commitment.ComputeGenesisRootDirect: cursorStream adapters (row key IS the
  nibblized hashed key the engine sorts by; value carries plainKey + Update),
  per-worker WRITE-ONCE branch sinks (lazy — unfoldRoot's provisional ctxs
  never write; rows counted as emitted → exact BranchCount, no count scan),
  root row flushed via ApplyAndClearInlineDeferredUpdates as on the engine
  path. Branch reads return nil (from-empty single-shot never re-reads a
  written prefix; every prev-merge read is nil-correct); Account/Storage FAIL
  LOUDLY (any re-fetch = broken assumption).
- Result gains branchSinks mode: BranchIterate k-way heap-merges the sorted
  sinks (nibble-disjoint prefixes + unique root row) into the ascending
  stream snap.WriteCommitment already consumes; CloseBranches dual-mode.
- Gated: STATE_ACTOR_COMMITMENT_DIRECT (default ON) AND KeyingHashed
  (single-shot) — the Updates/etl engine path stays as fallback.

Deleted relative to the engine path: the never-spilled Touch dedup map
(~50-67 GB at 613 M keys — the reason chunking existed), the etl re-sort
spill, all cross-chunk branch prev-reads, all per-key input Gets, and the
44 GB BranchCount scan.

Golden B (directdrive_test.go): engine/plain == engine/hashed == DIRECT on
root + BranchCount + every branch row byte + HPHState, across span-2048
(w/ storage), single, one-slot, multi-slot, one-nibble-cluster, and empty
fixtures.
…omment rot

Five-agent review of the full stack found zero correctness defects; this lands
the fact-checked remainder, holding to a minimum-additions rule (guards that
replace tests; deletions over rewrites).

Hardening (error paths only — no produced byte changes):
- directdrive: register the root sink BEFORE acting on flush errors (was
  leakable), and record each sink's Finalize error in the registry (firstErr,
  surfaced before handoff) instead of swallowing it.
- snap.WriteCommitment: assert the branch stream is strictly ascending — the
  one seam WriteDomain trusts blindly ('behaviour is undefined'); replaces the
  proposed fixture merge-order test with a check every run performs.
- snap.WriteDomain: remove the final-named .kv when pass-2 (accessor build)
  fails, so an aborted build can't pass for a complete domain.
- commitment.ComputeGenesisRoot: probe the input key length (plain 20/52 vs
  hashed 64/128 — disjoint) against the declared keying; a layout skew now
  fails loud instead of folding a silently wrong root.
- snapshot_cgo: set SkipDerivedHashes on a LOCAL copy of the Plan (the shared
  config is no longer mutated).

Oracle gap (the one genuine coverage hole): code-bearing accounts never
crossed the byte-identity gates. Golden A's fixture now marks every 5th
account with delegation code (both engines' encoders gained CodeUpdate);
Golden B adds 'delegated' + 'codeAndStorage' fixtures and bumps multiSlot to
64 slots (multi-level storage subtree).

Comment rot (net-negative lines): provenance header now lists the real strip
(5 funcs incl. PrintGrid + 4 imports); deleted the ~45-line orphaned block
documenting the removed branchesOut parameter; the CloseBranches defer comment
states the true safety reason (reader spawns after the early returns); the
chunk-keys/pipeline/streamsort docs no longer describe the engine fallback as
the default path; batch-buffer doc states 8x in-flight rows, not 'same'.
…y-default

Tail (the ~26.5-min snapshot/commitment phase after the DDF fold):
- seg: CompressFromSource writes the .kv straight from a repeatable word
  source (pass A counts, pass B encodes) — byte-identical by construction
  (the .kv is a pure function of the word sequence; the huffman table
  derives from the incremental posMap; the bit writer flushes to byte
  alignment per word). WriteDomain now drives its entries closure twice
  through it, eliminating the .idt scratch: ~90 GB of writes across the 4
  domains and a 44 GB transient disk peak at 100 GB scale. The golden test
  runs BOTH paths against the same upstream bytes.
- snap.WriteCommitment: state-row splice + ascending assert moved to
  per-invocation scope (load-bearing for the twice-driven closure — hoisted
  state would silently drop the state row from pass 2).
- recsplit: parallel Build (Args.Workers) mirroring upstream's
  buildWithWorkers — producer groups buckets in ascending order with dense
  seq numbers, N workers run the unchanged recsplitRecurse on private
  scratches (own golombParamCache each; the footer harvests the longest
  table), a min-heap consumer writes strictly in dispatch order, so output
  is byte-identical at any worker count. flushCurrentBucket refactored into
  computeBucket (pure) + writeResult (serial) shared by both paths. The
  spike golden now runs Workers∈{0,4}. Client default min(NumCPU,8) via
  STATE_ACTOR_RECSPLIT_WORKERS.
- Instrumentation under Verbose: per-domain WriteDomain, fold, commitIn
  close, WriteCommitment timings; STATE_ACTOR_SNAP_TIMING adds pass-1/
  pass-2/Build splits — the first true decomposition of the tail.

RAM under control BY DEFAULT (in-binary, zero-config): pebble memtable
arenas and block caches are C-malloc'd under cgo — off the Go heap,
invisible to GOMEMLIMIT — so the fix is code defaults, not env discipline:
- streamsort.Options.MemTableBytes (new knob) + right-sized defaults:
  64 MiB commitIn/branch-sink/recsplit-spill arenas, 128 MiB value stores
  (was 256 MiB each; up to 2 live arenas per store).
- commitIn block cache defaults to 8 MiB/store under hashed keying (DDF
  cursors and the engine Getter read sequentially — the 4 GiB cache only
  helps the chunked plain path's random Gets, which keeps it);
  STATE_ACTOR_COMMITMENT_CACHE_GB set explicitly still wins.
- debug.SetMemoryLimit(8 GiB) iff GOMEMLIMIT unset — heap ceiling for
  unconfigured runs; explicit env always wins.
- Verbose prints the go_heap/go_sys split so RSS readers aren't misled by
  reclaimable mmap pages.
Expected committed off-heap during the fold: ~8-12 GiB -> ~1.5-3 GiB.

All seg/recsplit/snap/streamsort suites green locally, including the
two-path seg golden and the workers-parity spike golden.
… (R1)

The Direct-Drive Fold obsoleted chunking as the low-RAM option; the engine
path survives single-shot-only as the Golden-B oracle and the
STATE_ACTOR_COMMITMENT_DIRECT=0 fallback. ComputeGenesisRoot's fallback
collapses to one Updates + concurrent Process; the chunk knobs
(STATE_ACTOR_COMMITMENT_CHUNK_KEYS, firstChunkKeys), the hashed+chunked
guard, HashedInput() (constant-true once chunking is gone), and the
cursor round-robin walker all go. chunked_test.go dies with the path;
hashed_single_shot_test.go was a strict subset of Golden B (its
keccakSpanFixture relocates to directdrive_test.go).

Result.BranchNodes was test-only plumbing on a production struct — tests
now collect via BranchIterate (collectBranches helper) and own the
CloseBranches lifecycle. Dead setErigonWorkers removed.

Net −406 lines; roots re-proven by Golden B + H4 + the 5 GB gate.
Reachability closure from the live roots (DirectFold + the Golden A
driver): none of these had an inbound edge — deleted with Golden A left
UNCHANGED as the proof. hex_patricia_hashed: SetState/state.Decode/
cell.Decode (state-restore, unused — the chunked consumer used the
upstream module), the HexTrie* string readers, feedBranchHashesToKeccak
(superseded by hashRow), Grid, the DomainPutter/CommitmentWrite aliases
(+stateifs import), and the never-armed collapse-tracer cluster (type,
field, setter, both detectors, two nil-guarded call sites — the only
live-function edits, both provably never taken since the setter had no
callers). hph/commitment.go: ReplacePlainKeys/MergeHexBranches/
Validate+helpers/BranchStat+Collect+DecodeBranchAndCollectStat/
ParseTrieVariant/PendingCommitmentUpdate/the non-Direct Touch* family/
NewEmpty/SetMode/Mode/PlainKeys/keyHasherNoop (+ crypto/accounts/keccak/
slices/nibbles imports). keys_nibbles + nibbles: dead helpers (incl. the
CompactToHex cascade once validatePlainKeys died). metrics.go: the CSV
read-back cluster (zero callers anywhere). Provenance headers updated.

Net ≈ −1,290 lines; byte-identity re-proven by the unchanged Golden A +
Golden B + full cgo suites.
…ed engine half (R3)

Golden A now pins what production actually runs: vendored DirectFold vs
the UPSTREAM module engine, byte-for-byte on root + every branch row +
HPHState (new), fed by an in-memory sliceStream (16 first-nibble shards,
ascending hashed order — the cursorStream analogue). The old driver
exercised Updates/Process/ParallelHashSort — a choreography production
never executes; it was the only thing keeping that half of the vendored
package alive.

Purged with it: the Updates core (NewUpdates/TouchPlainKeyDirect/
HashSort/arena/Mode/KeyUpdate/keyHasher), both Process variants,
ParallelHashSort, CanDoConcurrentNext, the dead Trie interface +
TrieVariant + InitializeTrieAndUpdates + CommitProgress + the Tier-2
methods they compile-required, the never-enabled warmup subsystem
(warmuper.go + warmup_cache.go deleted; TrieContextFactory relocated to
directdrive.go; cacheOrDB fast-paths collapsed to plain ctx delegates),
the hphPool/Release/resetForReuse cluster, Process-confined fields
(traceDomain/capture/leaveDeferredForCaller — trace stays, 54 live
reads), and metrics.go reduced to the live counter subset (790 → 188).

hex_concurrent is now exactly the DirectFold core: mountTo,
NewConcurrentPatriciaHashed, foldNibble, unfoldRoot. Vendored package:
~7,400 → 3,677 lines. Provenance headers list the full strip.

Byte-identity: the retargeted Golden A (independent upstream oracle —
a stream-order bug fails loud, it cannot false-green) + Golden B + the
5 GB root gate.
Shrinks the heavyweight comment blocks to their load-bearing content:
fullRange/fat-genesis (constraint kept, history cut), Step-5b/overlap
narration, domainBatchSize/commitIn-close profiling stories, streamsort
package+Options docs, the DDF header, subtreeCtx lifecycle, the
WriteCommitment contract, and the recsplit spill-key rationale (the seq
LOAD-BEARING note stays). branchstore drops the chunk-era 1 GiB block
cache + STATE_ACTOR_BRANCH_CACHE_GB env (the re-read path died with
chunking) — 8 MiB cache + 64 MiB memtable like the other write-once
stores. Comments only otherwise.
@CPerezz CPerezz merged commit 6f1f4ce into ethereum:main Jul 7, 2026
9 checks passed
@CPerezz CPerezz deleted the perf/erigon-direct-drive branch July 7, 2026 14:28
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.

1 participant