Repository navigation
perf(beacon): weigh fork-choice votes from a justified-balances snapshot - #649
Conversation
Import-replay benchmarkMethod.
This PR does not touch block processing, yet All five performance PRs together (#647, #648, #649, #650, #633), measured with a 1 s delay between blocks so the state writer drains (
|
…3-636-638-gloas-live Conflicted files: - crates/blockchain/state_transition/src/beacon/fork_choice.rs - crates/blockchain/state_transition/src/metrics.rs (rerere reused an earlier resolution: both metric sets kept) - crates/common/types/src/beacon/fork_choice.rs - crates/storage/src/store.rs - docs/metrics.md (same rerere resolution) Gloas adaptations: - compute_node_weights, the payload-aware vote loop every head computation uses (this branch's gloas fork choice has no compute_weights path in production), reads each voter's weight from the justified-balances snapshot instead of state.validator(i) per vote. A zero in the snapshot (past the registry, inactive, slashed) is a skip, as the old missing/slashed/inactive checks were; a zero effective balance adds nothing either way. The payload status, previous-slot rule, bound and the gated proposer boost are untouched: should_apply_proposer_boost_with still gets the justified state itself, and get_proposer_score uses the snapshot total, which is the number the justified state gave before. - The spec-literal gloas references (gloas_get_weight, gloas_get_attestation_score, gloas_get_head) are not changed. - is_head_weak and is_parent_strong keep this branch's versions (they score against the justified state, including the equivocator top-up); only the pre-gloas shapes #649 rewrote were dropped, so their thresholds still come from calculate_committee_fraction over that state. - Fixture runner: the compute_weights vs get_weight oracle #649 added is kept and runs on every case; a gloas counterpart compares compute_node_weights with gloas_get_weight for every node under the justified root (not only the leaves viable_for_head_roots_and_weights covers) at each checks step of a case whose current slot is gloas. - The dense latest_messages tests build the gloas LatestMessage (slot, payload_present); the snapshot builder walks iter_validators(). Other: types fork_choice.rs and the storage import list keep both sides' imports.
2cdfdc8 to
aa1b89d
Compare
🤖 Codex Code ReviewOverall this looks solid. The main fork-choice optimization is well targeted, and I don’t see a consensus-logic regression in the new
One thing I’d flag:
No other correctness, security, memory-safety, or SSZ concerns stood out from this diff. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview of PR 649: justified-balances snapshot and dense vote table (beacon fork choice) I read the diff only. I did not build the code or run the tests. The change is sound overall and the semantics look preserved. The tests are thorough. Why the semantics look preserved
Points to consider
I found no consensus-correctness issues. Item 1 is the one I'd address before merging, or at least confirm that every caller passes a validated index. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Kimi Code ReviewI'll review this PR which optimizes fork-choice weight computation by introducing a Security & Correctness1.
|
| Severity | Issue | Location |
|---|---|---|
| Medium | MAX_PLAUSIBLE_VALIDATOR_INDEX is debug-only |
store.rs:3370 |
| Low | Fragile error string matching in tests | fork_choice.rs:720 |
| Low | usize truncation on 32-bit systems |
fork_choice.rs:147 |
| Low | Doc comment epoch mismatch | fork_choice.rs:119 |
| Nit | set_justified_balances naming |
store.rs:3428 |
The core logic is correct and well-tested. The JustifiedBalances snapshot correctly replicates spec semantics while avoiding per-vote state lookups. The dense latest_messages table is a good optimization with acceptable memory tradeoffs.
Most important fix: Harden MAX_PLAUSIBLE_VALIDATOR_INDEX to a release assertion or bounded growth policy.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
ilitteri
left a comment
There was a problem hiding this comment.
Medium: the committee-fraction total must exclude active slashed validators.
calculate_committee_fraction in specs/phase0/fork-choice.md:300-307 builds unslashed_and_active_indices and computes total_balance over those indices. build_justified_balances instead adds every active validator to total before checking validator.slashed (crates/blockchain/state_transition/src/beacon/fork_choice.rs:1321-1333). That total feeds proposer boost and reorg thresholds, so slashed-but-active stake currently inflates them.
Lighthouse's JustifiedBalances::from_justified_state adds only active non-slashed validators to total_effective_balance; Prysm likewise supplies ActiveNonSlashedBalancesByRoot for this calculation.
This behavior existed before the PR, but the new snapshot and the_proposer_score_counts_a_slashed_but_active_validator test now codify the non-spec behavior. Please compute the committee total from the active non-slashed snapshot balances and update that test's expectation (9 active non-slashed validators in its fixture, not 10 including the slashed one). The separate get_total_active_balance used by state-transition rewards is not the quantity this fork-choice helper should use.
|
Fixed in ec477bc: the committee-fraction total (proposer boost and both reorg thresholds) now counts active, unslashed validators only, and Note on the spec reference: the pinned fixtures (v1.7.0-beta.2) still define
|
ec477bc to
5f96548
Compare
b14ff10
into
feat/prune-finalized-states
…st from the snapshot (#665) ## Motivation On the eth-3 followers (48h of import timings, 10-03 to 10-05), the head recompute at an epoch-start tick sometimes took 0.5-4 s, and the epoch's first block queued behind it: | network | head computes > 0.5 s | slot-0 block queue wait | |---|---|---| | Hoodi | 8 (7 in 2-4 s, 1 in 4-12 s) | up to 2.3 s | | Plataberget | 22 | up to 1.1 s | At that tick the justified checkpoint moves to (E-1, R), and the first head walk builds the justified-balances snapshot from `checkpoint_state((E-1, R))`. When that state has left the 32-entry state cache, the chain actor rebuilds it inline: a full snapshot decode (~0.3 s on Hoodi), plus a whole epoch transition when R's block is before the epoch's first slot (an empty first slot, or an epoch-start block orphaned by a late block). A replay of the logs through a model of the state cache puts the rebuilds at ~100 of ~410 Hoodi epoch ticks. Lighthouse never reads a state at that tick: `BeaconForkChoiceStore::on_verified_block` builds the justified balances at block import, for the block's epoch-boundary checkpoint, into a small cache keyed by (boundary root, epoch), from a state import already has. The tick only looks them up. Prysm keeps a per-checkpoint balances vector too, but builds it from the checkpoint block's un-advanced post-state, which is not what the specification's `checkpoint_states` holds; this PR follows Lighthouse. ## Changes This branch carries two open PRs it builds on, merged in with the resolutions `tmp/bci-...-gloas-live` already uses for gloas (minus #648's active-balance cache and #646's block-production operations, which are not on this branch): - #649, the justified-balances snapshot (`86b07388`) - #652, the epoch precompute and its `transition_block` (`b3017e4b`) The change itself is `6badb4f3`: 1. **A balances cache instead of one snapshot.** The store keeps up to 8 `JustifiedBalances` keyed by checkpoint. When full it evicts the lowest epoch first (ties: oldest insert), and never the current justified checkpoint. Not Lighthouse's plain 4-entry FIFO: on a network with many forks (Plataberget), several blocks crossing one boundary from different parents could flush the canonical entry within an epoch. 2. **Built at import.** - A block at its epoch's first slot caches the balances from its own post-state, under (epoch, block_root): that post-state is what `checkpoint_state` returns for it. - A block crossing from an earlier-epoch parent (empty first slot) caches them from the boundary state, under (epoch, parent_root), inside `transition_block`: from the precomputed state on a precompute hit, or by advancing the cloned parent to the epoch start once on a miss (each slot is still processed once). The boundary state depends only on the parent, so building it before the block is validated cannot cache a wrong value. - `justified_balances()` looks the current checkpoint up and keeps the lazy `checkpoint_state` path for a miss (a restart, or a boundary this node never imported). 3. **The gloas boost reads the snapshot.** `compute_node_weights` fetched the justified checkpoint state on every head walk, only for the gloas boost gate. `is_head_weak`, `is_parent_strong` and `should_apply_proposer_boost` now use the snapshot: the total active balance and vote weights were already there; the equivocator term in `is_head_weak` needs the raw `effective_balance` at the justified state, which the vote weights zero for slashed validators, so the snapshot gains an exact copy as a `u16` count of `EFFECTIVE_BALANCE_INCREMENT` per validator (asserted at build). Committees still come from the head/parent block states. The spec-literal `get_weight`, `gloas_get_weight` and `get_attestation_score` stay state-based as references. On a snapshot hit, no head walk (pre-gloas or gloas) reads the justified state. From review: 4. **The committee total leaves slashed validators out.** The snapshot's total, and the spec-literal `calculate_committee_fraction`, now sum active, unslashed validators only, as consensus-specs #5679 does. Fork choice weighs no vote from a slashed validator, so a boost or reorg threshold sized against their stake outweighs a larger share of the votes that count. #5679 first shipped in v1.7.0-beta.4 and the fixtures are pinned to beta.2, so `docs/spec_deviations.md` records it until the pin catches up; no beta.2 fork-choice fixture tells the two apart. Lighthouse already sums the same way. The raw `effective_balance` copy is unchanged, since `is_head_weak` still needs it for slashed equivocators. 5. **The timer precompute is bounded to a head in its own epoch.** The timer trigger checked only that the head was not past its slot. In a network-wide stall the sync tracker reports synced, so a timer epochs past a stalled head replayed every epoch in between, and again under a new key at each later epoch. A worker now crosses one epoch boundary at most, on either trigger. ## Cost - One in-order registry pass (~25 ms on Hoodi, `lean_beacon_justified_balances_build_seconds`) per epoch-boundary checkpoint, at import, on the importer, as in Lighthouse. - 10 bytes per validator per cached snapshot (8 for the vote weight, 2 for the new raw effective balance): ~12 MB per entry on Hoodi, so ~96 MB with all 8 entries filled. Worth reviewing whether 8 entries (vs 4) earn their memory. ## Testing - New: the cache's eviction policy (bound, lowest epoch, ties, protected justified, replace); a crossing import caches the boundary balances on both precompute paths with the block's state root still verifying; a first-slot import caches from its post-state and other slots cache nothing; a snapshot hit reads no justified state on every head path under both fork rules; a slashed equivocator still counts toward head weakness, matching the state-based reference; a slashed-but-active validator is left out of the proposer score and the snapshot total but keeps its raw balance; a timer whose epoch holds no block, or a stall several epochs long, starts no precompute. - `cargo test --profile release-fast --lib --bins` for storage, state-transition, blockchain and types: 1083 passed, 0 failed. - Beacon `fork_choice` fixtures, consensus-specs v1.7.0-beta.2, mainnet: 206 passed, 0 failed (before items 4 and 5; not re-run locally since, so CI's `test-beacon` jobs are the check). - `cargo clippy --workspace --all-targets -- -D warnings`: clean. ## Not covered - Live effect on the epoch-tick head recompute: worth re-measuring on a follower after deploy. - An `on_block` unit test of the first-slot hook needs a BLS-signed block; it is tested through `transition_block` and the cache helper instead. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
…on-gloas-live Brings in the justified-balances snapshot that the import-time balances build on. Resolved as tmp/bci-...-gloas-live resolved the same merge (df472fa): gloas's `is_head_weak`/`is_parent_strong` keep their own versions, `compute_node_weights` weighs votes from the snapshot, the snapshot iterates the registry through `iter_validators`, and the gloas `LatestMessage` fields are filled in the moved tests. The metrics hunks take lambdaclass#649's own placement, since lambdaclass#648 is not on this branch.
Motivation
Since the registry moved onto a persistent tree,
state.validator(i)is a tree descent instead of an array read. Two places inget_headpay one per validator:compute_weightsdoes a descent per latest vote, then a hash-order probe into the equivocator set and a SipHash map.get_proposer_scorecallsget_total_active_balance: an active-indexVec, then one descent per active index.At mainnet scale (estimate: about 1M voters and 1M active validators in a 2.4M registry) that is of the order of two million descents per
get_head. Theget_headregression after the tree PR was measured at 126 ms to 198 ms p50 in the import-replay benchmark; how much of it is these two loops was inferred from the code, not profiled. The import-replay benchmark was not run as part of this work, so this PR carries no timing claims.Changes
Three commits (plus merges from
main), so the PR can be split along the commit boundaries if preferred.perf(beacon): weigh fork-choice votes from a justified-balances snapshotJustifiedBalances(types crate): per-validator vote weight (0 if inactive or slashed) plus their total, built in one in-order pass overcheckpoint_state(justified)and cached inBeaconScratch, keyed by its own checkpoint.compute_weightsandget_proposer_scoreread it.calculate_committee_fractionis split into a purecommittee_fraction(total, percent). Metricslean_beacon_justified_balances_lookups_total{result}andlean_beacon_justified_balances_build_seconds; docs indocs/metrics.mdanddocs/beacon_stf.md.perf(beacon): keep latest messages dense, indexed by validatorBeaconScratch.latest_messagesbecomesVec<Option<LatestMessage>>, so the vote loop walks votes and snapshot balances in index order and skips the equivocator probe while the set is empty. No API change.fix(beacon): leave slashed validators out of the committee-fraction totalvote_balance), so the snapshot's total is the sum of its balances;JustifiedBalances::total_active_balanceis renamedtotal_balance.calculate_committee_fractionwalks the registry once withiter_validators()and no longer returnsResult.Tests:
get_weightstays spec-literal as the oracle. The weights-vs-spec unit test now includes slashed, not-yet-active, exited and past-the-registry voters while a boost is set.build_justified_balancesedge cases (activation and exit at the epoch, slashed-but-active, all-inactive floor, index past the end); a new justified checkpoint rebuilds the snapshot;get_proposer_scoreleaves out a slashed-but-active validator (and agrees withcalculate_committee_fractionon the state); dense storage table (index order, gaps, overwrite, equivocators).fork_choicefixture runner now asserts, at everychecksstep, thatcompute_weightsequalsget_weightfor every root of the filtered block tree. No fixture checks weights directly, so this puts the snapshot against the spec path on every case. It skips a root only whereget_weightitself errors because invalidation removed the voted block (thesync/*/optimistic/from_syncing_to_invalidcases), sincecompute_weightsdrops those votes by design. It is the only edit to the runner.Design decisions
saturating_add). This is consensus-specs #5679 (v1.7.0-beta.4), ahead of the v1.7.0-beta.2 fixture pin, whosecalculate_committee_fractionstill usesget_total_active_balance; Lighthouse already excluded slashed stake (Lighthouse issue 10057). The state transition'sget_total_active_balanceis unchanged.Box<[Gwei]>, notu16increments (avoids an invariant to assert).BeaconScratchfield, not a new module.checkpoint_state(justified). A later post-state of the same epoch is not exact (slashedcan change mid-epoch). Equivocations stay out of the snapshot and are filtered per vote.is_head_weak/is_parent_strongcalculate_committee_fraction(checkpoint_state(justified)). That is the same state the snapshot is built from, and both totals usevote_balance, so they cannot disagree.debug_assert!that an index is below a plausible bound before the table grows; the table has one slot per index up to the highest seen, so only validated attestation indices may reach it. Release builds do not check.BeaconScratchlock on this path: nocrates/netcode calls the snapshot or vote accessors.Deviation from the original sketch: the builder does not
debug_assertthat the state's epoch equals the checkpoint's epoch. Two existing unit-test stores pair an epoch-0 checkpoint with a state one epoch in, and activity is evaluated at the state's own epoch exactly asget_weightdoes, so correctness does not depend on it.Not in this PR
get_headmax at epoch-start blocks after this PR.update_latest_messagesand a root-prefix hasher for the weights map.Open question: the replay harness never ticks, so the proposer boost never resets there and every replayed
get_headpays the boost path. Should it tick? Out of scope here.Testing
Run with
--profile release-fast; spec fixtures from the existing local copy.cargo clippy --locked --workspace --all-targets -- -D warningscargo clippy -p ethlambda-state-transition --all-targets --features beacon-spec-tests -- -D warningscargo test -p ethlambda-types --libcargo test -p ethlambda-storage --libcargo test -p ethlambda-state-transition --libgossip/*/matched_fixture_casesfail only because the gossip fixture tree is empty in this environmentcargo check -p ethlambdaAfter the review fix (
ec477bc1):make lintcargo test -p ethlambda-types -p ethlambda-state-transition -p ethlambda-storage --libfork_choice(v1.7.0-beta.2)get_head/proposer_boost_excludes_slashed_validators, copied into the local tree (altair to gloas)test-beacon-mainnetcovers it