Skip to content

perf(beacon): back the participation lists and pending deposits with persistent trees - #762

Draft
MegaRedHand wants to merge 7 commits into
mainfrom
perf/tree-backed-participation
Draft

MegaRedHand wants to merge 7 commits into
mainfrom
perf/tree-backed-participation

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

The post-block state root check cost ~27 ms per block on the mainnet follower. Perf put 87% of BeaconState::hash_tree_root in plain SszList fields re-merkleized from scratch on every call: the two participation lists (2.4 MB each) and PendingDeposits; only 13% was in the tree-backed ethlambda_ssz_tree lists. Lighthouse keeps every state list in milhouse persistent trees with cached node hashes.

What Changed

  • EpochParticipation (altair through fulu) is ethlambda_ssz_tree::List<u8, LIMIT> with the dense VecMap write buffer; PendingDeposits (electra, fulu) is List<PendingDeposit, LIMIT, BTreeMap>; gloas's are ethlambda_ssz_tree::ProgressiveList. All in tree_fields!, so apply_pending_mutations flushes them, and BeaconState::rebase_on shares them with a resident relative.
  • Accessors: altair_validator_lists() returns ParticipationRefs, epoch_participation_mut() a ParticipationMut.
  • helpers/participation.rs::scan walks both participation iterators in lockstep with iter_validators() instead of an indexed read per validator.
  • Small edits in the process_attestation variants, epoch rotation, fork upgrades and process_pending_deposits; docs/beacon_stf.md and CLAUDE.md updated.

Correctness / Behavior Guarantees

  • SSZ encoding and hash roots are byte-identical, so no DB_VERSION bump: new tests compare the old and new representations at lengths 0, 1, 31, 32, 33, 1000, 65541 and 300001 (bounded and progressive participation, pending deposits), plus writes and a push after a warm hash.
  • Buffer choice: a BTreeMap buffer was a clear loss for participation (75k scattered writes: 29-32 ms vs 13 ms flat); the dense VecMap buffer gives ~5 ms for the two lists.

Benchmarks

Offline ethlambda benchmark import replay on ethlambda-5 (Ryzen 9700X), three mainnet corpora, 2 rounds, steady state n=164, paired per block against main 012dc4f49:

main this PR change 95% CI
STF per block 158.1 ms 135.3 ms -22.9 ms (-14.5%) [-23.4, -22.4]
wall (import + fork choice) 179.7 ms 156.2 ms -23.4 ms (-13.0%)

Per round -15.3% / -13.6%. Epoch-boundary blocks (n=2) -4.9%.

Follow-up commits: regressions this PR introduced

Measured on nightly with all four Lighthouse ports, this PR's first commit cost attestations +1.5 ms per block (a tree descent per attester to read its flags, and another per get_mut write) and process_epoch +9.1 ms at epoch boundaries. The last five commits address that:

  • ethlambda_ssz_tree: List::set / ProgressiveList::set (a blind buffered write), sorted_reader() (reads non-decreasing indices, descending only when an index leaves the leaf last read), and repeat(value, len) (one full leaf and one full inner node per level, shared, each hashing once).
  • process_attestation: the read phase uses the sorted reader (attesting indices are sorted) and records current | new; the write phase stores it with set. That is exact because an attestation's indices are distinct and nothing writes the list between the two phases.
  • The epoch rotation and the altair upgrade build the zero list with repeat.

Micro-benchmarks (2.4M validators, ~74k ascending attesters): read 0.56-0.70 -> 0.10 ms, write 1.6-2.2 -> ~1.0 ms; rotation plus the next hash 3-6 ms -> 0.1-0.2 ms.

On nightly, together with #761's follow-up, offline replay on ethlambda-5, paired, steady state n=164:

before after change 95% CI
STF per block 20.7 ms 19.1 ms -1.5 ms (-7.3%) [-1.6, -1.4]
attestations (ordinary blocks) 5.8 ms 4.3 ms -1.5 ms
process_epoch (epoch boundaries) 106.2 ms 104.8 ms -1.5 ms

Still above nightly before the ports (2.9 ms and 97.2 ms): attestations +1.4 ms (shared with #761) and process_epoch +7.6 ms per epoch boundary; being profiled.

PendingDeposits is left as it is: dropping a processed prefix that is not a multiple of a leaf's element count shifts every later element into another leaf, so the cached hashes cannot be kept without a re-chunking operation. Its per-epoch cost is about 1 ms plus 3 ms of hashing for 20k deposits, against a hash that used to be paid on every block.

Tests Added / Run

  • Representation-equivalence tests above; ignored micro-benchmark participation_root_after_a_block_of_writes_bench (cargo test --profile release-fast -p ethlambda-types --lib -- --ignored --nocapture participation_root).
  • Follow-ups: set, the sorted reader and repeat checked against get and against Vec-built lists (roots and SSZ bytes) at lengths around every leaf and inner-node boundary up to 2.4M; writes into a repeated list through set, get_mut, iter_cow, rebase_on and push leave the clone and untouched leaves unchanged. Ignored benches attestation_pass_bench, participation_rotation_bench, pending_deposits_epoch_rebuild_bench.
  • cargo test --profile release-fast -p ethlambda-ssz-tree --lib (80, also in the dev profile), -p ethlambda-types --lib (237), -p ethlambda-state-transition --lib (630), -p ethlambda-storage --lib (210); cargo check of blockchain, rpc, p2p
  • Beacon spec fixtures: ssz_static/ 1761, sanity/ 595, epoch_processing/ 804, /attestation/ 356, fork/ 138, transition/ 172, /random/ 208, fork_choice/ 206, /deposit_request/ 43, rewards/ 372, finality/ 41

Related Issues / PRs

  • Part of a set of Lighthouse ports from the same profile, each its own PR.

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test — not run; the crate lib tests and spec filters above were run instead

…persistent trees

On mainnet (about 2.4M validators) the post-block state_root check costs
about 27 ms per block, and perf attributes most of BeaconState::hash_tree_root
to plain SszList fields re-merkleized from scratch on every call: the two
participation lists (2.4 MB each) and pending_deposits. Lighthouse keeps every
state list in milhouse trees with cached node hashes.

EpochParticipation (altair through fulu), PendingDeposits (electra, fulu) and
their gloas progressive counterparts now use ethlambda_ssz_tree lists. Only the
current epoch's participation list is written by a block, so the previous
epoch's list and an untouched deposit queue reuse their cached roots. The SSZ
encoding and roots are unchanged (tests compare against the flat lists), so
DB_VERSION is not bumped.

Participation writes are buffered densely, not in a BTreeMap: at one slot's
worth of scattered attesters the BTreeMap inserts cost several times the
hashing they save. A quick ignored benchmark at 2.4M entries and 75k writes
measures about 13 ms for the flat lists against about 5 ms for the trees.

Reads go through ParticipationRef and ParticipationMut, since a tree has no
slice view. The participation scan walks the list in lockstep with the
registry instead of a tree descent per validator. Both lists are also rebased
onto a resident relative of a state decoded from storage.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Combined result for the four Lighthouse ports (#759 pubkey index, #760 column KZG, #761 progressive target balances, #762 tree-backed participation), squashed onto nightly b1fc143 as 080bd40..1d06bf9.

Offline import replay on ethlambda-5, same three mainnet corpora, both legs built at the commit and run with --warm-committee-sums, 2 rounds, steady state n=164, paired per block:

nightly b1fc143 + all four (1d06bf9) change 95% CI
STF per block 140.7 ms 20.7 ms -120.0 ms (-85.3%) [-123.5, -117.3]
wall (import + fork choice) 162.5 ms 43.8 ms -118.7 ms (-73.0%) [-122.1, -116.0]

Per STF step, ordinary blocks (n=162): sync_aggregate 71.4 -> 0.9 ms (#759), execution_requests 5.3 -> 0.0 ms (#759), state_root 24.8 -> 4.0 ms (#762), unattributed 28.0 -> 1.3 ms (#761's removed state clone and scan, #762's cheaper clone). Regressions being fixed: attestations +2.9 ms, process_epoch +9.2 ms at epoch boundaries, get_head +1.0 ms; a per-item attribution run is in progress.

Live on the ethlambda-2 mainnet follower (nightly 1d06bf9): steady stf ~30-40 ms per gossip block (nightly before the ports: ~175 ms).

…peat

Three primitives the participation-list fixes that follow need, kept in their
own commit so the fixes read as pure call-site changes.

- `List::set` / `ProgressiveList::set` buffer a value without first descending
  the tree to copy the old one into the write buffer, which `get_mut` does even
  when the caller already knows the old value.
- `sorted_reader()` reads non-decreasing indices keeping the current leaf's
  slice, descending only when an index leaves it. Pending writes are consulted
  first, exactly as `get` does, so the answers are identical.
- `List::repeat` / `ProgressiveList::repeat` build a list of identical elements
  by sharing one full leaf and one full inner node per level, each caching its
  hash once, so building and hashing cost the tree height plus the partial last
  leaf instead of the whole length. Nodes are immutable and every write copies
  the path it touches, so a shared leaf can never be mutated; tests write into a
  repeated list and check its clone and the untouched leaves, and compare roots
  against the Vec-built list at lengths around every leaf and node boundary.
…rotation

Two micro-benchmarks next to `participation_root_after_a_block_of_writes_bench`,
at 2.4M validators: the participation part of one `process_attestation`
(about 75k ascending reads, then the writes) in each combination of per-index
`get`/sorted reader and `get_mut`/`set`, and the epoch rotation's new list
built from a zeroed `Vec` against `List::repeat`, with the root that follows.
Run with `cargo test --profile release-fast -p ethlambda-types --lib --
--ignored --nocapture attestation_pass_bench participation_rotation_bench`.
The write phase did `get_mut(index)` and `*flags |= new_flags` for every
attester. On a tree-backed list `get_mut` descends the tree to copy the old
value into the write buffer, although the read phase had already read it as
`current_flags`. Micro-benchmark at 2.4M validators and about 74k ascending
attesters (best of five, release-fast): the write phase takes 2.16 ms with
`get_mut` and 0.94 ms with the blind `set`.

The read phase now records `current_flags | new_flags` and the write phase
stores it with the new `ParticipationMut::set`. That is exactly what `|=` would
leave: the attesting indices are distinct (members of disjoint committees, and
the signature set built before the read phase refuses a repeated index), so
each index is written once, and nothing writes the list between the two phases,
so the stored value is still `current_flags`. Applies to the altair, deneb,
electra (electra and fulu) and gloas versions of `process_attestation`.
… reader

Once the participation lists became trees, the read phase of
`process_attestation` paid a full tree descent per attester (`get` was a slice
index before). An attestation carries tens of thousands of attesters. Its
indices are sorted ascending and consecutive ones almost always share a leaf,
which holds thousands of flags.

`sorted_reader()` keeps the current leaf's slice and descends only when an
index leaves it, consulting pending writes first exactly as `get` does, so the
answers are identical. Micro-benchmark at 2.4M validators and about 74k
ascending attesters (best of five, release-fast): the read phase takes 0.70 ms
per-index and 0.10 ms with the reader; with the blind write from the previous
commit the whole participation pass goes from 2.86 ms to 1.03 ms. Every
`process_attestation` (altair, deneb, electra and fulu, gloas) now uses it;
the reader debug-asserts that indices do not decrease, which
`get_attesting_indices` guarantees by sorting.
…o leaf

`process_participation_flag_updates` installed the new current list as
`vec![0; validator_count]` converted into a tree: about 2.4M bytes allocated
and filled into hundreds of separate leaves, every one of which the next state
root then hashed. At epoch boundaries `process_epoch` went from 96.3 ms to
105.5 ms in the offline import replay once the lists became trees.

`List::repeat` / `ProgressiveList::repeat` (added with the other ssz-tree
primitives) build the list from one full leaf and one full inner node per level,
each shared through its `Arc` with its cached hash, so building and hashing cost
the tree height plus the partial last leaf. Micro-benchmark at 2.4M validators
(release-fast, steady state over three runs): the old build plus the root takes
3.0 to 6.0 ms, the repeated list 0.10 to 0.13 ms. Nodes are immutable and every
write copies its path, so sharing is invisible to later writes; the roots and
SSZ bytes are tested equal to the flat list's at lengths around every leaf and
node boundary, and a write to a repeated list is tested to leave its clone and
the other leaves alone.

Used by the altair-through-fulu rotation, gloas's own copy of it, and the
altair upgrade's two fresh lists.

PendingDeposits is rebuilt from a `Vec` once per epoch by
`process_pending_deposits`, which only drops a prefix and appends postponed
deposits. The tree cannot keep the suffix's cached hashes: leaves are aligned
runs, so dropping a prefix that is not a multiple of a leaf shifts every later
element into a different leaf, and the API has no operation that re-chunks
while carrying cached element roots. An ignored benchmark in `ethlambda-types`
puts the cost at about 7 ms for the copy and rebuild plus about 24 ms to hash,
for a queue of 200k deposits (about 1 ms plus 3 ms at 20k), once per epoch. The
hash was paid on every block before the trees, so this is left as it is.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Per-PR attribution on nightly, where the four ports are stacked in the order #759, #760 + #761, #762. Offline import replay on ethlambda-5, three mainnet corpora, --warm-committee-sums, 2 rounds, steady state n=164, paired per block:

nightly at STF per block change vs before the ports 95% CI
b1fc143 (before the ports) 140.1 ms
+ #759 (080bd40) 65.1 ms -75.0 ms (-53.5%) [-78.5, -72.3]
+ #760, #761 (33551eb) 39.9 ms -100.2 ms (-71.5%) [-103.7, -97.5]
+ #762 (1d06bf9) 20.8 ms -119.4 ms (-85.2%) [-122.9, -116.6]
+ follow-ups in #761 and #762 (2b51a33) 19.1 ms

#760 does not run in the replay (no custody columns), so the second step is #761's.

Regressions found by the per-step split, and what the follow-up commits (now pushed to #761 and #762) did about them:

step from added after the follow-ups
attestations, ordinary blocks #761: second validator read per attester +1.4 ms 5.8 -> 4.3 ms together (before the ports: 2.9)
attestations, ordinary blocks #762: per-attester tree read and write +1.5 ms
process_epoch, epoch boundaries #762 +9.1 ms 106.2 -> 104.8 ms (before the ports: 97.2)
get_head the #760 + #761 step +1.3 ms unchanged

get_head's code is untouched by either PR; a perf profile puts the extra time in walk_head's own code, so it looks like codegen rather than work. The same profile shows std SipHash in fork choice's vote tally as most of get_head's ~17 ms per block; that is a separate change, not part of these PRs.

ethlambda-2 (mainnet follower) runs nightly 2b51a33 since 2026-11-03 14:42 UTC.

@MegaRedHand MegaRedHand added performance Performance improvements or possible performance improvements beacon Ethereum Beacon Chain client labels Nov 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beacon Ethereum Beacon Chain client performance Performance improvements or possible performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant