Skip to content

perf(beacon): hash fork-choice votes with a seeded fast hasher - #767

Closed
MegaRedHand wants to merge 2 commits into
mainfrom
perf/fork-choice-vote-hasher
Closed

MegaRedHand wants to merge 2 commits into
mainfrom
perf/fork-choice-vote-hasher

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

get_head costs ~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)> plus DefaultHasher::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: votes is now HashMap<(Root, Slot, bool), Gwei, foldhash::fast::RandomState>.
  • crates/blockchain/state_transition/Cargo.toml: foldhash = "0.2.0", already in Cargo.lock through other crates, so no new version is pulled in.
  • An ignored micro-benchmark, bench_vote_tally_hashers, compares the two hashers on 1M synthetic votes over 64 keys.

Correctness / Behavior Guarantees

  • foldhash::fast::RandomState is 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 in latest_messages for a block in the store.
  • The tally is drained into weights with saturating adds, so the map's iteration order does not change the weights.
  • The other maps walk_head touches (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 replay on ethlambda-5 (Ryzen 9700X), three mainnet corpora, --warm-committee-sums, 2 rounds, steady state n=164, paired per block, on nightly (2b51a33 vs the same plus this commit):

before after change 95% CI
get_head 18.0 ms 12.4 ms -5.6 ms (-31.2%) [-5.7, -5.5]
wall (import + fork choice) 42.4 ms 36.9 ms -5.5 ms (-13.0%) [-5.7, -5.3]
STF 19.2 ms 19.3 ms +0.1 ms [-0.0, +0.2]

Micro-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 passed
  • Beacon spec fixtures: fork_choice/ 206 passed (mainnet preset)
  • On the nightly version of this commit also: sanity/ 595, cargo check -p ethlambda-blockchain

Related Issues / PRs

✅ 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

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.
@MegaRedHand MegaRedHand added performance Performance improvements or possible performance improvements beacon Ethereum Beacon Chain client labels Nov 3, 2026
@MegaRedHand

Copy link
Copy Markdown
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 MegaRedHand closed this Nov 4, 2026
MegaRedHand added a commit that referenced this pull request Nov 4, 2026
…r" (#767)

Reverts nightly's a20c52e. #767 is closed in favor of #775, which takes the
per-validator vote tally off the head computation's path: the head walk reads
cached vote weights, and compute_node_weights, the tally #767 sped up, stays only
as the reference the tests compare against.
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.
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