Skip to content

Commit

Permalink
Use same online ratio calculator for computing rewards and exemptions…
Browse files Browse the repository at this point in the history
… for validators (#11982)

Currently the online ratio calculated for 1) reward calculator and 2)
validators exempted from kickout are different; the former uses
block+chunk production and endorsements, while the latter does not use
endorsements.

This PR makes both code use the same calculation. For this, we extract
the code that calculates the online ratio to a new file
`validator_stats.rs`. The function `get_validator_online_ratio` returns
`Ratio<U256>`. However, this cannot be used for sorting since `Ratio`
requires that the inner type is implements the `Integer` trait, which
`U256` does not. Thus, for the sorting only, we convert `Ration<U256>`
to `Ratio<BitInt>` (`BigRational`). In the future we may consider
implementing `Integer` trait for `U256` or always using `BigInt`.

This is currently not enabled and is guarded by the new protocol feature
`ChunkEndorsementsInBlockHeader`, which sets the chunk endorsements in
the `BlockHeader` and `BlockInfo`, so that we will calculate the
endorsements in `EpochManager`.
We add some tests to check chunk-validator exemptions with and without
this feature.
  • Loading branch information
tayfunelmas authored Aug 29, 2024
1 parent ec2ece3 commit 51e4728
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 104 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions chain/epoch-manager/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ workspace = true
[dependencies]
borsh.workspace = true
chrono = { workspace = true, optional = true }
num-bigint.workspace = true
num-rational.workspace = true
primitive-types.workspace = true
rand.workspace = true
Expand Down
77 changes: 40 additions & 37 deletions chain/epoch-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use crate::metrics::{PROTOCOL_VERSION_NEXT, PROTOCOL_VERSION_VOTES};
use near_cache::SyncLruCache;
use near_chain_configs::GenesisConfig;
use near_primitives::block::{BlockHeader, Tip};
use near_primitives::checked_feature;
use near_primitives::epoch_block_info::{BlockInfo, SlashState};
use near_primitives::epoch_info::EpochInfo;
use near_primitives::epoch_manager::{
Expand All @@ -21,17 +20,21 @@ use near_primitives::types::{
EpochInfoProvider, NumSeats, ShardId, ValidatorId, ValidatorInfoIdentifier,
ValidatorKickoutReason, ValidatorStats,
};
use near_primitives::version::{ProtocolVersion, UPGRADABILITY_FIX_PROTOCOL_VERSION};
use near_primitives::version::{
ProtocolFeature, ProtocolVersion, UPGRADABILITY_FIX_PROTOCOL_VERSION,
};
use near_primitives::views::{
CurrentEpochValidatorInfo, EpochValidatorInfo, NextEpochValidatorInfo, ValidatorKickoutView,
};
use near_store::{DBCol, Store, StoreUpdate, HEADER_HEAD_KEY};
use num_rational::Rational64;
use primitive_types::U256;
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, warn};
use validator_stats::{
get_sortable_validator_online_ratio, get_sortable_validator_online_ratio_without_endorsements,
};

pub use crate::adapter::EpochManagerAdapter;
pub use crate::proposals::proposals_to_epoch_info;
Expand All @@ -50,6 +53,7 @@ pub mod test_utils;
mod tests;
pub mod types;
mod validator_selection;
mod validator_stats;

const EPOCH_CACHE_SIZE: usize = if cfg!(feature = "no_cache") { 1 } else { 50 };
const BLOCK_CACHE_SIZE: usize = if cfg!(feature = "no_cache") { 5 } else { 1000 }; // TODO(#5080): fix this
Expand Down Expand Up @@ -392,9 +396,11 @@ impl EpochManager {
/// we don't kick out too many validators in case of network instability.
/// We also make sure that these exempted validators were not kicked out in the last epoch,
/// so it is guaranteed that they will stay as validators after this epoch.
///
/// `accounts_sorted_by_online_ratio`: Validator accounts sorted by online ratio in ascending order.
fn compute_exempted_kickout(
epoch_info: &EpochInfo,
validator_block_chunk_stats: &HashMap<AccountId, BlockChunkValidatorStats>,
accounts_sorted_by_online_ratio: &Vec<AccountId>,
total_stake: Balance,
exempt_perc: u8,
prev_validator_kickout: &HashMap<AccountId, ValidatorKickoutReason>,
Expand All @@ -407,40 +413,10 @@ impl EpochManager {
// Later when we perform the check to kick out validators, we don't kick out validators in
// exempted_validators.
let mut exempted_validators = HashSet::new();
if checked_feature!("stable", MaxKickoutStake, epoch_info.protocol_version()) {
if ProtocolFeature::MaxKickoutStake.enabled(epoch_info.protocol_version()) {
let min_keep_stake = total_stake * (exempt_perc as u128) / 100;
let mut sorted_validators = validator_block_chunk_stats
.iter()
.map(|(account, stats)| {
let production_ratio =
if stats.block_stats.expected == 0 && stats.chunk_stats.expected() == 0 {
Rational64::from_integer(1)
} else if stats.block_stats.expected == 0 {
Rational64::new(
stats.chunk_stats.produced() as i64,
stats.chunk_stats.expected() as i64,
)
} else if stats.chunk_stats.expected() == 0 {
Rational64::new(
stats.block_stats.produced as i64,
stats.block_stats.expected as i64,
)
} else {
(Rational64::new(
stats.chunk_stats.produced() as i64,
stats.chunk_stats.expected() as i64,
) + Rational64::new(
stats.block_stats.produced as i64,
stats.block_stats.expected as i64,
)) / 2
};
(production_ratio, account)
})
.collect::<Vec<_>>();
sorted_validators.sort();

let mut exempted_stake: Balance = 0;
for (_, account_id) in sorted_validators.into_iter().rev() {
for account_id in accounts_sorted_by_online_ratio.into_iter().rev() {
if exempted_stake >= min_keep_stake {
break;
}
Expand Down Expand Up @@ -525,11 +501,38 @@ impl EpochManager {
.insert(account_id.clone(), BlockChunkValidatorStats { block_stats, chunk_stats });
}

let accounts_sorted_by_online_ratio: Vec<AccountId> =
if ProtocolFeature::ChunkEndorsementsInBlockHeader
.enabled(epoch_info.protocol_version())
{
let mut sorted_validators = validator_block_chunk_stats
.iter()
.map(|(account, stats)| (get_sortable_validator_online_ratio(stats), account))
.collect::<Vec<_>>();
sorted_validators.sort();
sorted_validators
.into_iter()
.map(|(_, account)| account.clone())
.collect::<Vec<_>>()
} else {
let mut sorted_validators = validator_block_chunk_stats
.iter()
.map(|(account, stats)| {
(get_sortable_validator_online_ratio_without_endorsements(stats), account)
})
.collect::<Vec<_>>();
sorted_validators.sort();
sorted_validators
.into_iter()
.map(|(_, account)| account.clone())
.collect::<Vec<_>>()
};

let exempt_perc =
100_u8.checked_sub(config.validator_max_kickout_stake_perc).unwrap_or_default();
let exempted_validators = Self::compute_exempted_kickout(
epoch_info,
&validator_block_chunk_stats,
&accounts_sorted_by_online_ratio,
total_stake,
exempt_perc,
prev_validator_kickout,
Expand Down
71 changes: 5 additions & 66 deletions chain/epoch-manager/src/reward_calculator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use near_primitives::checked_feature;
use near_primitives::types::{AccountId, Balance, BlockChunkValidatorStats};
use near_primitives::version::{ProtocolVersion, ENABLE_INFLATION_PROTOCOL_VERSION};

use crate::validator_stats::get_validator_online_ratio;

pub(crate) const NUM_NS_IN_SECOND: u64 = 1_000_000_000;
pub const NUM_SECONDS_IN_A_YEAR: u64 = 24 * 60 * 60 * 365;

Expand Down Expand Up @@ -88,77 +90,14 @@ impl RewardCalculator {
let mut epoch_actual_reward = epoch_protocol_treasury;
let total_stake: Balance = validator_stake.values().sum();
for (account_id, stats) in validator_block_chunk_stats {
// Uptime is an average of block produced / expected, chunk produced / expected,
// and chunk endorsed produced / expected.
let production_ratio = get_validator_online_ratio(&stats);
let average_produced_numer = production_ratio.numer();
let average_produced_denom = production_ratio.denom();

let expected_blocks = stats.block_stats.expected;
let expected_chunks = stats.chunk_stats.expected();
let expected_endorsements = stats.chunk_stats.endorsement_stats().expected;

let (average_produced_numer, average_produced_denom) =
match (expected_blocks, expected_chunks, expected_endorsements) {
// Validator was not expected to do anything
(0, 0, 0) => (U256::from(0), U256::from(1)),
// Validator was a stateless validator only (not expected to produce anything)
(0, 0, expected_endorsements) => {
let endorsement_stats = stats.chunk_stats.endorsement_stats();
(U256::from(endorsement_stats.produced), U256::from(expected_endorsements))
}
// Validator was a chunk-only producer
(0, expected_chunks, 0) => {
(U256::from(stats.chunk_stats.produced()), U256::from(expected_chunks))
}
// Validator was only a block producer
(expected_blocks, 0, 0) => {
(U256::from(stats.block_stats.produced), U256::from(expected_blocks))
}
// Validator produced blocks and chunks, but not endorsements
(expected_blocks, expected_chunks, 0) => {
let numer = U256::from(
stats.block_stats.produced * expected_chunks
+ stats.chunk_stats.produced() * expected_blocks,
);
let denom = U256::from(2 * expected_chunks * expected_blocks);
(numer, denom)
}
// Validator produced chunks and endorsements, but not blocks
(0, expected_chunks, expected_endorsements) => {
let endorsement_stats = stats.chunk_stats.endorsement_stats();
let numer = U256::from(
endorsement_stats.produced * expected_chunks
+ stats.chunk_stats.produced() * expected_endorsements,
);
let denom = U256::from(2 * expected_chunks * expected_endorsements);
(numer, denom)
}
// Validator produced blocks and endorsements, but not chunks
(expected_blocks, 0, expected_endorsements) => {
let endorsement_stats = stats.chunk_stats.endorsement_stats();
let numer = U256::from(
endorsement_stats.produced * expected_blocks
+ stats.block_stats.produced * expected_endorsements,
);
let denom = U256::from(2 * expected_blocks * expected_endorsements);
(numer, denom)
}
// Validator did all the things
(expected_blocks, expected_chunks, expected_endorsements) => {
let produced_blocks = stats.block_stats.produced;
let produced_chunks = stats.chunk_stats.produced();
let produced_endorsements = stats.chunk_stats.endorsement_stats().produced;

let numer = U256::from(
produced_blocks * expected_chunks * expected_endorsements
+ produced_chunks * expected_blocks * expected_endorsements
+ produced_endorsements * expected_blocks * expected_chunks,
);
let denom = U256::from(
3 * expected_chunks * expected_blocks * expected_endorsements,
);
(numer, denom)
}
};

let online_min_numer = U256::from(*self.online_min_threshold.numer() as u64);
let online_min_denom = U256::from(*self.online_min_threshold.denom() as u64);
// If average of produced blocks below online min threshold, validator gets 0 reward.
Expand Down
135 changes: 134 additions & 1 deletion chain/epoch-manager/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2905,7 +2905,7 @@ fn test_max_kickout_stake_ratio() {
),
]);
assert_eq!(validator_stats, wanted_validator_stats,);
// At most 50% of total stake can be kicked out
// At most 40% of total stake can be kicked out
epoch_config.validator_max_kickout_stake_perc = 40;
let (validator_stats, kickouts) = EpochManager::compute_validators_to_reward_and_kickout(
&epoch_config,
Expand All @@ -2928,6 +2928,139 @@ fn test_max_kickout_stake_ratio() {
assert_eq!(validator_stats, wanted_validator_stats,);
}

/// Common test scenario for a couple of tests exercising chunk validator kickouts.
fn test_chunk_validator_kickout(
expected_kickouts: HashMap<AccountId, ValidatorKickoutReason>,
expect_new_algorithm: bool,
) {
if expect_new_algorithm
!= ProtocolFeature::ChunkEndorsementsInBlockHeader.enabled(PROTOCOL_VERSION)
{
return;
}
let mut epoch_config = epoch_config(5, 2, 4, 80, 80, 80).for_protocol_version(PROTOCOL_VERSION);
let accounts = vec![
("test0".parse().unwrap(), 1000),
("test1".parse().unwrap(), 1000),
("test2".parse().unwrap(), 1000),
("test3".parse().unwrap(), 1000),
("test4".parse().unwrap(), 1000),
("test5".parse().unwrap(), 1000),
];
let epoch_info = epoch_info(0, accounts, vec![0, 1, 2, 3], vec![vec![0, 1], vec![0, 2]]);
let block_stats = HashMap::from([
(0, ValidatorStats { produced: 90, expected: 100 }),
(1, ValidatorStats { produced: 90, expected: 100 }),
(2, ValidatorStats { produced: 90, expected: 100 }),
(3, ValidatorStats { produced: 0, expected: 0 }),
]);
let chunk_stats = HashMap::from([
(
0,
HashMap::from([
(0, ChunkStats::new_with_production(90, 100)),
(1, ChunkStats::new_with_production(90, 100)),
(3, ChunkStats::new_with_endorsement(0, 0)),
(4, ChunkStats::new_with_endorsement(10, 100)),
(5, ChunkStats::new_with_endorsement(90, 100)),
]),
),
(
1,
HashMap::from([
(0, ChunkStats::new_with_production(90, 100)),
(2, ChunkStats::new_with_production(90, 100)),
(3, ChunkStats::new_with_endorsement(0, 0)),
(4, ChunkStats::new_with_endorsement(10, 100)),
(5, ChunkStats::new_with_endorsement(90, 100)),
]),
),
]);

let prev_validator_kickout =
HashMap::from([("test3".parse().unwrap(), ValidatorKickoutReason::Unstaked)]);
// At most 40% of total stake can be kicked out
epoch_config.validator_max_kickout_stake_perc = 40;
let (_, kickouts) = EpochManager::compute_validators_to_reward_and_kickout(
&epoch_config,
&epoch_info,
&block_stats,
&chunk_stats,
&HashMap::new(),
&prev_validator_kickout,
);
assert_eq!(kickouts, expected_kickouts);
}

/// Tests the case where a chunk validator has low endorsement stats but is exempted from being kicked out.
#[test]
fn test_chunk_validator_exempted() {
test_chunk_validator_kickout(HashMap::new(), false);
}

#[test]
/// Tests the case where a chunk validator has low endorsement stats and is kicked out (not exempted).
/// In this test, first 3 accounts are block and chunk producers and next 2 are chunk validator only.
fn test_chunk_validator_kicked_out_for_low_endorsement() {
test_chunk_validator_kickout(
HashMap::from([(
"test4".parse().unwrap(),
NotEnoughChunkEndorsements { produced: 20, expected: 200 },
)]),
true,
);
}

#[test]
/// Tests that a validator is not kicked out due to low endorsement only (as long as it produces most of its blocks and chunks).
fn test_block_and_chunk_producer_not_kicked_out_for_low_endorsements() {
if !ProtocolFeature::ChunkEndorsementsInBlockHeader.enabled(PROTOCOL_VERSION) {
return;
}
let mut epoch_config = epoch_config(5, 2, 4, 80, 80, 80).for_protocol_version(PROTOCOL_VERSION);
let accounts = vec![
("test0".parse().unwrap(), 1000),
("test1".parse().unwrap(), 1000),
("test2".parse().unwrap(), 1000),
];
let epoch_info = epoch_info(0, accounts, vec![0, 1, 2], vec![vec![0, 1, 2], vec![0, 1, 2]]);
let block_stats = HashMap::from([
(0, ValidatorStats { produced: 90, expected: 100 }),
(1, ValidatorStats { produced: 90, expected: 100 }),
(2, ValidatorStats { produced: 90, expected: 100 }),
]);
let chunk_stats = HashMap::from([
(
0,
HashMap::from([
(0, ChunkStats::new(90, 100, 10, 100)),
(1, ChunkStats::new(90, 100, 10, 100)),
(2, ChunkStats::new(90, 100, 10, 100)),
]),
),
(
1,
HashMap::from([
(0, ChunkStats::new(90, 100, 10, 100)),
(1, ChunkStats::new(90, 100, 10, 100)),
(2, ChunkStats::new(90, 100, 10, 100)),
]),
),
]);

// At most 40% of total stake can be kicked out
epoch_config.validator_max_kickout_stake_perc = 40;
let (_, kickouts) = EpochManager::compute_validators_to_reward_and_kickout(
&epoch_config,
&epoch_info,
&block_stats,
&chunk_stats,
&HashMap::new(),
&HashMap::new(),
);
assert_eq!(kickouts, HashMap::new());
}

fn test_chunk_header(h: &[CryptoHash], signer: &ValidatorSigner) -> ShardChunkHeader {
let congestion_info = ProtocolFeature::CongestionControl
.enabled(PROTOCOL_VERSION)
Expand Down
Loading

0 comments on commit 51e4728

Please sign in to comment.