Repository navigation
perf(beacon): hash fork-choice votes with a seeded fast hasher - #767
Closed
MegaRedHand wants to merge 2 commits into
Closed
MegaRedHand wants to merge 2 commits into
MegaRedHand wants to merge 2 commits into
Conversation
A flat profile of the offline mainnet block-import replay (~2.4M validators, ~1M latest messages) shows std's SipHash among the largest costs of the head computation: RandomState::hash_one over &(H256, u64, bool) plus DefaultHasher::write came to ~5100 samples at 4999 Hz over one ~60-block replay, on the order of 15 ms per get_head call, against ~17 ms per block for the whole get_head phase. The only map with that key is the vote tally in compute_node_weights, which hashes once per validator into a map of a few dozen keys. Use foldhash's fast RandomState there: still seeded per process, so a peer cannot precompute colliding vote keys. The tally is a saturating sum, so the order the map is drained in does not change the weights. foldhash 0.2.0 is already in the lockfile through other crates. An ignored micro-benchmark compares the two hashers on 1M synthetic votes over 64 keys.
3 tasks done
Collaborator
Author
|
Superseded by #775, which caches the fork-choice vote weights in a node array, so the head computation no longer runs the per-validator tally this PR sped up. |
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 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. A flat perf profile of the offline import replay put most of it in std's SipHash:RandomState::hash_one::<&(H256, u64, bool)>plusDefaultHasher::write, ~5100 samples at 4999 Hz over one ~60-block replay.The only map with that key is the vote tally in
compute_node_weights. It hashes once per latest message, ~1M times per call on mainnet, into a map that holds a few dozen distinct keys, so the hasher is the cost.What Changed
fork_choice.rs,compute_node_weights:votesis nowHashMap<(Root, Slot, bool), Gwei, foldhash::fast::RandomState>.crates/blockchain/state_transition/Cargo.toml:foldhash = "0.2.0", already inCargo.lockthrough other crates, so no new version is pulled in.bench_vote_tally_hashers, compares the two hashers on 1M synthetic votes over 64 keys.Correctness / Behavior Guarantees
foldhash::fast::RandomStateis seeded per process, so a peer cannot precompute vote keys that collide. The keys are also bounded by known blocks, since a vote only lands inlatest_messagesfor a block in the store.weightswith saturating adds, so the map's iteration order does not change the weights.walk_headtouches (weights,children_of,index) are hashed per block or per distinct vote key, not per validator, so they are left alone.Benchmarks
Offline
ethlambda benchmark import replayon ethlambda-5 (Ryzen 9700X), three mainnet corpora,--warm-committee-sums, 2 rounds, steady state n=164, paired per block, onnightly(2b51a33 vs the same plus this commit):get_headMicro-benchmark (1M votes, 64 keys): std ~25-48 ms, foldhash ~14-18 ms.
A structural follow-up would be Lighthouse's proto-array vote deltas: compare each validator's old and new vote by node index, with no hashing per validator.
Tests Added / Run
cargo test --profile release-fast -p ethlambda-state-transition --lib: 631 passedfork_choice/206 passed (mainnet preset)sanity/595,cargo check -p ethlambda-blockchainRelated Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test— not run; the crate lib tests and spec filters above were run instead