Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve processSlashings #6121

Merged
merged 7 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/state-transition/src/block/slashValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function slashValidator(
whistleblowerIndex?: ValidatorIndex
): void {
const {epochCtx} = state;
const epoch = epochCtx.epoch;
const {epoch, effectiveBalanceIncrements} = epochCtx;
const validator = state.validators.get(slashedIndex);

// TODO: Bellatrix initiateValidatorExit validators.update() with the one below
Expand All @@ -37,9 +37,15 @@ export function slashValidator(
validator.withdrawableEpoch = Math.max(validator.withdrawableEpoch, epoch + EPOCHS_PER_SLASHINGS_VECTOR);

const {effectiveBalance} = validator;
// TODO: could state.slashings be number?

// state.slashings is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because:
// - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset()
// - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet
// - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474
// - we don't need to compute the total slashings from state.slashings, it's handled by totalSlashingsByIncrement in EpochCache
const slashingIndex = epoch % EPOCHS_PER_SLASHINGS_VECTOR;
state.slashings.set(slashingIndex, state.slashings.get(slashingIndex) + BigInt(effectiveBalance));
state.slashings.set(slashingIndex, (state.slashings.get(slashingIndex) ?? 0) + effectiveBalance);
epochCtx.totalSlashingsByIncrement += effectiveBalanceIncrements[slashedIndex];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can effectiveBalanceIncrements[slashedIndex] be undefined? may be worth adding ?? 0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effectiveBalanceIncrements is populated with validator count and increased its length per processDeposit so the default value for any validator index is always 0

I also doubled check all use of effectiveBalanceIncrements, we don't need to handle undefined there so I think we don't need to do it here too. For example

epochCtx.currentTargetUnslashedBalanceIncrements += effectiveBalanceIncrements[index];


const minSlashingPenaltyQuotient =
fork === ForkSeq.phase0
Expand Down
10 changes: 10 additions & 0 deletions packages/state-transition/src/cache/epochCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {computeEpochShuffling, EpochShuffling, getShufflingDecisionBlock} from "../util/epochShuffling.js";
import {computeBaseRewardPerIncrement, computeSyncParticipantReward} from "../util/syncCommittee.js";
import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalance.js";
import {getTotalSlashingsByIncrement} from "../epoch/processSlashings.js";
import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js";
import {Index2PubkeyCache, PubkeyIndexMap, syncPubkeys} from "./pubkeyCache.js";
import {BeaconStateAllForks, BeaconStateAltair, ShufflingGetter} from "./types.js";
Expand Down Expand Up @@ -131,6 +132,10 @@ export class EpochCache {
* Effective balances, for altair processAttestations()
*/
effectiveBalanceIncrements: EffectiveBalanceIncrements;
/**
* Total state.slashings by increment, for processSlashing()
*/
totalSlashingsByIncrement: number;
syncParticipantReward: number;
syncProposerReward: number;
/**
Expand Down Expand Up @@ -206,6 +211,7 @@ export class EpochCache {
currentShuffling: EpochShuffling;
nextShuffling: EpochShuffling;
effectiveBalanceIncrements: EffectiveBalanceIncrements;
totalSlashingsByIncrement: number;
syncParticipantReward: number;
syncProposerReward: number;
baseRewardPerIncrement: number;
Expand All @@ -231,6 +237,7 @@ export class EpochCache {
this.currentShuffling = data.currentShuffling;
this.nextShuffling = data.nextShuffling;
this.effectiveBalanceIncrements = data.effectiveBalanceIncrements;
this.totalSlashingsByIncrement = data.totalSlashingsByIncrement;
this.syncParticipantReward = data.syncParticipantReward;
this.syncProposerReward = data.syncProposerReward;
this.baseRewardPerIncrement = data.baseRewardPerIncrement;
Expand Down Expand Up @@ -277,6 +284,7 @@ export class EpochCache {
const validatorCount = validators.length;

const effectiveBalanceIncrements = getEffectiveBalanceIncrementsWithLen(validatorCount);
const totalSlashingsByIncrement = getTotalSlashingsByIncrement(state);
const previousActiveIndices: ValidatorIndex[] = [];
const currentActiveIndices: ValidatorIndex[] = [];
const nextActiveIndices: ValidatorIndex[] = [];
Expand Down Expand Up @@ -425,6 +433,7 @@ export class EpochCache {
currentShuffling,
nextShuffling,
effectiveBalanceIncrements,
totalSlashingsByIncrement,
syncParticipantReward,
syncProposerReward,
baseRewardPerIncrement,
Expand Down Expand Up @@ -464,6 +473,7 @@ export class EpochCache {
// Uint8Array, requires cloning, but it is cloned only when necessary before an epoch transition
// See EpochCache.beforeEpochTransition()
effectiveBalanceIncrements: this.effectiveBalanceIncrements,
totalSlashingsByIncrement: this.totalSlashingsByIncrement,
// Basic types (numbers) cloned implicitly
syncParticipantReward: this.syncParticipantReward,
syncProposerReward: this.syncProposerReward,
Expand Down
26 changes: 23 additions & 3 deletions packages/state-transition/src/epoch/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import {ForkSeq} from "@lodestar/params";
import {
ForkSeq,
MAX_ATTESTER_SLASHINGS,
MAX_EFFECTIVE_BALANCE,
MAX_VALIDATORS_PER_COMMITTEE,
SLOTS_PER_EPOCH,
} from "@lodestar/params";
import {
CachedBeaconStateAllForks,
CachedBeaconStateCapella,
Expand Down Expand Up @@ -41,15 +47,29 @@ export {
};

export {computeUnrealizedCheckpoints} from "./computeUnrealizedCheckpoints.js";
const maxValidatorsPerStateSlashing = SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE;
const maxSafeValidators = Math.floor(Number.MAX_SAFE_INTEGER / MAX_EFFECTIVE_BALANCE);

export function processEpoch(fork: ForkSeq, state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void {
// state.slashings is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because:
// - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset()
// - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet
// - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474
if (maxValidatorsPerStateSlashing > maxSafeValidators) {
throw new Error("Lodestar does not support this network, parameters don't fit number value inside state.slashings");
}

processJustificationAndFinalization(state, cache);
if (fork >= ForkSeq.altair) {
processInactivityUpdates(state as CachedBeaconStateAltair, cache);
}
processRewardsAndPenalties(state, cache);
// processRewardsAndPenalties() is 2nd step in the specs, we optimize to do it
// after processSlashings() to update balances only once
// processRewardsAndPenalties(state, cache);
processRegistryUpdates(state, cache);
processSlashings(state, cache);
// accumulate slashing penalties and only update balances once in processRewardsAndPenalties()
const slashingPenalties = processSlashings(state, cache, false);
processRewardsAndPenalties(state, cache, slashingPenalties);
processEth1DataReset(state, cache);
processEffectiveBalanceUpdates(state, cache);
processSlashingsReset(state, cache);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {getRewardsAndPenaltiesAltair} from "./getRewardsAndPenalties.js";
*
* PERF: Cost = 'proportional' to $VALIDATOR_COUNT. Extra work is done per validator the more status flags are set
*/
export function processRewardsAndPenalties(state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void {
export function processRewardsAndPenalties(
state: CachedBeaconStateAllForks,
cache: EpochTransitionCache,
slashingPenalties: number[] = []
): void {
// No rewards are applied at the end of `GENESIS_EPOCH` because rewards are for work done in the previous epoch
if (cache.currentEpoch === GENESIS_EPOCH) {
return;
Expand All @@ -24,7 +28,7 @@ export function processRewardsAndPenalties(state: CachedBeaconStateAllForks, cac
const balances = state.balances.getAll();

for (let i = 0, len = rewards.length; i < len; i++) {
balances[i] += rewards[i] - penalties[i];
balances[i] += rewards[i] - penalties[i] - (slashingPenalties[i] ?? 0);
}

// important: do not change state one balance at a time. Set them all at once, constructing the tree in one go
Expand Down
77 changes: 56 additions & 21 deletions packages/state-transition/src/epoch/processSlashings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {bigIntMin} from "@lodestar/utils";
import {
EFFECTIVE_BALANCE_INCREMENT,
ForkSeq,
Expand All @@ -8,33 +7,35 @@ import {
} from "@lodestar/params";

import {decreaseBalance} from "../util/index.js";
import {CachedBeaconStateAllForks, EpochTransitionCache} from "../types.js";
import {BeaconStateAllForks, CachedBeaconStateAllForks, EpochTransitionCache} from "../types.js";

/**
* Update validator registry for validators that activate + exit
* updateBalance is an optimization:
* - For spec test, it's true
* - For processEpoch flow, it's false, i.e to only update balances once in processRewardsAndPenalties()
*
* PERF: Cost 'proportional' to only validators that are slashed. For mainnet conditions:
* PERF: almost no (constant) cost.
* - Total slashings by increment is computed once and stored in state.epochCtx.totalSlashingsByIncrement so no need to compute here
* - Penalties for validators with the same effective balance are the same and computed once
* - No need to apply penalties to validators here, do it once in processRewardsAndPenalties()
* - indicesToSlash: max len is 8704. But it's very unlikely since it would require all validators on the same
* committees to sign slashable attestations.
*
* - On normal mainnet conditions indicesToSlash = 0
*
* @returns slashing penalties to be applied in processRewardsAndPenalties()
*/
export function processSlashings(state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void {
// No need to compute totalSlashings if there no index to slash
export function processSlashings(
state: CachedBeaconStateAllForks,
cache: EpochTransitionCache,
updateBalance = true
): number[] {
// Return early if there no index to slash
if (cache.indicesToSlash.length === 0) {
return;
}
// TODO: have the regular totalBalance in EpochTransitionCache too?
const totalBalance = BigInt(cache.totalActiveStakeByIncrement) * BigInt(EFFECTIVE_BALANCE_INCREMENT);

// TODO: Could totalSlashings be number?
// TODO: Could totalSlashing be cached?
let totalSlashings = BigInt(0);
const slashings = state.slashings.getAll();
for (let i = 0; i < slashings.length; i++) {
totalSlashings += slashings[i];
return [];
}

const totalBalanceByIncrement = cache.totalActiveStakeByIncrement;
const fork = state.config.getForkSeq(state.slot);
const proportionalSlashingMultiplier =
fork === ForkSeq.phase0
Expand All @@ -44,12 +45,46 @@ export function processSlashings(state: CachedBeaconStateAllForks, cache: EpochT
: PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX;

const {effectiveBalanceIncrements} = state.epochCtx;
const adjustedTotalSlashingBalance = bigIntMin(totalSlashings * BigInt(proportionalSlashingMultiplier), totalBalance);
const adjustedTotalSlashingBalanceByIncrement = Math.min(
state.epochCtx.totalSlashingsByIncrement * proportionalSlashingMultiplier,
totalBalanceByIncrement
);
const increment = EFFECTIVE_BALANCE_INCREMENT;
const penalties: number[] = [];

const penaltiesByEffectiveBalanceIncrement = new Map<number, number>();
for (const index of cache.indicesToSlash) {
const effectiveBalanceIncrement = effectiveBalanceIncrements[index];
const penaltyNumerator = BigInt(effectiveBalanceIncrement) * adjustedTotalSlashingBalance;
const penalty = Number(penaltyNumerator / totalBalance) * increment;
decreaseBalance(state, index, penalty);
let penalty = penaltiesByEffectiveBalanceIncrement.get(effectiveBalanceIncrement);
if (penalty === undefined) {
const penaltyNumeratorByIncrement = effectiveBalanceIncrement * adjustedTotalSlashingBalanceByIncrement;
penalty = Math.floor(penaltyNumeratorByIncrement / totalBalanceByIncrement) * increment;
penaltiesByEffectiveBalanceIncrement.set(effectiveBalanceIncrement, penalty);
}

if (updateBalance) {
// for spec test only
decreaseBalance(state, index, penalty);
} else {
// do it later in processRewardsAndPenalties()
penalties[index] = penalty;
}
}

return penalties;
}

/**
* Get total slashings by increment.
* By default, total slashings are computed every time we run processSlashings() function above.
* We improve it by computing it once and store it in state.epochCtx.totalSlashingsByIncrement
* Every change to state.slashings should update totalSlashingsByIncrement.
*/
export function getTotalSlashingsByIncrement(state: BeaconStateAllForks): number {
let totalSlashingsByIncrement = 0;
const slashings = state.slashings.getAll();
for (let i = 0; i < slashings.length; i++) {
totalSlashingsByIncrement += Math.floor(slashings[i] / EFFECTIVE_BALANCE_INCREMENT);
}
return totalSlashingsByIncrement;
}
10 changes: 8 additions & 2 deletions packages/state-transition/src/epoch/processSlashingsReset.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {EPOCHS_PER_SLASHINGS_VECTOR} from "@lodestar/params";
import {EFFECTIVE_BALANCE_INCREMENT, EPOCHS_PER_SLASHINGS_VECTOR} from "@lodestar/params";
import {EpochTransitionCache, CachedBeaconStateAllForks} from "../types.js";

/**
Expand All @@ -10,5 +10,11 @@ export function processSlashingsReset(state: CachedBeaconStateAllForks, cache: E
const nextEpoch = cache.currentEpoch + 1;

// reset slashings
state.slashings.set(nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR, BigInt(0));
const slashIndex = nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR;
const oldSlashingValueByIncrement = Math.floor(state.slashings.get(slashIndex) / EFFECTIVE_BALANCE_INCREMENT);
state.slashings.set(slashIndex, 0);
state.epochCtx.totalSlashingsByIncrement = Math.max(
0,
state.epochCtx.totalSlashingsByIncrement - oldSlashingValueByIncrement
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ function benchmarkAltairEpochSteps(stateOg: LazyValue<CachedBeaconStateAllForks>
itBench({
id: `${stateId} - altair processSlashings`,
beforeEach: () => stateOg.value.clone() as CachedBeaconStateAltair,
fn: (state) => processSlashings(state, cache.value),
fn: (state) => {
processSlashings(state, cache.value, false);
},
});

itBench({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ function benchmarkAltairEpochSteps(stateOg: LazyValue<CachedBeaconStateAllForks>
itBench({
id: `${stateId} - capella processSlashings`,
beforeEach: () => stateOg.value.clone() as CachedBeaconStateCapella,
fn: (state) => processSlashings(state, cache.value),
fn: (state) => {
processSlashings(state, cache.value, false);
},
});

itBench({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ function benchmarkPhase0EpochSteps(stateOg: LazyValue<CachedBeaconStateAllForks>
itBench({
id: `${stateId} - phase0 processSlashings`,
beforeEach: () => stateOg.value.clone() as CachedBeaconStatePhase0,
fn: (state) => processSlashings(state, cache.value),
fn: (state) => {
processSlashings(state, cache.value, false);
},
});

itBench({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {itBench} from "@dapplion/benchmark";
import {MAX_EFFECTIVE_BALANCE} from "@lodestar/params";
import {
beforeProcessEpoch,
CachedBeaconStatePhase0,
Expand Down Expand Up @@ -34,7 +35,9 @@ describe("phase0 processSlashings", () => {
minRuns: 5, // Worst case is very slow
before: () => getProcessSlashingsTestData(indicesToSlashLen),
beforeEach: ({state, cache}) => ({state: state.clone(), cache}),
fn: ({state, cache}) => processSlashings(state as CachedBeaconStatePhase0, cache),
fn: ({state, cache}) => {
processSlashings(state as CachedBeaconStatePhase0, cache, false);
},
});
}
});
Expand All @@ -48,6 +51,11 @@ function getProcessSlashingsTestData(indicesToSlashLen: number): {
} {
const state = generatePerfTestCachedStatePhase0({goBackOneSlot: true});
const cache = beforeProcessEpoch(state);
state.slashings.set(0, indicesToSlashLen * MAX_EFFECTIVE_BALANCE);
for (let i = 1; i < state.slashings.length; i++) {
state.slashings.set(i, MAX_EFFECTIVE_BALANCE);
}
state.commit();

cache.indicesToSlash = linspace(indicesToSlashLen);

Expand Down
2 changes: 1 addition & 1 deletion packages/state-transition/test/utils/capella.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function modifyStateSameValidator(seedState: BeaconStateCapella): BeaconS
state.eth1DepositIndex = 1000;
state.balances.set(0, 30);
state.randaoMixes.set(0, crypto.randomBytes(32));
state.slashings.set(0, 1n);
state.slashings.set(0, 1);
state.previousEpochParticipation.set(0, 0b11111110);
state.currentEpochParticipation.set(0, 0b11111110);
state.justificationBits.set(0, true);
Expand Down
4 changes: 2 additions & 2 deletions packages/state-transition/test/utils/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {config} from "@lodestar/config/default";

import {createBeaconConfig, ChainForkConfig} from "@lodestar/config";
import {ZERO_HASH} from "../../src/constants/index.js";
import {newZeroedBigIntArray} from "../../src/util/index.js";
import {newZeroedArray} from "../../src/util/index.js";

import {
BeaconStatePhase0,
Expand Down Expand Up @@ -64,7 +64,7 @@ export function generateState(opts?: TestBeaconState): BeaconStatePhase0 {
validators: [],
balances: [],
randaoMixes: Array.from({length: EPOCHS_PER_HISTORICAL_VECTOR}, () => ZERO_HASH),
slashings: newZeroedBigIntArray(EPOCHS_PER_SLASHINGS_VECTOR),
slashings: newZeroedArray(EPOCHS_PER_SLASHINGS_VECTOR),
previousEpochAttestations: [],
currentEpochAttestations: [],
justificationBits: ssz.phase0.JustificationBits.defaultValue(),
Expand Down
10 changes: 8 additions & 2 deletions packages/types/src/phase0/sszTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const {
EpochInf,
CommitteeIndex,
ValidatorIndex,
Gwei,
Root,
Version,
ForkDigest,
Expand Down Expand Up @@ -248,7 +247,14 @@ export const Validator = ValidatorNodeStruct;
export const Validators = new ListCompositeType(ValidatorNodeStruct, VALIDATOR_REGISTRY_LIMIT);
export const Balances = new ListBasicType(UintNum64, VALIDATOR_REGISTRY_LIMIT);
export const RandaoMixes = new VectorCompositeType(Bytes32, EPOCHS_PER_HISTORICAL_VECTOR);
export const Slashings = new VectorBasicType(Gwei, EPOCHS_PER_SLASHINGS_VECTOR);
/**
* This is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because:
* - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset()
* - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet
* - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474
* - we don't need to compute the total slashings from state.slashings, it's handled by totalSlashingsByIncrement in EpochCache
*/
export const Slashings = new VectorBasicType(UintNum64, EPOCHS_PER_SLASHINGS_VECTOR);
export const JustificationBits = new BitVectorType(JUSTIFICATION_BITS_LENGTH);

// Misc dependants
Expand Down