Skip to content

perf(beacon): keep unslashed target balances incrementally for the pulled-up tip - #761

Draft
MegaRedHand wants to merge 3 commits into
mainfrom
perf/progressive-target-balances
Draft

MegaRedHand wants to merge 3 commits into
mainfrom
perf/progressive-target-balances

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

compute_pulled_up_tip runs on every imported block. It cloned the whole post-state and ran process_justification_and_finalization on the clone, which for altair and later scans all ~2.4M validators (ParticipationTotals::compute) plus an uncached total active balance. Lighthouse makes this O(1) with ProgressiveBalancesCache (unslashed participating balances per flag for the previous and current epoch, updated in process_attestation and on slashing) and weighs justification on a small JustificationAndFinalizationState instead of a state clone.

What Changed

  • ethlambda-storage: TargetBalanceCache, held by Store: block root -> raw unslashed TIMELY_TARGET balances of its post-state for the previous and current epoch, plus their epoch. Capped (lowest epoch evicted first) and pruned below the finalized epoch.
  • helpers/target_balances.rs: TargetBalanceDelta, carried in BlockProcessingOptions (target_delta) and recorded in every altair+ process_attestation when a validator newly earns TIMELY_TARGET while unslashed.
  • At import, a block's totals are its parent's cached totals plus the delta when the parent's entry is for the block's own epoch. An epoch boundary, a cache miss, a restart, or a block carrying a slashing does one full scan and caches the result.
  • stf/epoch/justification.rs: JustificationState::{from_state, weigh, write_back} holds only what weighing reads and writes; weigh_justification_and_finalization delegates to it, so epoch processing is unchanged. compute_pulled_up_tip uses it (no state clone) with the cached total active balance. Phase0 keeps the old path.
  • Debug builds run the old full path next to the new one in every pulled-up tip and assert the checkpoints and totals match.

Correctness / Behavior Guarantees

Checked against the code: only process_attestation writes participation flags within a block; effective balances change only in epoch processing (and the electra upgrade, at an epoch boundary); deposits, consolidation/withdrawal/exit requests and gloas builder operations touch balances, queues or exit epochs only. A slashing changes the unslashed set, so slashing blocks take the full scan.

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 133.0 ms -25.1 ms (-15.9%) [-25.9, -24.4]
wall (import + fork choice) 179.7 ms 156.4 ms -23.3 ms (-13.0%)

Per round -15.6% / -16.2%. Epoch-boundary blocks (n=2) -1.2%, by design (full scan there).

Tests Added / Run

  • the_target_delta_is_what_a_registry_scan_gains (real signed attestations, previous and current epoch, a slashed attester, a repeated vote); tests for the delta, extend_parent_totals, and cache eviction/pruning.
  • cargo test --profile release-fast -p ethlambda-storage --lib (213), -p ethlambda-state-transition --lib (633), -p ethlambda-blockchain --lib (292)
  • Beacon spec fixtures: fork_choice/ 206, sanity/ 595, epoch_processing/ 804, /attestation/ 356, /proposer_slashing/ 235, /attester_slashing/ 240, /random/ 208, finality/ 41
  • fork_choice/ under the dev profile with the equivalence assertion active: 206 passed (948 blocks took the incremental path, 36 the full scan)

Notes

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

…lled-up tip

compute_pulled_up_tip ran on every imported block and, at ~2.4M validators,
cloned the whole post-state (~2.5 ms) and scanned the registry for the
participation totals (~6 ms), plus an uncached total active balance. Lighthouse
avoids both with its ProgressiveBalancesCache.

The per-fork states derive SSZ, so the cache lives in the Store, keyed by block
root (TargetBalanceCache: raw previous/current unslashed TIMELY_TARGET sums,
bounded by capacity and pruned below the finalized epoch). process_attestation
in every fork from altair on records into a TargetBalanceDelta, carried in
BlockProcessingOptions, when a validator newly earns the target flag. A block
in the same epoch as its parent has the parent's totals plus that delta; an
epoch boundary, a cache miss or a block carrying slashings takes one registry
scan, cached for the children.

The justification weighing now runs on a small JustificationState extracted
from the state, so the pulled-up tip clones nothing and reads the total active
balance from the store's ActiveBalanceCache. weigh_justification_and_finalization
delegates to the same logic, so epoch processing is unchanged. Debug builds
also run the old full path and assert the checkpoints and totals agree, which
the fork_choice fixtures exercise on every block.
@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).

…nce delta

The progressive target-balance delta recorded every attester that newly earned
TIMELY_TARGET with `state.validator(index)`, a second registry tree descent per
attester on top of the one the reward computation had already made. Offline
import replay at mainnet scale put the `attestations` step at 5.8 ms per block
against 2.8 ms before the tree-backed lists and the delta; this is the delta's
share of that.

The read phase now keeps the effective balance and `slashed` flag it read for
the reward, and `TargetBalanceDelta::record_new_target` takes those two values
instead of a `&Validator`. Nothing in the read phase writes the registry, so
the cached values are the ones a second read would return, and a newly earned
target flag implies the loop reached that flag and read the attester first.
Applies to the altair, deneb, electra (electra and fulu) and gloas versions of
`process_attestation`.
@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
MegaRedHand added a commit that referenced this pull request Nov 4, 2026
get_head tallied every validator's latest message on every call, about 1M
on mainnet, and a live import pays it twice for the first timely block of a
slot (on_block's boost-gate walk, then the actor's get_head). That tally was
most of get_head's ~17 ms in the eth-5 replay.

The store now keeps the vote weights in a node array (Lighthouse unstable's
proto-array layout: one node per block, carrying its PENDING, EMPTY and FULL
weights). One attestation is one vote batch: Store::record_latest_messages
records the messages and moves the attesters' weight under one lock, and the
next read refreshes the subtree totals in one reverse pass over the blocks.
The head walk reads them through cached_node_weights. The proposer boost,
gloas's previous-slot rule and the walk bound depend on the clock, so they
stay read-time. There are no best-child pointers: under gloas the choice
moves without any vote, which is why Lighthouse stopped maintaining them.

What the cache cannot follow incrementally (finality pruning, a live-window
removal, a restart, a new justified balances snapshot, the gloas rule switch,
a block stored without its node) marks it stale, and the next read rebuilds it
from the latest messages at the old tally's cost, about once per epoch. A
rebuild rescans LiveChain under the lock, so an API read that scanned before a
removal cannot rebuild the removed blocks back in.

compute_node_weights stays, as the reference. Equivalence is checked by a
proptest against a naive tally (with the incremental state also equal to a
fresh rebuild after every operation), on the random trees at every step of
every seed, by a new test that feeds votes, blocks and equivocations through
update_latest_messages, on_block's insertion order and
insert_equivocating_index, and at every head check of every fork-choice
fixture.

Local benchmark (1M validators, 73 blocks, laptop): the tally 27 ms; a warm
cached read 7 us; a read after a 15.6k-vote batch 49 us; the batch itself
1.3 ms; a rebuild 17 ms.

Cherry-picked from perf/beacon-proto-array at b4adbaf. Adapted onto
nightly: both vote writers (set_latest_message and the new
record_latest_messages) grow the dense vote table through one
grow_latest_messages helper, which keeps #740's exact growth plus headroom;
the storage and fork-choice imports are merged with #759's and #761's caches.
Replaces #767, reverted in the previous commit.

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