From 83aabfcb6e87e67169250a1d4dde859f37e60806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:00:38 -0300 Subject: [PATCH 1/7] fix(storage): grow the beacon vote table exactly instead of doubling Store::latest_messages is a dense table indexed by validator index, and set_latest_message grew it with resize, which at least doubles the capacity when an index lands past it. A registry that keeps gaining validators crosses that edge soon after the table is first sized, so on the mainnet follower the table had room for about twice the registry and roughly half of its 256 MB was never used. Growth now reserves the new index plus a fixed headroom, so a registry that gains a few validators an epoch still reallocates rarely, but the table stays within the headroom of the registry's size. That frees about 120 MB on the mainnet follower. --- crates/storage/src/store.rs | 51 ++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 14ac9116f..ca6e54636 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -680,6 +680,11 @@ impl GossipSignatureBuffer { /// per index up to the highest seen. const MAX_PLAUSIBLE_VALIDATOR_INDEX: u64 = 1 << 26; +/// Spare entries [`Store::set_latest_message`] reserves past the index that +/// outgrew the dense vote table, so a registry growing by a few validators an +/// epoch reallocates the table rarely rather than on every new index. +const LATEST_MESSAGES_HEADROOM: usize = 1 << 12; + /// How many epoch-boundary [`JustifiedBalances`] snapshots the store keeps. const JUSTIFIED_BALANCES_CAPACITY: usize = 8; @@ -4275,10 +4280,19 @@ impl Store { ); let slot = usize::try_from(index).expect("validator index fits in usize"); let mut beacon = self.beacon.lock().unwrap(); - if beacon.latest_messages.len() <= slot { - beacon.latest_messages.resize(slot + 1, None); + let messages = &mut beacon.latest_messages; + if messages.len() <= slot { + // Exact growth plus headroom rather than `resize`'s amortized + // doubling: attestations name validators in no particular order, + // so the table is first sized by one high index and then doubled + // by a slightly higher one, leaving about half of a + // registry-sized table unused for the life of the process. + if messages.capacity() <= slot { + messages.reserve_exact(slot + 1 + LATEST_MESSAGES_HEADROOM - messages.len()); + } + messages.resize(slot + 1, None); } - beacon.latest_messages[slot] = Some(message); + messages[slot] = Some(message); } /// Gloas: the payload timeliness committee's per-member votes on whether @@ -9115,6 +9129,37 @@ mod tests { assert_eq!(store.latest_message(u64::MAX), None); } + #[test] + fn the_latest_message_table_grows_exactly_rather_than_doubling() { + let mut store = Store::test_store(); + let message = LatestMessage { + epoch: 1, + slot: SLOTS_PER_EPOCH, + root: H256::from([1u8; 32]), + payload_present: false, + }; + let capacity = |store: &Store| store.beacon.lock().unwrap().latest_messages.capacity(); + + // The first vote sizes the table, and a higher index inside the + // headroom fits without a reallocation. + let first = 100_000; + store.set_latest_message(first, message); + let sized = capacity(&store); + assert!(sized > usize::try_from(first).unwrap()); + assert!(sized <= usize::try_from(first).unwrap() + 1 + LATEST_MESSAGES_HEADROOM); + store.set_latest_message(first + 10, message); + assert_eq!(capacity(&store), sized); + + // Past the headroom, the table grows to the new index plus headroom, + // not to twice what it was. + let higher = first + u64::try_from(LATEST_MESSAGES_HEADROOM).unwrap() + 1_000; + store.set_latest_message(higher, message); + let grown = capacity(&store); + assert!(grown <= usize::try_from(higher).unwrap() + 1 + LATEST_MESSAGES_HEADROOM); + assert!(grown < 2 * sized); + assert_eq!(store.latest_message(higher).map(|vote| vote.epoch), Some(1)); + } + #[test] fn a_pow_block_is_looked_up_by_its_own_hash() { let mut store = Store::test_store(); From 451b9ca046db0f298c6e75a9e908a467731dfdbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:04 -0300 Subject: [PATCH 2/7] refactor(storage): check the vote table's capacity arithmetic The sum was safe only because a debug assertion bounds the index, so the line could not be read as correct on its own. Name the target and check the addition. --- crates/storage/src/store.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index ca6e54636..815b613b8 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -4288,7 +4288,10 @@ impl Store { // by a slightly higher one, leaving about half of a // registry-sized table unused for the life of the process. if messages.capacity() <= slot { - messages.reserve_exact(slot + 1 + LATEST_MESSAGES_HEADROOM - messages.len()); + let target = slot + .checked_add(1 + LATEST_MESSAGES_HEADROOM) + .expect("validator index plus headroom fits in usize"); + messages.reserve_exact(target - messages.len()); } messages.resize(slot + 1, None); } From 3a49d1073e12e6d865ffd4e51f089690e8411f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:13 -0300 Subject: [PATCH 3/7] docs(storage): say why the vote table's resize cannot reallocate The reserve above it is what makes the growth exact; without the note, the resize reads as if it could still double the table. --- crates/storage/src/store.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 815b613b8..d7a5f4a9a 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -4293,6 +4293,7 @@ impl Store { .expect("validator index plus headroom fits in usize"); messages.reserve_exact(target - messages.len()); } + // Capacity now exceeds `slot`, so this cannot reallocate. messages.resize(slot + 1, None); } messages[slot] = Some(message); From 275ef362d09c0ec804a9964c5403230a3e1a8806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:21 -0300 Subject: [PATCH 4/7] test(storage): name the vote-table test after what reserve_exact promises reserve_exact guarantees at least the requested capacity, not exactly it, so the old name claimed more than the code does. --- crates/storage/src/store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index d7a5f4a9a..a8da6cf35 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -9134,7 +9134,7 @@ mod tests { } #[test] - fn the_latest_message_table_grows_exactly_rather_than_doubling() { + fn the_latest_message_table_grows_with_headroom_rather_than_doubling() { let mut store = Store::test_store(); let message = LatestMessage { epoch: 1, From 276cf37beef67dd6abed410fd26f3f27c11468be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:46 -0300 Subject: [PATCH 5/7] test(storage): assert the vote table's growth contract, not a ratio `grown < 2 * sized` held only because of the indices the test picked. Check what the code promises instead: the highest index inside the headroom fits without a reallocation, and growth past it lands at the new index plus the headroom, as an upper bound. --- crates/storage/src/store.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index a8da6cf35..0d6a9d13c 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -9144,23 +9144,26 @@ mod tests { }; let capacity = |store: &Store| store.beacon.lock().unwrap().latest_messages.capacity(); - // The first vote sizes the table, and a higher index inside the - // headroom fits without a reallocation. + let headroom = u64::try_from(LATEST_MESSAGES_HEADROOM).unwrap(); + let slot = |index: u64| usize::try_from(index).unwrap(); + + // The first vote sizes the table to its index plus the headroom. let first = 100_000; store.set_latest_message(first, message); let sized = capacity(&store); - assert!(sized > usize::try_from(first).unwrap()); - assert!(sized <= usize::try_from(first).unwrap() + 1 + LATEST_MESSAGES_HEADROOM); - store.set_latest_message(first + 10, message); + assert!(sized > slot(first)); + assert!(sized <= slot(first) + 1 + LATEST_MESSAGES_HEADROOM); + + // The highest index inside the headroom fits without a reallocation. + store.set_latest_message(first + headroom, message); assert_eq!(capacity(&store), sized); - // Past the headroom, the table grows to the new index plus headroom, - // not to twice what it was. - let higher = first + u64::try_from(LATEST_MESSAGES_HEADROOM).unwrap() + 1_000; + // Past the headroom, the table grows to the new index plus headroom. + let higher = first + headroom + 1_000; store.set_latest_message(higher, message); let grown = capacity(&store); - assert!(grown <= usize::try_from(higher).unwrap() + 1 + LATEST_MESSAGES_HEADROOM); - assert!(grown < 2 * sized); + assert!(grown > slot(higher)); + assert!(grown <= slot(higher) + 1 + LATEST_MESSAGES_HEADROOM); assert_eq!(store.latest_message(higher).map(|vote| vote.epoch), Some(1)); } From 56faefb74c8f12e333ce3ede2ee5f2e3b17db937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:54 -0300 Subject: [PATCH 6/7] docs(storage): attestations reference validators, they don't name them --- crates/storage/src/store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 0d6a9d13c..8badb3d33 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -4283,7 +4283,7 @@ impl Store { let messages = &mut beacon.latest_messages; if messages.len() <= slot { // Exact growth plus headroom rather than `resize`'s amortized - // doubling: attestations name validators in no particular order, + // doubling: attestations reference validators in no particular order, // so the table is first sized by one high index and then doubled // by a slightly higher one, leaving about half of a // registry-sized table unused for the life of the process. From be37485387061d48cdb49bb862785871a770c94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:23:02 -0300 Subject: [PATCH 7/7] fix(storage): reserve the vote table once per attestation, not per index An indexed attestation's attesters are sorted ascending. With exact growth, each index past the headroom reallocated and copied the whole table, so the first aggregate spanning the registry after a start copied a registry-sized table hundreds of times while holding the beacon lock. update_latest_messages now reserves room for the batch's highest index first, so the batch reallocates once at most. Indices are taken by max, not by position, so an unsorted caller is still covered. --- .../src/beacon/fork_choice.rs | 6 ++ crates/storage/src/store.rs | 102 +++++++++++++++--- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 02c959376..c1b2511dd 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -4693,6 +4693,12 @@ pub fn update_latest_messages( let beacon_block_root = data.beacon_block_root; let payload_present = rules == ForkRules::Gloas && data.index == 1; + // One reservation for the whole batch: an indexed attestation's indices + // are sorted, and growing the table one ascending index at a time would + // copy it many times over. + if let Some(&highest) = attesting_indices.iter().max() { + store.reserve_latest_messages(highest); + } for &index in attesting_indices { if store.is_equivocating(index) { continue; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 8badb3d33..b6c776018 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -685,6 +685,37 @@ const MAX_PLAUSIBLE_VALIDATOR_INDEX: u64 = 1 << 26; /// epoch reallocates the table rarely rather than on every new index. const LATEST_MESSAGES_HEADROOM: usize = 1 << 12; +/// The dense vote table's slot for validator `index`. +/// +/// The assertion is the tripwire for an index that did not come from a +/// validated attestation; see [`Store::set_latest_message`]. +fn latest_message_slot(index: u64) -> usize { + debug_assert!( + index < MAX_PLAUSIBLE_VALIDATOR_INDEX, + "validator index {index} would grow the dense vote table far past any registry" + ); + usize::try_from(index).expect("validator index fits in usize") +} + +/// Grows the vote table's capacity to hold `slot`, plus +/// [`LATEST_MESSAGES_HEADROOM`] spare entries; does nothing when it already +/// holds `slot`. +/// +/// Exact growth plus headroom rather than `resize`'s amortized doubling: +/// successive attestations reference validators in no particular order, so the +/// table is first sized by one high index and then doubled by a slightly higher one, +/// leaving about half of a registry-sized table unused for the life of the +/// process. +fn reserve_latest_message_slot(messages: &mut Vec>, slot: usize) { + if messages.capacity() <= slot { + let target = slot + .checked_add(1 + LATEST_MESSAGES_HEADROOM) + .expect("validator index plus headroom fits in usize"); + // `len <= capacity <= slot < target`, so this cannot underflow. + messages.reserve_exact(target - messages.len()); + } +} + /// How many epoch-boundary [`JustifiedBalances`] snapshots the store keeps. const JUSTIFIED_BALANCES_CAPACITY: usize = 8; @@ -4273,32 +4304,35 @@ impl Store { /// attestation (a member of a committee of the registry), never from raw /// input: a wild index would allocate a table to match. The assertion is /// the tripwire for that in debug builds and fixtures. + /// + /// A caller recording a batch should call + /// [`Store::reserve_latest_messages`] first. pub fn set_latest_message(&mut self, index: u64, message: LatestMessage) { - debug_assert!( - index < MAX_PLAUSIBLE_VALIDATOR_INDEX, - "validator index {index} would grow the dense vote table far past any registry" - ); - let slot = usize::try_from(index).expect("validator index fits in usize"); + let slot = latest_message_slot(index); let mut beacon = self.beacon.lock().unwrap(); let messages = &mut beacon.latest_messages; if messages.len() <= slot { - // Exact growth plus headroom rather than `resize`'s amortized - // doubling: attestations reference validators in no particular order, - // so the table is first sized by one high index and then doubled - // by a slightly higher one, leaving about half of a - // registry-sized table unused for the life of the process. - if messages.capacity() <= slot { - let target = slot - .checked_add(1 + LATEST_MESSAGES_HEADROOM) - .expect("validator index plus headroom fits in usize"); - messages.reserve_exact(target - messages.len()); - } + reserve_latest_message_slot(messages, slot); // Capacity now exceeds `slot`, so this cannot reallocate. messages.resize(slot + 1, None); } messages[slot] = Some(message); } + /// Makes room in the vote table for every index up to `highest`, so the + /// batch of [`Store::set_latest_message`] calls that follows reallocates + /// the table once at most. + /// + /// An aggregate lists its attesters in ascending index order. Grown one + /// index at a time, the table would be copied whenever an index passed the + /// headroom: hundreds of copies of a table sized to the registry, for a + /// single aggregate that spans it. + pub fn reserve_latest_messages(&mut self, highest: u64) { + let slot = latest_message_slot(highest); + let mut beacon = self.beacon.lock().unwrap(); + reserve_latest_message_slot(&mut beacon.latest_messages, slot); + } + /// Gloas: the payload timeliness committee's per-member votes on whether /// `root`'s payload was timely, one entry per PTC member and `None` for a /// member that has not voted yet. `None` when `root` has no vote vector at @@ -9167,6 +9201,42 @@ mod tests { assert_eq!(store.latest_message(higher).map(|vote| vote.epoch), Some(1)); } + #[test] + fn a_reserved_batch_of_ascending_votes_never_moves_the_table() { + let mut store = Store::test_store(); + let message = LatestMessage { + epoch: 1, + slot: SLOTS_PER_EPOCH, + root: H256::from([1u8; 32]), + payload_present: false, + }; + let table = |store: &Store| { + let beacon = store.beacon.lock().unwrap(); + ( + beacon.latest_messages.as_ptr(), + beacon.latest_messages.capacity(), + ) + }; + + // An aggregate's attesters, ascending and spread wider apart than + // the headroom: grown index by index, each would reallocate. + let stride = u64::try_from(LATEST_MESSAGES_HEADROOM).unwrap() + 1; + let attesters: Vec = (1..=64).map(|i| i * stride).collect(); + let highest = *attesters.last().unwrap(); + + store.reserve_latest_messages(highest); + let reserved = table(&store); + assert!(reserved.1 > usize::try_from(highest).unwrap()); + for &index in &attesters { + store.set_latest_message(index, message); + assert_eq!(table(&store), reserved, "index {index} moved the table"); + } + assert_eq!( + store.latest_message(highest).map(|vote| vote.epoch), + Some(1) + ); + } + #[test] fn a_pow_block_is_looked_up_by_its_own_hash() { let mut store = Store::test_store();