diff --git a/Cargo.lock b/Cargo.lock index 0bec6c9e0..7c867b902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2190,6 +2190,7 @@ dependencies = [ "ethlambda-storage", "ethlambda-test-fixtures", "ethlambda-types", + "foldhash 0.2.0", "hex", "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", diff --git a/crates/blockchain/state_transition/Cargo.toml b/crates/blockchain/state_transition/Cargo.toml index 7b819c538..f921f3dc2 100644 --- a/crates/blockchain/state_transition/Cargo.toml +++ b/crates/blockchain/state_transition/Cargo.toml @@ -87,6 +87,11 @@ c-kzg = { version = "2.1.8", features = ["ethereum_kzg_settings"] } # functions that c-kzg does not export. num-bigint = "0.4.6" +# The fork-choice vote tally hashes once per validator into a map of a few dozen +# keys; std's SipHash was one of the largest costs in head computation. Already +# in the lockfile through other crates, so this adds no new version. +foldhash = "0.2.0" + [dev-dependencies] ethlambda-test-fixtures.workspace = true serde.workspace = true diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index ac83d85fe..b560f01f2 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -2291,7 +2291,13 @@ pub fn compute_node_weights( // reads the store's own scratch may run inside the closure: see // `for_each_non_equivocating_latest_message` for why asking it per voter // from in here would deadlock, and a payload link is such a read. - let mut votes: HashMap<(Root, Slot, bool), Gwei> = HashMap::new(); + // + // One hash per validator into a map of a few dozen keys, so the hasher is + // the cost. foldhash's `RandomState` is seeded per process, so a peer + // cannot precompute vote keys that collide; the sums are saturating adds, + // so the order the map is drained in below does not matter. + let mut votes: HashMap<(Root, Slot, bool), Gwei, foldhash::fast::RandomState> = + HashMap::default(); store.for_each_non_equivocating_latest_message(|validator_index, message| { // An index past the snapshot is a validator that did not exist at the // justified checkpoint, and an inactive or slashed one is zero in it: @@ -10624,4 +10630,46 @@ mod tests { ); } } + + /// Prints the time to tally a mainnet-sized vote set (one hash per + /// validator, few distinct keys) under std's hasher and under the one + /// `compute_node_weights` uses. Run with `--ignored --nocapture`. + #[test] + #[ignore = "benchmark: prints timings"] + fn bench_vote_tally_hashers() { + use std::hash::BuildHasher; + use std::hint::black_box; + use std::time::Instant; + + const VOTES: usize = 1_000_000; + const KEYS: usize = 64; + let keys: Vec<(Root, Slot, bool)> = (0..KEYS) + .map(|i| (Root::repeat_byte(i as u8), 1000 + i as u64, i % 2 == 0)) + .collect(); + + fn tally(keys: &[(Root, Slot, bool)]) -> usize { + let mut votes: HashMap<(Root, Slot, bool), Gwei, S> = HashMap::default(); + for i in 0..VOTES { + let entry = votes.entry(keys[(i * 7) % KEYS]).or_default(); + *entry = entry.saturating_add(32); + } + votes.len() + } + + for _ in 0..6 { + let start = Instant::now(); + let std_len = black_box(tally::(black_box( + &keys, + ))); + let std_time = start.elapsed(); + let start = Instant::now(); + let fast_len = black_box(tally::(black_box(&keys))); + let fast_time = start.elapsed(); + assert_eq!(std_len, fast_len); + println!( + "{VOTES} votes over {KEYS} keys: std {std_time:?}, foldhash {fast_time:?}, ratio {:.1}", + std_time.as_secs_f64() / fast_time.as_secs_f64() + ); + } + } }