From 355e4dd0922621de4d0cd2e5459b3c94ea596d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:11:02 -0300 Subject: [PATCH] perf(beacon): walk the registry once in epoch steps 1-3 instead of per-index reads Justification, inactivity updates and rewards built index Vecs, then did a tree descent (state.validator(i)) per participant and a binary search per eligible validator, and the pulled-up-tip check repeats justification on every imported block. Each of those reads is a tree walk; the registry's iter() is a leaf-slice walk. Every spec function keeps its shape and signature, but now iterates validators().iter().enumerate() and indexes the flat participation and score lists by position. New helpers compute the unslashed participating balance and the total active balance (plus base reward per increment) in one pass without an index Vec; the public get_total_active_balance and get_base_reward_per_increment are untouched. The previous implementations are kept as a test-only reference, and randomized tests (altair and electra, short lists, off-grid balances, scores near the top of the range, leaking epochs) compare outputs, errors and post-step state against it. --- .../src/beacon/helpers/altair.rs | 311 +++++++---- .../beacon/helpers/altair/reference_tests.rs | 499 ++++++++++++++++++ .../src/beacon/stf/epoch/altair.rs | 103 ++-- 3 files changed, 772 insertions(+), 141 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index eaa008c2e..18786ff18 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -31,11 +31,12 @@ //! without changing the shape of any SSZ container. That is the only function //! below that takes a [`Config`]; the rest need nothing a network could vary. -use super::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use super::finality::is_in_inactivity_leak; +use super::predicates::is_active_validator; use crate::beacon::bls; use crate::beacon::config::Config; use crate::beacon::constants; -use crate::beacon::containers::shared::AttestationData; +use crate::beacon::containers::shared::{AttestationData, EpochParticipation, Validator}; use crate::beacon::containers::{BeaconState, altair}; use crate::beacon::error::{Error, Result}; use crate::beacon::fork::ForkName; @@ -45,7 +46,7 @@ use crate::beacon::primitives::{Epoch, Gwei, ParticipationFlags, ValidatorIndex} use super::accessors::{ get_active_validator_indices, get_block_root, get_block_root_at_slot, get_current_epoch, - get_previous_epoch, get_seed, get_total_active_balance, get_total_balance, + get_previous_epoch, get_seed, get_total_active_balance, }; use super::math::integer_squareroot; use super::shuffling::compute_shuffled_index; @@ -205,22 +206,13 @@ pub fn get_base_reward(state: &BeaconState, index: ValidatorIndex) -> Result Result> { +/// At the genesis epoch the previous and current epoch coincide, and the +/// current list is the one read, exactly as the specification's +/// `epoch == get_current_epoch(state)` branch does. +fn epoch_participation(state: &BeaconState, epoch: Epoch) -> Result<&EpochParticipation> { crate::beacon::verify( epoch == get_previous_epoch(state) || epoch == get_current_epoch(state), "epoch in (get_previous_epoch(state), get_current_epoch(state))", @@ -228,29 +220,114 @@ pub fn get_unslashed_participating_indices( let (previous_epoch_participation, current_epoch_participation, _) = state.altair_validator_lists()?; - let epoch_participation = if epoch == get_current_epoch(state) { + Ok(if epoch == get_current_epoch(state) { current_epoch_participation } else { previous_epoch_participation - }; + }) +} - let mut participating_indices = Vec::new(); - for index in get_active_validator_indices(state, epoch) { - let flags = - epoch_participation - .get(index as usize) - .copied() - .ok_or(Error::IndexOutOfBounds { - index: index as usize, - len: epoch_participation.len(), - })?; - if has_flag(flags, flag_index) && !state.validator(index)?.slashed { - participating_indices.push(index); +/// Calls `each` with the index and record of every active, unslashed validator +/// that had `flag_index` set for `epoch`, in ascending index order. +/// +/// One walk over the registry's leaves, reading the flat participation list by +/// position: no index `Vec` and no per-index registry descent, which is what +/// the callers below used to pay per participant. An active validator past the +/// end of the participation list is an error, as before, and is reported for +/// the lowest such index whether or not it holds the flag. +fn for_each_unslashed_participant( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, + mut each: impl FnMut(usize, &Validator), +) -> Result<()> { + let epoch_participation = epoch_participation(state, epoch)?; + + for (index, validator) in state.validators().iter().enumerate() { + if !is_active_validator(validator, epoch) { + continue; + } + let flags = epoch_participation + .get(index) + .copied() + .ok_or(Error::IndexOutOfBounds { + index, + len: epoch_participation.len(), + })?; + if has_flag(flags, flag_index) && !validator.slashed { + each(index, validator); } } + Ok(()) +} + +/// The active, unslashed validators that had `flag_index` set for `epoch`. +/// +/// `epoch` must be the current or previous epoch, since those are the only +/// two altair keeps a participation record for (`current_epoch_participation` +/// and `previous_epoch_participation`, mirroring the two-epoch window phase0 +/// keeps for `PendingAttestation`s). +/// +/// Ascending and duplicate-free, since it walks the registry in index order. +/// Callers that only need the combined balance should use +/// [`get_unslashed_participating_balance`], which skips building this list. +pub fn get_unslashed_participating_indices( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result> { + let mut participating_indices = Vec::new(); + for_each_unslashed_participant(state, flag_index, epoch, |index, _| { + participating_indices.push(index as ValidatorIndex); + })?; Ok(participating_indices) } +/// The combined effective balance of [`get_unslashed_participating_indices`]'s +/// result, floored at one increment like +/// [`get_total_balance`](super::accessors::get_total_balance). +/// +/// Exactly `get_total_balance(state, &get_unslashed_participating_indices(..)?)` +/// (same saturating sum, same floor, same errors), computed in the one pass +/// that finds the participants. +pub fn get_unslashed_participating_balance( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result { + let mut total: Gwei = 0; + for_each_unslashed_participant(state, flag_index, epoch, |_, validator| { + total = total.saturating_add(validator.effective_balance); + })?; + Ok(total.max(preset::EFFECTIVE_BALANCE_INCREMENT)) +} + +/// The value [`get_total_active_balance`] returns, in one walk over the +/// registry's leaves. +/// +/// Sums every validator active at the current epoch (slashed ones included), +/// saturating, floored at one increment. It exists so the epoch steps do not +/// pay [`get_total_active_balance`]'s index `Vec` plus one registry descent +/// per active validator. +pub(crate) fn compute_total_active_balance(state: &BeaconState) -> Gwei { + let current_epoch = get_current_epoch(state); + let total = state + .validators() + .iter() + .filter(|validator| is_active_validator(validator, current_epoch)) + .fold(0, |total: Gwei, validator| { + total.saturating_add(validator.effective_balance) + }); + total.max(preset::EFFECTIVE_BALANCE_INCREMENT) +} + +/// [`get_base_reward_per_increment`] for an already-computed +/// `total_active_balance`, with the same formula and operation order. +pub(crate) fn base_reward_per_increment_from_total(total_active_balance: Gwei) -> Gwei { + preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR + / integer_squareroot(total_active_balance) +} + /// Which of the three participation flags an attestation with `data`, /// included after `inclusion_delay` slots, satisfies. /// @@ -306,9 +383,10 @@ pub fn get_attestation_participation_flag_indices( /// The reward and penalty for one participation flag, for every validator. /// -/// Reuses [`get_eligible_validator_indices`] and [`is_in_inactivity_leak`] -/// from phase0's rewards module unchanged, since the specification does not -/// touch either of them in altair. +/// Applies [`get_eligible_validator_indices`](super::finality::get_eligible_validator_indices)'s +/// predicate inline (one walk instead of an index list) and reuses +/// [`is_in_inactivity_leak`] from phase0's rewards module unchanged, since the +/// specification does not touch either of them in altair. /// /// During an inactivity leak, a matching validator earns nothing here for /// this flag rather than the balance-weighted share the non-leaking branch @@ -325,62 +403,53 @@ pub fn get_flag_index_deltas( let mut penalties = vec![0; validator_count]; let previous_epoch = get_previous_epoch(state); - let unslashed_participating_indices = - get_unslashed_participating_indices(state, flag_index, previous_epoch)?; - let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + // Also the bounds check for the loop below: an active validator past the + // end of the participation list fails here, so the loop only indexes the + // list for validators this call has already proven are in range. let unslashed_participating_balance = - get_total_balance(state, &unslashed_participating_indices)?; + get_unslashed_participating_balance(state, flag_index, previous_epoch)?; + let epoch_participation = epoch_participation(state, previous_epoch)?; + + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; let unslashed_participating_increments = unslashed_participating_balance / preset::EFFECTIVE_BALANCE_INCREMENT; - let active_increments = get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; - - // Hoisted out of the loop below, where the specification writes - // `get_base_reward(state, index)` per eligible validator. That helper is - // `increments * get_base_reward_per_increment(state)`, and the second - // factor is `get_total_active_balance`, an unconditional `O(registry - // size)` scan with no cache of its own. That is the same quantity - // `active_increments` above already paid for, just run through a - // different formula (`get_base_reward_per_increment` divides by - // `integer_squareroot`, `active_increments` does not), so it is not - // reusable as-is and has to be hoisted on its own. - // - // [`process_epoch::electra::process_epoch`] calls this (via - // `process_epoch::altair::process_rewards_and_penalties`) once per - // [`crate::beacon::constants::PARTICIPATION_FLAG_WEIGHTS`] entry, three times per - // epoch boundary. At mainnet's ~1M validators, the unhoisted form is - // three separate million-element scans per *eligible validator*, effectively - // unbounded, for what this function already computes once above. This is - // the same bug already fixed in `process_attestation`'s per-attester loop - // (see that function's own comment), left unfixed here because it runs - // once per epoch rather than once per block and so never showed up in a - // profile that did not cross an epoch boundary. - // - // Measured directly: `tests::measures_the_cost_of_get_flag_index_deltas` - // times this call at 2^15 validators. Unhoisted, that call took ~11.9s; - // hoisted, ~384us: roughly 31,000x at that scale, and the gap widens - // further at mainnet's ~2^20 validators, since the unhoisted form is - // O(n^2) (`1024x` slower again at that size) while this is O(n) (`32x` - // slower again, same as every other size-dependent cost in this crate). - let base_reward_per_increment = get_base_reward_per_increment(state)?; - - for index in get_eligible_validator_indices(state) { + let total_active_balance = compute_total_active_balance(state); + let active_increments = total_active_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + + // `get_base_reward(state, index)` is `increments * + // get_base_reward_per_increment(state)`, and the second factor depends on + // the total active balance, a registry-wide sum. It is the same for every + // validator, so it is computed once here rather than per eligible + // validator (which made this function quadratic in the registry size). + let base_reward_per_increment = base_reward_per_increment_from_total(total_active_balance); + let leaking = is_in_inactivity_leak(state); + + // One walk over the registry: eligibility (same predicate as + // `get_eligible_validator_indices`) and participation are decided from the + // iterated validator, so no index list, binary search or descent is needed. + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + if !(active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch)) { + continue; + } // `get_base_reward(state, index)` inlined against the hoisted // per-increment value, in the helper's own order of operations so // the result is bit-identical. - let increments = - state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let increments = validator.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; let base_reward = increments * base_reward_per_increment; - if unslashed_participating_indices - .binary_search(&index) - .is_ok() - { - if !is_in_inactivity_leak(state) { + // Only an active validator has a participation record that counts; + // an eligible but inactive (slashed, exited) one never participates, + // and its index may lie past the end of the list. + let participated = + active && !validator.slashed && has_flag(epoch_participation[index], flag_index); + if participated { + if !leaking { let reward_numerator = base_reward * weight * unslashed_participating_increments; - rewards[index as usize] += + rewards[index] += reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); } } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { - penalties[index as usize] += base_reward * weight / constants::WEIGHT_DENOMINATOR; + penalties[index] += base_reward * weight / constants::WEIGHT_DENOMINATOR; } } Ok((rewards, penalties)) @@ -415,37 +484,66 @@ pub fn get_inactivity_penalty_deltas( let mut penalties = vec![0; validator_count]; let previous_epoch = get_previous_epoch(state); - let matching_target_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - previous_epoch, - )?; - + let target_participation = epoch_participation(state, previous_epoch)?; let (_, _, inactivity_scores) = state.altair_validator_lists()?; - - for index in get_eligible_validator_indices(state) { - if matching_target_indices.binary_search(&index).is_err() { - let effective_balance = state.validator(index)?.effective_balance; - let inactivity_score = - inactivity_scores - .get(index as usize) + let inactivity_penalty_quotient = + preset::retuned::inactivity_penalty_quotient(state.fork_name()); + + // A participation-list bounds error outranks a score error from an + // earlier validator (the participants are resolved before any score is + // read), so a score error is held back while the walk keeps looking for + // one. Both only occur on malformed states. + let mut deferred_error = None; + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + let flags = if active { + Some( + target_participation + .get(index) .copied() .ok_or(Error::IndexOutOfBounds { - index: index as usize, - len: inactivity_scores.len(), - })?; - - let penalty_numerator = effective_balance.checked_mul(inactivity_score).ok_or( - Error::ArithmeticOverflow("effective_balance * inactivity_scores[index]"), - )?; - let inactivity_penalty_quotient = - preset::retuned::inactivity_penalty_quotient(state.fork_name()); - let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; - penalties[index as usize] += penalty_numerator / penalty_denominator; + index, + len: target_participation.len(), + })?, + ) + } else { + None + }; + if deferred_error.is_some() { + continue; } + if !(active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch)) { + continue; + } + let participated = flags.is_some_and(|flags| { + !validator.slashed && has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX) + }); + if participated { + continue; + } + + let Some(&inactivity_score) = inactivity_scores.get(index) else { + deferred_error = Some(Error::IndexOutOfBounds { + index, + len: inactivity_scores.len(), + }); + continue; + }; + let Some(penalty_numerator) = validator.effective_balance.checked_mul(inactivity_score) + else { + deferred_error = Some(Error::ArithmeticOverflow( + "effective_balance * inactivity_scores[index]", + )); + continue; + }; + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + penalties[index] += penalty_numerator / penalty_denominator; } - Ok((rewards, penalties)) + match deferred_error { + Some(error) => Err(error), + None => Ok((rewards, penalties)), + } } // --------------------------------------------------------------------------- @@ -488,6 +586,9 @@ pub(crate) fn altair_state_ref<'a>( } } +#[cfg(test)] +mod reference_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs new file mode 100644 index 000000000..351eac743 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs @@ -0,0 +1,499 @@ +//! Randomized equivalence tests for the single-pass altair epoch helpers. +//! +//! The functions below are the implementations these helpers had before they +//! were rewritten to walk the registry once: they build index lists, descend +//! the registry per index and binary-search. They are kept verbatim as the +//! reference the current code must match exactly, errors included, on states +//! only a fixture would reach (short participation lists, zero and off-grid +//! effective balances, scores near the top of the range). + +use super::*; +use crate::beacon::containers::shared::InactivityScores; +use crate::beacon::helpers::accessors::{get_active_validator_indices, get_total_balance}; +use crate::beacon::helpers::finality::get_eligible_validator_indices; +use crate::beacon::helpers::math::saturating_sub; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::test_state::with_validators_at; +use crate::beacon::stf::epoch::altair as steps; +use crate::beacon::stf::epoch::justification::weigh_justification_and_finalization; + +// --------------------------------------------------------------------------- +// Reference implementations (the code as it was before the single pass) +// --------------------------------------------------------------------------- + +fn ref_unslashed_participating_indices( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result> { + crate::beacon::verify( + epoch == get_previous_epoch(state) || epoch == get_current_epoch(state), + "epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists()?; + let epoch_participation = if epoch == get_current_epoch(state) { + current_epoch_participation + } else { + previous_epoch_participation + }; + + let mut participating_indices = Vec::new(); + for index in get_active_validator_indices(state, epoch) { + let flags = + epoch_participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation.len(), + })?; + if has_flag(flags, flag_index) && !state.validator(index)?.slashed { + participating_indices.push(index); + } + } + Ok(participating_indices) +} + +fn ref_flag_index_deltas(state: &BeaconState, flag_index: usize) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let mut rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let unslashed_participating_indices = + ref_unslashed_participating_indices(state, flag_index, previous_epoch)?; + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + let unslashed_participating_balance = + get_total_balance(state, &unslashed_participating_indices)?; + let unslashed_participating_increments = + unslashed_participating_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let active_increments = get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + for index in get_eligible_validator_indices(state) { + let increments = + state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let base_reward = increments * base_reward_per_increment; + if unslashed_participating_indices + .binary_search(&index) + .is_ok() + { + if !is_in_inactivity_leak(state) { + let reward_numerator = base_reward * weight * unslashed_participating_increments; + rewards[index as usize] += + reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); + } + } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { + penalties[index as usize] += base_reward * weight / constants::WEIGHT_DENOMINATOR; + } + } + Ok((rewards, penalties)) +} + +fn ref_inactivity_penalty_deltas( + state: &BeaconState, + config: &Config, +) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let matching_target_indices = ref_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + + for index in get_eligible_validator_indices(state) { + if matching_target_indices.binary_search(&index).is_err() { + let effective_balance = state.validator(index)?.effective_balance; + let inactivity_score = + inactivity_scores + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: inactivity_scores.len(), + })?; + + let penalty_numerator = effective_balance.checked_mul(inactivity_score).ok_or( + Error::ArithmeticOverflow("effective_balance * inactivity_scores[index]"), + )?; + let inactivity_penalty_quotient = + preset::retuned::inactivity_penalty_quotient(state.fork_name()); + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + penalties[index as usize] += penalty_numerator / penalty_denominator; + } + } + + Ok((rewards, penalties)) +} + +fn ref_process_justification_and_finalization(state: &mut BeaconState) -> Result<()> { + if get_current_epoch(state) <= constants::GENESIS_EPOCH + 1 { + return Ok(()); + } + + let previous_indices = ref_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_previous_epoch(state), + )?; + let current_indices = ref_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_current_epoch(state), + )?; + let total_active_balance = get_total_active_balance(state)?; + let previous_target_balance = get_total_balance(state, &previous_indices)?; + let current_target_balance = get_total_balance(state, ¤t_indices)?; + weigh_justification_and_finalization( + state, + total_active_balance, + previous_target_balance, + current_target_balance, + ) +} + +fn ref_process_inactivity_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let eligible_indices = get_eligible_validator_indices(state); + let previous_epoch = get_previous_epoch(state); + let participating_indices = ref_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + let leaking = is_in_inactivity_leak(state); + + let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; + let score_count = inactivity_scores.len(); + for index in eligible_indices { + let score = inactivity_scores + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: score_count, + })?; + + if participating_indices.binary_search(&index).is_ok() { + *score = saturating_sub(*score, 1); + } else { + *score = score.checked_add(config.inactivity_score_bias).ok_or( + Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), + )?; + } + + if !leaking { + *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + } + } + + Ok(()) +} + +fn ref_process_rewards_and_penalties(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let mut deltas = Vec::with_capacity(constants::PARTICIPATION_FLAG_WEIGHTS.len() + 1); + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + deltas.push(ref_flag_index_deltas(state, flag_index)?); + } + deltas.push(ref_inactivity_penalty_deltas(state, config)?); + + let validator_count = state.validators().len() as ValidatorIndex; + for (rewards, penalties) in deltas { + for index in 0..validator_count { + increase_balance(state, index, rewards[index as usize])?; + decrease_balance(state, index, penalties[index as usize])?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Randomized states +// --------------------------------------------------------------------------- + +/// SplitMix64: a small deterministic generator, enough for test inputs. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn below(&mut self, bound: u64) -> u64 { + self.next() % bound + } + + fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } + + fn pick(&mut self, options: &[T]) -> T { + options[self.below(options.len() as u64) as usize] + } +} + +/// The most an electra-era effective balance can hold. +const MAX_BALANCE: Gwei = 2_048_000_000_000; + +/// The current epochs the tests visit: the first three, an ordinary one, and +/// one far enough past the genesis-default finalized checkpoint to leak. +fn epoch_choices() -> [Epoch; 5] { + [0, 1, 2, 5, preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 6] +} + +fn random_state(rng: &mut Rng, fork: ForkName, current_epoch: Epoch) -> BeaconState { + let count = 100 + rng.below(201) as usize; + let mut state = with_validators_at(fork, count); + *state.slot_mut() = current_epoch * preset::SLOTS_PER_EPOCH; + let previous_epoch = current_epoch.saturating_sub(1); + + // Finality anywhere at or before the previous epoch (a leak when it is + // far behind), which keeps the finality delay from underflowing. + let leaking_state = current_epoch > preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY; + let finalized_epoch = if leaking_state && rng.chance(70) { + 0 + } else { + rng.below(previous_epoch + 1) + }; + state.finalized_checkpoint_mut().epoch = finalized_epoch; + state.current_justified_checkpoint_mut().epoch = rng.below(current_epoch + 1); + state.previous_justified_checkpoint_mut().epoch = rng.below(previous_epoch + 1); + let bits = rng.below(16); + for bit in 0..4 { + let _ = state + .justification_bits_mut() + .set(bit, bits >> bit & 1 == 1); + } + + let epochs_around = [ + 0, + previous_epoch.saturating_sub(1), + previous_epoch, + current_epoch, + current_epoch + 1, + current_epoch + 2, + ]; + let large_scores = rng.chance(15); + for index in 0..count { + let activation_epoch = if rng.chance(70) { + 0 + } else { + rng.pick(&epochs_around) + }; + let exit_epoch = if rng.chance(70) { + constants::FAR_FUTURE_EPOCH + } else { + rng.pick(&epochs_around) + }; + let withdrawable_epoch = if rng.chance(50) { + constants::FAR_FUTURE_EPOCH + } else { + rng.pick(&epochs_around) + }; + let effective_balance = match rng.below(6) { + 0 => 0, + 1 => preset::MAX_EFFECTIVE_BALANCE, + 2 => rng.below(MAX_BALANCE + 1), + _ => { + rng.below(MAX_BALANCE / preset::EFFECTIVE_BALANCE_INCREMENT + 1) + * preset::EFFECTIVE_BALANCE_INCREMENT + } + }; + let validator = state.validator_mut(index as u64).unwrap(); + validator.activation_epoch = activation_epoch; + validator.exit_epoch = exit_epoch; + validator.withdrawable_epoch = withdrawable_epoch; + validator.slashed = rng.chance(25); + validator.effective_balance = effective_balance; + + let balance = match rng.below(4) { + 0 => 0, + 1 => rng.below(1_000_000_000), + 2 => effective_balance, + _ => effective_balance.saturating_add(rng.below(2_000_000_000)), + }; + *state.balances_mut().get_mut(index).unwrap() = balance; + } + + let (previous, current, scores) = state.altair_validator_lists_mut().unwrap(); + for index in 0..count { + previous[index] = rng.below(8) as u8; + current[index] = rng.below(8) as u8; + scores[index] = if large_scores && rng.chance(4) { + u64::MAX - rng.below(10) + } else if rng.chance(10) { + rng.below(1 << 20) + } else { + rng.below(200) + }; + } + + // Some states have a list shorter than the registry, which only a fixture + // can build; both implementations must report the same error. + if rng.chance(8) { + let keep = rng.below(count as u64) as usize; + match rng.below(3) { + 0 => *previous = truncated_participation(previous, keep), + 1 => *current = truncated_participation(current, keep), + _ => *scores = truncated_scores(scores, keep), + } + } + + state +} + +fn truncated_participation(list: &EpochParticipation, keep: usize) -> EpochParticipation { + list.iter() + .copied() + .take(keep) + .collect::>() + .try_into() + .unwrap() +} + +fn truncated_scores(list: &InactivityScores, keep: usize) -> InactivityScores { + list.iter() + .copied() + .take(keep) + .collect::>() + .try_into() + .unwrap() +} + +fn for_random_states(seed: u64, mut check: impl FnMut(&mut Rng, &BeaconState)) { + let mut rng = Rng(seed); + for fork in [ForkName::Altair, ForkName::Electra] { + for current_epoch in epoch_choices() { + for _ in 0..12 { + let state = random_state(&mut rng, fork, current_epoch); + check(&mut rng, &state); + } + } + } +} + +fn debug(value: &T) -> String { + format!("{value:?}") +} + +/// Everything the three epoch steps can write. +fn snapshot(state: &BeaconState) -> String { + let (_, _, scores) = state.altair_validator_lists().unwrap(); + debug(&( + state.justification_bits().clone(), + state.previous_justified_checkpoint(), + state.current_justified_checkpoint(), + state.finalized_checkpoint(), + scores.iter().copied().collect::>(), + state.balances().iter().copied().collect::>(), + )) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn participation_helpers_match_reference() { + for_random_states(1, |_, state| { + let current_epoch = get_current_epoch(state); + let epochs = [ + get_previous_epoch(state), + current_epoch, + // Neither current nor previous: both must refuse. + current_epoch + 3, + ]; + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + for epoch in epochs { + let expected = ref_unslashed_participating_indices(state, flag_index, epoch); + let actual = get_unslashed_participating_indices(state, flag_index, epoch); + assert_eq!(debug(&actual), debug(&expected)); + + let expected_balance = + expected.and_then(|indices| get_total_balance(state, &indices)); + let actual_balance = get_unslashed_participating_balance(state, flag_index, epoch); + assert_eq!(debug(&actual_balance), debug(&expected_balance)); + } + } + + let total = get_total_active_balance(state).unwrap(); + assert_eq!(compute_total_active_balance(state), total); + assert_eq!( + base_reward_per_increment_from_total(total), + get_base_reward_per_increment(state).unwrap(), + ); + }); +} + +#[test] +fn reward_deltas_match_reference() { + let config = Config::mainnet(); + for_random_states(2, |_, state| { + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + let expected = ref_flag_index_deltas(state, flag_index); + let actual = get_flag_index_deltas(state, flag_index); + assert_eq!(debug(&actual), debug(&expected), "flag {flag_index}"); + } + let expected = ref_inactivity_penalty_deltas(state, &config); + let actual = get_inactivity_penalty_deltas(state, &config); + assert_eq!(debug(&actual), debug(&expected)); + }); +} + +#[test] +fn epoch_steps_match_reference() { + let config = Config::mainnet(); + for_random_states(3, |_, state| { + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_justification_and_finalization(&mut expected_state); + let actual = steps::process_justification_and_finalization(&mut actual_state); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_inactivity_updates(&mut expected_state, &config); + let actual = steps::process_inactivity_updates(&mut actual_state, &config); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_rewards_and_penalties(&mut expected_state, &config); + let actual = steps::process_rewards_and_penalties(&mut actual_state, &config); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + // The steps in driver order, so step 3 reads the scores step 2 wrote + // and the leak flag step 1's finality may have moved. + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_justification_and_finalization(&mut expected_state) + .and_then(|()| ref_process_inactivity_updates(&mut expected_state, &config)) + .and_then(|()| ref_process_rewards_and_penalties(&mut expected_state, &config)); + let actual = steps::process_justification_and_finalization(&mut actual_state) + .and_then(|()| steps::process_inactivity_updates(&mut actual_state, &config)) + .and_then(|()| steps::process_rewards_and_penalties(&mut actual_state, &config)); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + }); +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs index 1b2f07320..13e096920 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -20,16 +20,15 @@ use crate::beacon::config::Config; use crate::beacon::constants; use crate::beacon::containers::BeaconState; use crate::beacon::error::{Error, Result}; -use crate::beacon::helpers::accessors::{ - get_current_epoch, get_previous_epoch, get_total_active_balance, get_total_balance, -}; +use crate::beacon::helpers::accessors::{get_current_epoch, get_previous_epoch}; use crate::beacon::helpers::altair::{ - get_flag_index_deltas, get_inactivity_penalty_deltas, get_next_sync_committee, - get_unslashed_participating_indices, + compute_total_active_balance, get_flag_index_deltas, get_inactivity_penalty_deltas, + get_next_sync_committee, get_unslashed_participating_balance, has_flag, }; -use crate::beacon::helpers::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::helpers::finality::is_in_inactivity_leak; use crate::beacon::helpers::math::saturating_sub; use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::predicates::is_active_validator; use crate::beacon::preset; use crate::beacon::primitives::ValidatorIndex; @@ -61,7 +60,7 @@ pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { /// timely, correct target vote for the previous and current epoch. /// /// The only difference from phase0's version: the two target balances come -/// from [`get_unslashed_participating_indices`] over the epoch's participation +/// from [`get_unslashed_participating_balance`] over the epoch's participation /// flags rather than from matching stored `PendingAttestation`s against the /// block root history. Once those two balances are in hand, the actual /// bitfield and finality bookkeeping is identical, so this hands off to @@ -74,19 +73,17 @@ pub fn process_justification_and_finalization(state: &mut BeaconState) -> Result return Ok(()); } - let previous_indices = get_unslashed_participating_indices( + let previous_target_balance = get_unslashed_participating_balance( state, constants::TIMELY_TARGET_FLAG_INDEX, get_previous_epoch(state), )?; - let current_indices = get_unslashed_participating_indices( + let current_target_balance = get_unslashed_participating_balance( state, constants::TIMELY_TARGET_FLAG_INDEX, get_current_epoch(state), )?; - let total_active_balance = get_total_active_balance(state)?; - let previous_target_balance = get_total_balance(state, &previous_indices)?; - let current_target_balance = get_total_balance(state, ¤t_indices)?; + let total_active_balance = compute_total_active_balance(state); weigh_justification_and_finalization( state, total_active_balance, @@ -116,51 +113,85 @@ pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> R return Ok(()); } - // Every read below needs `&BeaconState`, so they all run before this takes - // the mutable borrow `inactivity_scores` requires: `altair_validator_lists_mut` - // borrows the whole state, and there is no way to hold that mutably while - // also calling `get_eligible_validator_indices`, `get_unslashed_participating_indices`, - // or `is_in_inactivity_leak`, each of which needs its own `&BeaconState`. - // `process_effective_balance_updates` in the parent module resolves the - // identical conflict the same way: decide everything in one pass over - // immutable state, then apply it in a second pass over a mutable borrow. - let eligible_indices = get_eligible_validator_indices(state); + // The decisions come from one immutable walk over the registry's leaves + // and are applied in a second pass over the mutable score list: + // `altair_validator_lists_mut` borrows the whole state, so the two cannot + // overlap. `process_effective_balance_updates` in the parent module + // resolves the identical conflict the same way. The decisions are one + // byte per validator, not index lists searched per validator. + const INELIGIBLE: u8 = 0; + const PARTICIPATED: u8 = 1; + const MISSED: u8 = 2; + let previous_epoch = get_previous_epoch(state); - let participating_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - previous_epoch, - )?; let leaking = is_in_inactivity_leak(state); + // Not the genesis epoch (returned above), so "previous" is a distinct + // epoch with its own list, unlike the genesis case in + // `get_unslashed_participating_indices`. + let (previous_epoch_participation, _, _) = state.altair_validator_lists()?; + + let mut decisions = Vec::with_capacity(state.validators().len()); + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + // A participation record is only read for an active validator, as + // `get_unslashed_participating_indices` does, and a short list is an + // error there before any score is touched. + let flags = if active { + Some(previous_epoch_participation.get(index).copied().ok_or( + Error::IndexOutOfBounds { + index, + len: previous_epoch_participation.len(), + }, + )?) + } else { + None + }; + let eligible = + active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch); + decisions.push(if !eligible { + INELIGIBLE + } else if flags.is_some_and(|flags| { + !validator.slashed && has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX) + }) { + PARTICIPATED + } else { + MISSED + }); + } let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); - for index in eligible_indices { + for (index, decision) in decisions.into_iter().enumerate() { + if decision == INELIGIBLE { + continue; + } let score = inactivity_scores - .get_mut(index as usize) + .get_mut(index) .ok_or(Error::IndexOutOfBounds { - index: index as usize, + index, len: score_count, })?; - // `participating_indices` is ascending and duplicate-free (see - // `get_unslashed_participating_indices`), so membership is a binary - // search rather than a linear scan. - if participating_indices.binary_search(&index).is_ok() { + let mut updated = *score; + if decision == PARTICIPATED { // `x -= min(1, x)`, written with `saturating_sub` so a // already-zero score cannot underflow. - *score = saturating_sub(*score, 1); + updated = saturating_sub(updated, 1); } else { // The specification treats a `uint64` overflow here as an invalid // state rather than a wrapped one, so this is checked rather than // left to release-mode wrapping. - *score = score.checked_add(config.inactivity_score_bias).ok_or( + updated = updated.checked_add(config.inactivity_score_bias).ok_or( Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), )?; } if !leaking { - *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + updated = saturating_sub(updated, config.inactivity_score_recovery_rate); + } + // Most scores sit at zero on a healthy chain; skip the write. + if updated != *score { + *score = updated; } }