Repository navigation
perf(beacon): cache fork-choice vote weights in a node array - #775
Draft
MegaRedHand wants to merge 2 commits into
Draft
MegaRedHand wants to merge 2 commits into
MegaRedHand wants to merge 2 commits into
Conversation
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.
2 of 3 tasks
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.
MegaRedHand
added a commit
that referenced
this pull request
Nov 5, 2026
…ches On mainnet a block (c2302e2b, slot 15587649) took 826 ms to import, 766 ms of it queued in the chain actor's mailbox behind the slot tick, whose FCR run took 1-2 s. The rule runs on the actor, so it has to cost about what the head computation does. It now reads what fork choice already keeps: - get_current_target_score skips latest messages from other epochs, which can never vote for the current target. Resolving their checkpoints walked roots below the live chain index, decoding a block per parent hop; that walk was the >1 s tail. - Balance sources come from the store's per-checkpoint JustifiedBalances, which now also carry the active-but-slashed validators and the total active balance, instead of a registry pass per source per run. The pulled-up head reads the current target's entry unless the head state shows a slashing processed in the current epoch, and then builds from the pulled-up state as before: activity and effective balances are fixed within an epoch, and slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] starts every epoch at zero. - Attestation scores are the proto-array's PENDING subtree totals (#775) when the source is the justified checkpoint's, which it is on every run but an epoch's first; the remaining fold hashes roots with foldhash. - Committee weights are memoized per (source, slot), and a range across an epoch boundary reuses the later part's members, shared by every range ending at the same slot; equivocators are a bitmap. At 1M validators (the ignored fast_confirmation_timing bench), per run: mid-epoch ~27 ms -> ~0.5 ms (get_head: ~80 us); an epoch's first slot 113 ms -> 14.7 ms (get_head: 12.9 ms); its second and third ~40 ms -> 2.3-2.8 ms. New tests compare the cached scores with the fold, pre-gloas and under gloas; the filtered target score with the unfiltered sum; and the reused pulled-up balances with the pulled-up state's, with and without a slashing in the epoch. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
5 of 6 tasks
MegaRedHand
added a commit
that referenced
this pull request
Nov 5, 2026
…ches On mainnet a block (c2302e2b, slot 15587649) took 826 ms to import, 766 ms of it queued in the chain actor's mailbox behind the slot tick, whose FCR run took 1-2 s. The rule runs on the actor, so it has to cost about what the head computation does. It now reads what fork choice already keeps: - get_current_target_score skips latest messages from other epochs, which can never vote for the current target. Resolving their checkpoints walked roots below the live chain index, decoding a block per parent hop; that walk was the >1 s tail. - Balance sources come from the store's per-checkpoint JustifiedBalances, which now also carry the active-but-slashed validators and the total active balance, instead of a registry pass per source per run. The pulled-up head reads the current target's entry unless the head state shows a slashing processed in the current epoch, and then builds from the pulled-up state as before: activity and effective balances are fixed within an epoch, and slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] starts every epoch at zero. - Attestation scores are the proto-array's PENDING subtree totals (#775) when the source is the justified checkpoint's, which it is on every run but an epoch's first; the remaining fold hashes roots with foldhash. - Committee weights are memoized per (source, slot), and a range across an epoch boundary reuses the later part's members, shared by every range ending at the same slot; equivocators are a bitmap. At 1M validators (the ignored fast_confirmation_timing bench), per run: mid-epoch ~27 ms -> ~0.5 ms (get_head: ~80 us); an epoch's first slot 113 ms -> 14.7 ms (get_head: 12.9 ms); its second and third ~40 ms -> 2.3-2.8 ms. New tests compare the cached scores with the fold, pre-gloas and under gloas; the filtered target score with the unfiltered sum; and the reused pulled-up balances with the pulled-up state's, with and without a slashing in the epoch. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
MegaRedHand
added a commit
that referenced
this pull request
Nov 5, 2026
…ash (#749): run off the fork choice's caches On mainnet a block (c2302e2b, slot 15587649) took 826 ms to import, 766 ms of it queued in the chain actor's mailbox behind the slot tick, whose FCR run took 1-2 s. The rule runs on the actor, so it has to cost about what the head computation does. It now reads what fork choice already keeps: - get_current_target_score skips latest messages from other epochs, which can never vote for the current target. Resolving their checkpoints walked roots below the live chain index, decoding a block per parent hop; that walk was the >1 s tail. - Balance sources come from the store's per-checkpoint JustifiedBalances, which now also carry the active-but-slashed validators and the total active balance, instead of a registry pass per source per run. The pulled-up head reads the current target's entry unless the head state shows a slashing processed in the current epoch, and then builds from the pulled-up state as before: activity and effective balances are fixed within an epoch, and slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] starts every epoch at zero. - Attestation scores are the proto-array's PENDING subtree totals (#775) when the source is the justified checkpoint's, which it is on every run but an epoch's first; the remaining fold hashes roots with foldhash. - Committee weights are memoized per (source, slot), and a range across an epoch boundary reuses the later part's members, shared by every range ending at the same slot; equivocators are a bitmap. At 1M validators (the ignored fast_confirmation_timing bench), per run: mid-epoch ~27 ms -> ~0.5 ms (get_head: ~80 us); an epoch's first slot 113 ms -> 14.7 ms (get_head: 12.9 ms); its second and third ~40 ms -> 2.3-2.8 ms. New tests compare the cached scores with the fold, pre-gloas and under gloas; the filtered target score with the unfiltered sum; and the reused pulled-up balances with the pulled-up state's, with and without a slashing in the epoch. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
MegaRedHand
added a commit
that referenced
this pull request
Nov 5, 2026
…740): review fixes - fix(storage): reserve the vote table once per attestation, not per index. An aggregate lists its attesters in ascending order, so growing the table one index at a time copied it whenever an index passed the headroom: hundreds of copies of a registry-sized table for one aggregate spanning it. Store::reserve_latest_messages makes room up to the highest index first. - refactor(storage): check the vote table's capacity arithmetic (checked_add), and say why the resize after it cannot reallocate. - test(storage): assert the growth contract (index plus headroom) rather than a ratio, name the test after it, and check that a reserved batch of ascending votes never moves the table. On nightly the table has a second writer, #775's Store::record_latest_messages, which records a whole attestation under one lock and is the only one on the production path. It reserves for the batch's highest index itself, under that lock, so #740's call from fork_choice::update_latest_messages is not needed here; both writers share the slot and reservation helpers. Updates nightly's #740 from fix/storage-latest-messages-exact-growth at 83aabfc to be37485 (the branch head, bfb4697, adds only a merge of main). Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🗒️ Description / Motivation
get_headcosts ~17-18 ms per imported block on the mainnet follower (12.4 ms with #767's faster hasher), and a live import pays it twice for the first timely block of a slot:on_block's proposer-boost gate runswalk_head, then the chain actor runsget_head. Most of it is the vote tally incompute_node_weights: every call walks every validator's latest message (~1M on mainnet) and hashes each into a map.This keeps the vote weights cached in a node array instead, updated when a batch of votes arrives, so a head computation no longer touches the validator set.
What Changed
crates/storage/src/proto_array.rs(new): the cache. It uses Lighthouseunstable's proto-array layout: one node per block, carrying itsPENDING,EMPTYandFULLweights (ownvotes and cached subtreetotals). Each validator's counted vote is remembered by node index.ownweights and marks the array dirty. The next read refreshes everytotalin one reverse pass over the blocks.best_child/best_descendantpointers. Under gloas the choice between children moves without any vote: the previous slot'sEMPTY/FULLweigh 0, the PTC tiebreaker, aFULLbranch appearing when its envelope verifies, and boost gating. That is why Lighthouse stopped maintaining them too, and the walk from the justified node is cheap.Store(crates/storage/src/store.rs):BeaconScratch, under the lock that guardslatest_messages, so a vote and its weight change are one step.record_latest_messages: one attestation is one batch, under one lock. It replaces the per-attester loop that took the lock three times per attester.insert_fork_choice_node, whichon_blockcalls just beforeinsert_signed_block.proto_array_weights: the read.set_latest_message,insert_equivocating_index,prune_live_chainanddelete_live_chain_entries.fork_choice.rs:walk_headreadscached_node_weightsinstead ofcompute_node_weights.add_proposer_boost, which both paths share. It depends on the clock, as do gloas's previous-slot rule and the walk bound, so all three are applied at read time and never cached.update_latest_messagesmakes onerecord_latest_messagescall.lean_beacon_proto_array_rebuilds_total{reason}andlean_beacon_proto_array_rebuild_seconds(documented indocs/metrics.md).foldhashis now a dependency ofethlambda-storage, for the root lookups. It was already in the lockfile, so no new version is pulled in.Correctness / Behavior Guarantees
Same weights as before. For every node at or above the walk bound,
cached_node_weightsequalscompute_node_weightson the same store.compute_node_weightsstays, public, as the reference, likecompute_headandgloas_get_head.Staleness. Whatever the cache cannot follow incrementally marks it stale. The next read then rebuilds it from the latest messages, at the old tally's cost. That covers:
LiveChainwithout its node.On a live node that is about one rebuild per epoch, on the first head computation after the justified checkpoint or finality moves, which can land inside a block import.
Race-safe rebuilds. A rebuild scans
LiveChainitself under the lock rather than trusting the caller's index. An API read that scanned before a removal therefore cannot rebuild the removed blocks back in.Rule set. The cache records the rule set it was built under. Pre-gloas rules read every block as a single full node, as the tally does.
Underflow. A weight that would go below zero (only a bug reaches that) saturates, marks the cache stale and logs a warning; the next read repairs it.
No behavior change in what fork choice selects.
Benchmarks
Local micro-benchmark,
cached_weights_timing(ignored test): 1M validators, 73 blocks, on a laptop under load, so the tally varied 27 to 60 ms between runs.compute_node_weights, the old path)record_latest_messages, at vote arrival)The eth-5 import replay has not been run yet; numbers will follow in a comment.
Tests Added / Run
Differential testing against
compute_node_weights:proto_array::tests::cached_weights_match_a_fresh_tally). After every operation of a random sequence the cached weights equal a naive tally, and the incrementally kept array equals a fresh rebuild node by node. Operations: block insertions, vote batches, equivocations, balance swaps, rule switches, stale marks.the_bottom_up_walk_matches_the_spec_literal_references_on_random_trees): cached weights equal the tally at every step of every seed.the_cached_weights_follow_votes_blocks_and_equivocations. Feeds votes, blocks and equivocations throughupdate_latest_messages,on_block's insertion order andinsert_equivocating_index, under both rule sets. After each step it checks the incremental cache against a rebuild and against the tally, and the head against the spec-literal reference.check_head_against_referencenow also compares cached weights with the tally at everyheadcheck of every fork-choice fixture.Run:
cargo test --workspace --profile release-fast --lib --binsmake test-beacon(v1.7.0-beta.2 fixtures, both presets):beacon-spec-tests335;Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake testequivalent:cargo test --workspace --profile release-fast --lib --binsplusmake test-beacon(the leanSpec spec suites were not run)