diff --git a/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java b/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java new file mode 100644 index 00000000000..6ec01697721 --- /dev/null +++ b/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java @@ -0,0 +1,383 @@ +/* + * Copyright Consensys Software Inc., 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.statetransition.forkchoice.fastconfirmation; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import org.apache.tuweni.bytes.Bytes32; +import org.apache.tuweni.bytes.Bytes48; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import tech.pegasys.teku.bls.BLSPublicKey; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints; +import tech.pegasys.teku.spec.datastructures.blocks.SlotAndBlockRoot; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; +import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoicePayloadStatus; +import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData; +import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeValidationStatus; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteSnapshot; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; +import tech.pegasys.teku.spec.util.DataStructureUtil; + +/** + * Benchmarks the Fast Confirmation Rule catch-up cost (issue #10994 acceptance criterion): the + * ~2-epoch catch-up slot that ends the warm-up — {@code get_latest_confirmed} restarting the + * confirmed root from the observed justified checkpoint and advancing it across a whole epoch of + * blocks to the head — must complete comfortably inside one slot at mainnet scale. + * + *

Fixture: mainnet preset, a linear chain with one block at every slot of epochs 0 and 1, the + * current slot starting epoch 2, and every validator voting (90% for the head, 10% spread across + * the previous epoch to exercise the prefix binary search). Fork choice is a lightweight in-memory + * linear chain whose ancestor lookups walk the parent chain the way protoarray does, so per-check + * costs are comparable to production. The head state is built directly in the current epoch, so the + * benchmark excludes the epoch-transition (pull-up) cost, which the scoring optimization does not + * affect. + * + *

{@code perBlockScoreTwoEpochChain} reproduces the pre-optimization cost model (one full + * validator pass per block) for direct comparison with {@code batchScoreTwoEpochChain} (one pass + * for the whole segment). + * + *

Quick run: {@code ./gradlew :ethereum:statetransition:jmh --args="FastConfirmationBenchmark -p + * validatorCount=1000000 -wi 1 -i 2 -f 0"} + */ +@Warmup(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@Fork( + value = 1, + jvmArgsAppend = {"-Xmx8g"}) +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +public class FastConfirmationBenchmark { + + private static final Spec SPEC = TestSpecFactory.createMainnetPhase0(); + private static final int SLOTS_PER_EPOCH = SPEC.getGenesisSpecConfig().getSlotsPerEpoch(); + + /** One block at every slot of epochs 0 and 1; the current slot is the first of epoch 2. */ + private static final int CHAIN_LENGTH = SLOTS_PER_EPOCH * 2; + + private static final UInt64 CURRENT_SLOT = UInt64.valueOf(CHAIN_LENGTH); + + @Param({"100000", "1000000"}) + private int validatorCount; + + private BeaconState balanceSource; + private FastConfirmationStore fcrStore; + private FastConfirmationStates states; + private Bytes32 head; + private List scoredChain; + + @Setup(Level.Trial) + public void init() { + final Random random = new Random(2718281828L); + // Pubkeys are never used by the FCR, so generate cheap (lazily parsed) random keys instead of + // paying real BLS key generation for a mainnet-sized validator set. + final DataStructureUtil dataStructureUtil = + new DataStructureUtil(SPEC) + .withPubKeyGenerator( + () -> { + final byte[] keyBytes = new byte[48]; + random.nextBytes(keyBytes); + return BLSPublicKey.fromBytesCompressed(Bytes48.wrap(keyBytes)); + }); + balanceSource = + dataStructureUtil.randomBeaconStateWithActiveValidators(validatorCount, CURRENT_SLOT); + + final List chain = new ArrayList<>(CHAIN_LENGTH); + for (int slot = 0; slot < CHAIN_LENGTH; slot++) { + chain.add(Bytes32.random(random)); + } + head = chain.get(CHAIN_LENGTH - 1); + final Bytes32 previousSlotHead = chain.get(CHAIN_LENGTH - 2); + + final Checkpoint finalized = new Checkpoint(UInt64.ZERO, chain.get(0)); + final Checkpoint observedJustified = new Checkpoint(UInt64.ONE, chain.get(SLOTS_PER_EPOCH)); + // Every block reports the epoch-1 checkpoint as its unrealized justification, matching the + // healthy-network shape the restart branch expects. + final BlockCheckpoints blockCheckpoints = + new BlockCheckpoints(finalized, finalized, observedJustified, finalized); + final LinearChainForkChoiceStrategy forkChoice = + new LinearChainForkChoiceStrategy(chain, blockCheckpoints); + + final VoteTracker[] votes = new VoteTracker[validatorCount]; + for (int index = 0; index < validatorCount; index++) { + final Bytes32 votedRoot = + index % 10 == 0 + ? chain.get(SLOTS_PER_EPOCH + 1 + ((index / 10) % (SLOTS_PER_EPOCH - 2))) + : head; + votes[index] = new VoteTracker(Bytes32.ZERO, votedRoot); + } + final VoteSnapshot voteSnapshot = + VoteSnapshot.create(UInt64.valueOf(validatorCount - 1), votes); + + final ReadOnlyStore store = mock(ReadOnlyStore.class); + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); + when(store.getVoteSnapshot()).thenReturn(voteSnapshot); + when(store.getFinalizedCheckpoint()).thenReturn(finalized); + + // Warm-up exit shape: the confirmed root is still the (2-epoch-old) finalized block, so + // get_latest_confirmed reverts it, restarts it from the observed justified checkpoint at the + // epoch-1 start, and advances it across the whole previous epoch to the head. + fcrStore = + new FastConfirmationStore( + store, + chain.get(0), + finalized, + observedJustified, + observedJustified, + previousSlotHead, + head); + states = new FastConfirmationStates(Optional.of(balanceSource), balanceSource, balanceSource); + // The full 2-epoch segment above the finalized block, for the scoring benchmarks. + scoredChain = List.copyOf(chain.subList(1, CHAIN_LENGTH)); + + // Fail fast if the fixture stops exercising the full catch-up path. + final long startNanos = System.nanoTime(); + final Bytes32 confirmed = newCalculator().getLatestConfirmed(); + if (!confirmed.equals(head)) { + throw new IllegalStateException( + "Benchmark fixture did not advance the confirmed root to the head: " + confirmed); + } + System.out.printf( + "init done: %d validators, cold 2-epoch catch-up took %d ms%n", + validatorCount, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos)); + } + + /** The acceptance-criterion scenario: must average comfortably below one slot (12s). */ + @Benchmark + public void twoEpochCatchUpGetLatestConfirmed(final Blackhole bh) { + bh.consume(newCalculator().getLatestConfirmed()); + } + + /** The optimized core: the whole 2-epoch segment scored in one validator pass. */ + @Benchmark + public void batchScoreTwoEpochChain(final Blackhole bh) { + bh.consume(newCalculator().computeChainAttestationScores(scoredChain, balanceSource)); + } + + /** Pre-optimization cost model: one full validator pass per block of the segment. */ + @Benchmark + public void perBlockScoreTwoEpochChain(final Blackhole bh) { + final FastConfirmationCalculator calculator = newCalculator(); + for (final Bytes32 blockRoot : scoredChain) { + bh.consume(calculator.getAttestationScore(blockRoot, balanceSource)); + } + } + + private FastConfirmationCalculator newCalculator() { + return new FastConfirmationCalculator(SPEC, fcrStore, states, CURRENT_SLOT); + } + + public static void main(final String[] args) { + final FastConfirmationBenchmark benchmark = new FastConfirmationBenchmark(); + benchmark.validatorCount = args.length > 0 ? Integer.parseInt(args[0]) : 1_000_000; + benchmark.init(); + for (int i = 0; i < 5; i++) { + final long catchUpStart = System.nanoTime(); + benchmark.newCalculator().getLatestConfirmed(); + final long batchStart = System.nanoTime(); + benchmark + .newCalculator() + .computeChainAttestationScores(benchmark.scoredChain, benchmark.balanceSource); + final long perBlockStart = System.nanoTime(); + final FastConfirmationCalculator calculator = benchmark.newCalculator(); + for (final Bytes32 blockRoot : benchmark.scoredChain) { + calculator.getAttestationScore(blockRoot, benchmark.balanceSource); + } + final long endNanos = System.nanoTime(); + System.out.printf( + "catch-up %d ms, batch score %d ms, per-block score (old model) %d ms%n", + TimeUnit.NANOSECONDS.toMillis(batchStart - catchUpStart), + TimeUnit.NANOSECONDS.toMillis(perBlockStart - batchStart), + TimeUnit.NANOSECONDS.toMillis(endNanos - perBlockStart)); + } + } + + /** + * Minimal linear-chain fork-choice view: one block per slot, parent = previous slot. Ancestor + * lookups walk the chain slot by slot the way protoarray walks parent links, so per-check cost is + * comparable to production. Only the methods the fast confirmation calculator touches are + * implemented. + */ + private static final class LinearChainForkChoiceStrategy implements ReadOnlyForkChoiceStrategy { + private final List rootBySlot; + private final Map slotByRoot = new HashMap<>(); + private final BlockCheckpoints blockCheckpoints; + + private LinearChainForkChoiceStrategy( + final List rootBySlot, final BlockCheckpoints blockCheckpoints) { + this.rootBySlot = rootBySlot; + this.blockCheckpoints = blockCheckpoints; + for (int slot = 0; slot < rootBySlot.size(); slot++) { + slotByRoot.put(rootBySlot.get(slot), slot); + } + } + + @Override + public Optional blockSlot(final Bytes32 blockRoot) { + return Optional.ofNullable(slotByRoot.get(blockRoot)).map(UInt64::valueOf); + } + + @Override + public Optional blockParentRoot(final Bytes32 blockRoot) { + final Integer slot = slotByRoot.get(blockRoot); + if (slot == null) { + return Optional.empty(); + } + return Optional.of(slot == 0 ? Bytes32.ZERO : rootBySlot.get(slot - 1)); + } + + @Override + public Optional getAncestor(final Bytes32 blockRoot, final UInt64 slot) { + final Integer blockSlot = slotByRoot.get(blockRoot); + if (blockSlot == null) { + return Optional.empty(); + } + // Walk down one slot at a time like protoarray's parent-pointer walk. + int currentSlot = blockSlot; + while (UInt64.valueOf(currentSlot).isGreaterThan(slot)) { + currentSlot--; + } + return Optional.of(rootBySlot.get(currentSlot)); + } + + @Override + public Optional getAncestorNode(final ForkChoiceNode node, final UInt64 slot) { + return getAncestor(node.blockRoot(), slot).map(ForkChoiceNode::createBase); + } + + @Override + public boolean contains(final Bytes32 blockRoot) { + return slotByRoot.containsKey(blockRoot); + } + + @Override + public boolean isFullyValidated(final Bytes32 blockRoot) { + return contains(blockRoot); + } + + @Override + public Optional isOptimistic(final Bytes32 blockRoot) { + return Optional.of(false); + } + + @Override + public Optional getBlockData(final Bytes32 blockRoot) { + return blockSlot(blockRoot) + .map( + slot -> + new ProtoNodeData( + slot, + blockRoot, + blockParentRoot(blockRoot).orElse(Bytes32.ZERO), + Bytes32.ZERO, + UInt64.ZERO, + Bytes32.ZERO, + UInt64.ZERO, + ProtoNodeValidationStatus.VALID, + blockCheckpoints, + UInt64.ZERO, + ForkChoicePayloadStatus.PAYLOAD_STATUS_PENDING)); + } + + @Override + public List getBlockData() { + return rootBySlot.stream().map(root -> getBlockData(root).orElseThrow()).toList(); + } + + @Override + public Optional executionBlockNumber(final Bytes32 blockRoot) { + throw notUsed(); + } + + @Override + public Optional executionBlockHash(final Bytes32 blockRoot) { + throw notUsed(); + } + + @Override + public Optional getParentBeaconBlockNode(final ForkChoiceNode node) { + throw notUsed(); + } + + @Override + public Optional findCommonAncestor( + final Bytes32 blockRoot1, final Bytes32 blockRoot2) { + throw notUsed(); + } + + @Override + public List getBlockRootsAtSlot(final UInt64 slot) { + throw notUsed(); + } + + @Override + public List getChainHeads(final boolean includeNonViableHeads) { + throw notUsed(); + } + + @Override + public Optional getOptimisticallySyncedTransitionBlockRoot(final Bytes32 head) { + throw notUsed(); + } + + @Override + public boolean shouldExtendPayload( + final ReadOnlyStore store, final SlotAndBlockRoot slotAndBlockRoot) { + throw notUsed(); + } + + @Override + public boolean shouldBuildOnFull( + final ReadOnlyStore store, final UInt64 slot, final ForkChoiceNode head) { + throw notUsed(); + } + + @Override + public Optional getWeight(final Bytes32 blockRoot) { + throw notUsed(); + } + + private static UnsupportedOperationException notUsed() { + return new UnsupportedOperationException("Not used by the fast confirmation calculator"); + } + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java index e130c56dadb..4c81a7a6c21 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java @@ -13,12 +13,18 @@ package tech.pegasys.teku.statetransition.forkchoice.fastconfirmation; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Deque; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -72,6 +78,22 @@ class FastConfirmationCalculator { // Lazily computed once per instance (single-threaded per slot); see getPulledUpHeadState. private BeaconState pulledUpHeadState; + // Per-instance (per-slot) memoization. The safety-threshold helpers query the same slot + // committees and slot ranges for every block they score (and re-evaluate a block across the two + // walk phases), the justifiability gates recompute the current target score and honest FFG + // support, and the equivocator set is fixed for the slot's vote snapshot; single-threaded per + // slot, so plain fields suffice. The per-balance-source maps are identity-keyed: a calculator + // sees at most two source states, and value equality on BeaconState would hash the whole state. + private final Map slotCommitteeBySlot = new HashMap<>(); + private final Map committeeByRange = new HashMap<>(); + private final IdentityHashMap> adversarialWeightBySource = + new IdentityHashMap<>(); + private final IdentityHashMap> safetyThresholdBySource = + new IdentityHashMap<>(); + private IntList equivocatingValidatorIndices; + private UInt64 currentTargetScore; + private UInt64 honestFfgSupportForCurrentTarget; + FastConfirmationCalculator( final Spec spec, final FastConfirmationStore fcrStore, @@ -147,15 +169,23 @@ BeaconState getPulledUpHeadState() { * epoch behind (a long run of empty slots) would therefore throw {@code StateTooOldException} for * current-epoch slots and abort confirmation. The intervening slots are empty, so advancing the * head state through them is deterministic and yields the same shuffling the real chain would. + * + *

Memoized per slot: the ranges queried while scoring successive blocks overlap heavily, and + * the shuffling source is fixed for the whole run. */ IntSet getSlotCommittee(final UInt64 slot) { + return slotCommitteeBySlot.computeIfAbsent(slot, this::computeSlotCommittee); + } + + private IntSet computeSlotCommittee(final UInt64 slot) { final BeaconState shufflingSource = getPulledUpHeadState(); final UInt64 epoch = spec.computeEpochAtSlot(slot); - final int committeesCount = spec.getCommitteeCountPerSlot(shufflingSource, epoch).intValue(); + final UInt64 committeesCount = spec.getCommitteeCountPerSlot(shufflingSource, epoch); final IntSet participants = new IntOpenHashSet(); - for (int committeeIndex = 0; committeeIndex < committeesCount; committeeIndex++) { - participants.addAll( - spec.getBeaconCommittee(shufflingSource, slot, UInt64.valueOf(committeeIndex))); + for (UInt64 committeeIndex = UInt64.ZERO; + committeeIndex.isLessThan(committeesCount); + committeeIndex = committeeIndex.increment()) { + participants.addAll(spec.getBeaconCommittee(shufflingSource, slot, committeeIndex)); } return participants; } @@ -203,6 +233,96 @@ UInt64 getAttestationScore(final Bytes32 nodeRoot, final BeaconState balanceSour return score; } + /** + * Computes {@code get_attestation_score} for every block of {@code chainRoots} (blocks of a + * single chain, ordered oldest first) in one pass over the active validator set, instead of one + * pass per block. + * + *

Because the blocks lie on one chain, the blocks a vote supports form a prefix of the list: + * supporting a descendant of a block implies supporting a descendant of every earlier block. + * (Ancestry is checked against base/PENDING nodes, exactly as {@link #getAttestationScore} does, + * and under Gloas a PENDING ancestor matches any payload status, so this is plain block ancestry + * — transitive along the chain on all forks.) Each vote is therefore resolved once and bucketed + * at the latest chain block it supports; a block's score is the sum of the buckets from its own + * position onward. + */ + Map computeChainAttestationScores( + final List chainRoots, final BeaconState balanceSource) { + if (chainRoots.isEmpty()) { + return Map.of(); + } + final List chainNodes = chainRoots.stream().map(this::getNodeForRoot).toList(); + // supportByLatestIndex[i]: total balance of votes whose latest supported chain block is i. + final UInt64[] supportByLatestIndex = new UInt64[chainNodes.size()]; + Arrays.fill(supportByLatestIndex, UInt64.ZERO); + + final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource); + final SszList validators = balanceSource.getValidators(); + for (final int index : spec.getActiveValidatorIndices(balanceSource, balanceSourceEpoch)) { + final Validator validator = validators.get(index); + if (validator.isSlashed()) { + continue; + } + final VoteTracker vote = votes.getVote(index); + if (vote.isEquivocating()) { + continue; + } + final Bytes32 votedRoot = vote.getNextRoot(); + // A zero root means the validator is not in store.latest_messages. + if (votedRoot.isZero()) { + continue; + } + final Optional maybeVotedNode = + forkChoice.getSupportedNode( + currentSlot, votedRoot, vote.getNextSlot(), vote.isNextFullPayloadHint()); + if (maybeVotedNode.isEmpty()) { + continue; + } + final int latestSupported = + findLatestSupportedChainIndex(chainNodes, maybeVotedNode.orElseThrow()); + if (latestSupported >= 0) { + supportByLatestIndex[latestSupported] = + supportByLatestIndex[latestSupported].plus(validator.getEffectiveBalance()); + } + } + + // score(chainRoots[i]) = votes supporting chainRoots[i] or any later chain block. + final Map scores = HashMap.newHashMap(chainRoots.size()); + UInt64 runningScore = UInt64.ZERO; + for (int i = chainRoots.size() - 1; i >= 0; i--) { + runningScore = runningScore.plus(supportByLatestIndex[i]); + scores.put(chainRoots.get(i), runningScore); + } + return scores; + } + + /** + * The index of the latest (highest-slot) chain block the vote supports (i.e. of which the voted + * node is a descendant), or {@code -1} when it supports none. Tests the newest block first — the + * common case, since most latest messages vote at or near the head and so support the whole chain + * — and otherwise binary-searches the boundary of the supported prefix. + */ + int findLatestSupportedChainIndex( + final List chainNodes, final ForkChoiceNode votedNode) { + final int last = chainNodes.size() - 1; + if (isAncestor(votedNode, chainNodes.get(last))) { + return last; + } + int latestSupported = -1; + int low = 0; + int high = last - 1; + while (low <= high) { + final int mid = (low + high) >>> 1; + if (isAncestor(votedNode, chainNodes.get(mid))) { + latestSupported = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return latestSupported; + } + /** * Implements {@code get_block_support_between_slots}: the total effective balance (per {@code * balanceSource}) of unslashed, active, non-equivocating validators assigned to the inclusive @@ -233,14 +353,24 @@ UInt64 getBlockSupportBetweenSlots( * Implements {@code get_equivocation_score}: the total effective balance (per {@code * balanceSource}) of active, equivocating validators assigned to the inclusive slot range. Per * spec, slashed validators are not filtered out here (they are very likely already equivocating). + * + *

Iterates the (almost always empty) equivocator set and checks each member's committee + * assignment, rather than materializing the committees of the whole slot range and filtering them + * for equivocators as the spec does. The result is identical — both select the validators that + * are equivocating and assigned to the range — but no slot committee is computed at all in the + * common no-equivocation case, which makes {@code compute_adversarial_weight} pure arithmetic. */ UInt64 getEquivocationScore( final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) { + final IntList equivocatingIndices = getEquivocatingValidatorIndices(); + if (equivocatingIndices.isEmpty()) { + return UInt64.ZERO; + } final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource); final SszList validators = balanceSource.getValidators(); UInt64 score = UInt64.ZERO; - for (final int index : getCommitteeBetweenSlots(startSlot, endSlot)) { - if (!votes.getVote(index).isEquivocating()) { + for (final int index : equivocatingIndices) { + if (!isInCommitteeBetweenSlots(index, startSlot, endSlot)) { continue; } final Validator validator = validators.get(index); @@ -251,13 +381,50 @@ UInt64 getEquivocationScore( return score; } + /** Indices of validators marked equivocating in the vote snapshot, collected once per slot. */ + private IntList getEquivocatingValidatorIndices() { + if (equivocatingValidatorIndices == null) { + final IntArrayList indices = new IntArrayList(); + for (int index = 0; index < votes.size(); index++) { + if (votes.getVote(index).isEquivocating()) { + indices.add(index); + } + } + equivocatingValidatorIndices = indices; + } + return equivocatingValidatorIndices; + } + + /** Whether the validator is assigned to any committee of the inclusive slot range. */ + private boolean isInCommitteeBetweenSlots( + final int validatorIndex, final UInt64 startSlot, final UInt64 endSlot) { + for (UInt64 slot = startSlot; slot.isLessThanOrEqualTo(endSlot); slot = slot.increment()) { + if (getSlotCommittee(slot).contains(validatorIndex)) { + return true; + } + } + return false; + } + /** * Implements {@code compute_adversarial_weight}: the maximum weight that could be adversarial in * the committees of the slot range, assuming {@code CONFIRMATION_BYZANTINE_THRESHOLD} and * discounting validators already known to be equivocating. + * + *

Memoized per (balance source, slot range): the honest-FFG gate re-queries the current + * epoch's range on every call, and blocks re-evaluated across the walk phases repeat theirs. */ UInt64 computeAdversarialWeight( final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) { + return adversarialWeightBySource + .computeIfAbsent(balanceSource, __ -> new HashMap<>()) + .computeIfAbsent( + new SlotRange(startSlot, endSlot), + range -> calculateAdversarialWeight(balanceSource, range.startSlot(), range.endSlot())); + } + + private UInt64 calculateAdversarialWeight( + final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) { final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); final UInt64 maximumWeight = FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( @@ -316,8 +483,21 @@ UInt64 getSupportDiscount(final BeaconState balanceSource, final Bytes32 blockRo return computeEmptySlotSupportDiscount(balanceSource, blockRoot); } - /** Implements {@code compute_safety_threshold}: the LMD-GHOST safety threshold for the block. */ + /** + * Implements {@code compute_safety_threshold}: the LMD-GHOST safety threshold for the block. + * + *

Memoized per (balance source, block): the block that stops the previous-epoch walk is + * re-evaluated by the current-epoch walk, repeating the support-discount and adversarial-weight + * range work below. + */ UInt64 computeSafetyThreshold(final Bytes32 blockRoot, final BeaconState balanceSource) { + return safetyThresholdBySource + .computeIfAbsent(balanceSource, __ -> new HashMap<>()) + .computeIfAbsent(blockRoot, root -> calculateSafetyThreshold(root, balanceSource)); + } + + private UInt64 calculateSafetyThreshold( + final Bytes32 blockRoot, final BeaconState balanceSource) { final UInt64 parentSlot = getBlockSlot(getBlockParentRoot(blockRoot)); final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); final UInt64 proposerScore = spec.getProposerBoostAmount(balanceSource); @@ -344,7 +524,19 @@ boolean isOneConfirmed(final BeaconState balanceSource, final Bytes32 blockRoot) if (!isValidForConfirmation(blockRoot)) { return false; } - final UInt64 support = getAttestationScore(blockRoot, balanceSource); + return isOneConfirmedWithSupport( + balanceSource, blockRoot, getAttestationScore(blockRoot, balanceSource)); + } + + /** + * {@code is_one_confirmed} evaluated against a precomputed attestation score (see {@link + * #computeChainAttestationScores}); otherwise identical to {@link #isOneConfirmed}. + */ + private boolean isOneConfirmedWithSupport( + final BeaconState balanceSource, final Bytes32 blockRoot, final UInt64 support) { + if (!isValidForConfirmation(blockRoot)) { + return false; + } final UInt64 safetyThreshold = computeSafetyThreshold(blockRoot, balanceSource); return support.isGreaterThan(safetyThreshold); } @@ -368,8 +560,19 @@ private boolean isValidForConfirmation(final Bytes32 blockRoot) { /** * Implements {@code get_current_target_score}: the estimated FFG support of the current-epoch * target, using the pulled-up head state's validator set and the LMD votes received so far. + * + *

Memoized: a full pass over the active validator set that the justifiability gates ({@code + * will_no_conflicting_checkpoint_be_justified}, {@code will_current_target_be_justified}) would + * otherwise recompute several times per slot against the same snapshot. */ UInt64 getCurrentTargetScore() { + if (currentTargetScore == null) { + currentTargetScore = computeCurrentTargetScore(); + } + return currentTargetScore; + } + + private UInt64 computeCurrentTargetScore() { final Checkpoint target = getCurrentTarget(); final BeaconState state = getPulledUpHeadState(); final UInt64 epoch = spec.getCurrentEpoch(state); @@ -411,8 +614,18 @@ UInt64 getCurrentTargetScore() { * Implements {@code compute_honest_ffg_support_for_current_target}: the minimum honest FFG * support the current-epoch target can be assured of, assuming synchrony and {@code * CONFIRMATION_BYZANTINE_THRESHOLD}. + * + *

Memoized: both justifiability gates derive from it and can each run more than once per slot + * against the same snapshot. */ UInt64 computeHonestFfgSupportForCurrentTarget() { + if (honestFfgSupportForCurrentTarget == null) { + honestFfgSupportForCurrentTarget = calculateHonestFfgSupportForCurrentTarget(); + } + return honestFfgSupportForCurrentTarget; + } + + private UInt64 calculateHonestFfgSupportForCurrentTarget() { final BeaconState balanceSource = getPulledUpHeadState(); final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); final UInt64 ffgSupportForCheckpoint = getCurrentTargetScore(); @@ -496,8 +709,13 @@ boolean isConfirmedChainSafe(final Bytes32 confirmedRoot) { () -> new IllegalStateException( "Previous balance source is required for reconfirmation")); - return getAncestorRoots(confirmedRoot, startRootExclusive).stream() - .allMatch(root -> isOneConfirmed(previousBalanceSource, root)); + final List chainToReconfirm = getAncestorRoots(confirmedRoot, startRootExclusive); + // Score the whole chain in a single pass over the validator set instead of one per block. + final Map chainScores = + computeChainAttestationScores(chainToReconfirm, previousBalanceSource); + return chainToReconfirm.stream() + .allMatch( + root -> isOneConfirmedWithSupport(previousBalanceSource, root, chainScores.get(root))); } /** @@ -513,6 +731,13 @@ Bytes32 findLatestConfirmedDescendant(final Bytes32 latestConfirmedRoot) { final BeaconState currentBalanceSource = states.currentBalanceSource(); Bytes32 confirmedRoot = latestConfirmedRoot; + // Both walk phases score blocks from this one chain (the second phase from a suffix of it), so + // their attestation scores are computed together in a single pass over the validator set on + // first need (see computeChainAttestationScores) instead of one pass per block. Left null until + // a phase actually scores, so slots where neither gate passes do no scoring work at all. + final List candidateChain = getAncestorRoots(head, latestConfirmedRoot); + Map chainScores = null; + // The previous slot head is a root persisted in the FCR store across slots, so it may have been // pruned from fork choice (protoarray only keeps finalized-onward blocks, while the spec // assumes @@ -528,8 +753,9 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2) getUnrealizedJustification(previousSlotHead).getEpoch(), 1) || epochPlusIsAtLeastCurrent( getUnrealizedJustification(head).getEpoch(), 1))))) { + chainScores = computeChainAttestationScores(candidateChain, currentBalanceSource); // Advance towards the head over previous-epoch blocks; stop at the first unconfirmed one. - for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) { + for (final Bytes32 blockRoot : candidateChain) { // Only meant to confirm previous-epoch blocks. if (getBlockEpoch(blockRoot).equals(currentEpoch)) { break; @@ -538,7 +764,8 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2) if (!isAncestor(previousSlotHead, blockRoot)) { break; } - if (!isOneConfirmed(currentBalanceSource, blockRoot)) { + if (!isOneConfirmedWithSupport( + currentBalanceSource, blockRoot, chainScores.get(blockRoot))) { break; } confirmedRoot = blockRoot; @@ -546,14 +773,20 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2) } if (atEpochStart || epochPlusIsAtLeastCurrent(getUnrealizedJustification(head).getEpoch(), 1)) { + if (chainScores == null) { + chainScores = computeChainAttestationScores(candidateChain, currentBalanceSource); + } Bytes32 tentativeConfirmedRoot = confirmedRoot; + // A suffix of candidateChain: confirmedRoot only ever advances along it, so every walked + // block already has a precomputed score. for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) { // Only true the first time the walk advances into the current epoch. if (getBlockEpoch(blockRoot).isGreaterThan(getBlockEpoch(tentativeConfirmedRoot)) && !willCurrentTargetBeJustified()) { break; } - if (!isOneConfirmed(currentBalanceSource, blockRoot)) { + if (!isOneConfirmedWithSupport( + currentBalanceSource, blockRoot, chainScores.get(blockRoot))) { break; } tentativeConfirmedRoot = blockRoot; @@ -650,8 +883,15 @@ private Checkpoint getStoreUnrealizedJustifiedCheckpoint() { /** * Union of {@code get_slot_committee} over the inclusive slot range {@code [startSlot, endSlot]}. + * Memoized per range, so a re-evaluated block does not rebuild its union. */ private IntSet getCommitteeBetweenSlots(final UInt64 startSlot, final UInt64 endSlot) { + return committeeByRange.computeIfAbsent( + new SlotRange(startSlot, endSlot), + range -> computeCommitteeBetweenSlots(range.startSlot(), range.endSlot())); + } + + private IntSet computeCommitteeBetweenSlots(final UInt64 startSlot, final UInt64 endSlot) { final IntSet participants = new IntOpenHashSet(); for (UInt64 slot = startSlot; slot.isLessThanOrEqualTo(endSlot); slot = slot.increment()) { participants.addAll(getSlotCommittee(slot)); @@ -757,4 +997,7 @@ private BlockCheckpoints getCheckpoints(final Bytes32 blockRoot) { .map(ProtoNodeData::getCheckpoints) .orElseThrow(() -> new IllegalStateException("Missing checkpoints for " + blockRoot)); } + + /** Inclusive slot range used as a memoization key. */ + private record SlotRange(UInt64 startSlot, UInt64 endSlot) {} } diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java index 35f0220b115..301186a7e65 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java @@ -172,13 +172,13 @@ private SafeFuture withSegmentTimeout(final SafeFuture segment, fina * *

Relaxed to {@link #WARM_UP_SEGMENT_TIMEOUT_SLOTS} slots while the tracker is warming up. The * update that ends the warm-up advances {@code confirmed_root} across every block accumulated - * while it was pinned to finality — up to two epochs — and {@code - * find_latest_confirmed_descendant} scores each of those blocks over the whole active validator - * set, so that single slot can exceed a one-slot budget on a large network. It is a one-off: in - * steady state the walk covers a single block. Timing out here would not stop the work (the - * timeout does not cancel it) but would log it as a failure and send the slot's fcU late, so the - * bound is widened for the catch-up instead. A stopgap — the real fix is to score the whole chain - * segment in one pass over the validator set. + * while it was pinned to finality — up to two epochs. {@code find_latest_confirmed_descendant} + * scores that whole chain in a single pass over the active validator set (see {@code + * FastConfirmationCalculator#computeChainAttestationScores}), but the catch-up slot remains the + * heaviest one on a large network, so the widened bound is kept as a safety margin. It is a + * one-off: in steady state the walk covers a single block. Timing out here would not stop the + * work (the timeout does not cancel it) but would log it as a failure and send the slot's fcU + * late. */ private Duration segmentTimeout(final UInt64 slot) { final long slotDurationMillis = spec.getSlotDurationMillis(slot); diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java index 69e90172647..757f3a479a7 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -26,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.LongStream; import org.apache.tuweni.bytes.Bytes32; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,12 +55,18 @@ class FastConfirmationCalculatorTest { private final ReadOnlyStore store = mock(ReadOnlyStore.class); private final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class); - // A canonical linear chain where block at index i has slot i and parent = chain(i - 1). + // A canonical linear chain where block at index i has parent = chain(i - 1); slots are + // ascending but not necessarily consecutive (see buildLinearChainAtSlots). private final List chain = new ArrayList<>(); - private final Map slotByRoot = new HashMap<>(); + private final List chainSlots = new ArrayList<>(); + private final Map indexByRoot = new HashMap<>(); @BeforeEach void setUp() { + chain.clear(); + chainSlots.clear(); + indexByRoot.clear(); + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); // Default to no votes; the weight tests re-stub with specific votes before building a // calculator. @@ -182,8 +190,9 @@ void shouldUnionAllSlotCommitteesToTheActiveValidatorSetOverAnEpoch() { final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0); final IntSet allCommitteeMembers = new IntOpenHashSet(); - for (int slot = 0; slot < spec.getSlotsPerEpoch(UInt64.ZERO); slot++) { - allCommitteeMembers.addAll(calculator.getSlotCommittee(UInt64.valueOf(slot))); + final UInt64 slotsPerEpoch = UInt64.valueOf(spec.getSlotsPerEpoch(UInt64.ZERO)); + for (UInt64 slot = UInt64.ZERO; slot.isLessThan(slotsPerEpoch); slot = slot.increment()) { + allCommitteeMembers.addAll(calculator.getSlotCommittee(slot)); } // Every active validator is assigned to exactly one committee per epoch, so the union of all @@ -228,6 +237,200 @@ void shouldSumAttestationScoreForNonEquivocatingUnslashedVotersSupportingADescen assertThat(calculator.getAttestationScore(chain.get(3), balanceSource)).isEqualTo(expected); } + @Test + void shouldComputeChainAttestationScoresInOnePassMatchingPerBlockScores() { + buildLinearChain(8); + // Validator 5 votes for a descendant of the whole chain but is slashed -> excluded everywhere. + final BeaconState balanceSource = withSlashedValidator(genesisState(), 5); + final Bytes32 offChainRoot = Bytes32.random(); + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + 0, vote(chain.get(7)), // supports every scored block + 1, vote(chain.get(4)), // supports chain[3..4] only + 2, vote(chain.get(2)), // below the scored chain -> supports none of it + 3, equivocatingVote(chain.get(7)), // equivocating -> excluded + 4, vote(offChainRoot), // unknown to fork choice -> supports none + 5, vote(chain.get(7))))); // slashed -> excluded + final FastConfirmationCalculator calculator = calculator(chain.get(7), 7); + + final List scoredChain = + List.of(chain.get(3), chain.get(4), chain.get(5), chain.get(6), chain.get(7)); + final Map batchScores = + calculator.computeChainAttestationScores(scoredChain, balanceSource); + + assertThat(batchScores).containsOnlyKeys(scoredChain); + for (final Bytes32 root : scoredChain) { + assertThat(batchScores.get(root)) + .isEqualTo(calculator.getAttestationScore(root, balanceSource)); + } + } + + @Test + void shouldPrefixSumChainScoresFromVotesBucketedAtTheirLatestSupportedBlock() { + buildLinearChain(6); + final BeaconState balanceSource = genesisState(); + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + 0, vote(chain.get(5)), // latest supported: chain[5] -> last bucket + 1, vote(chain.get(3)), // latest supported: chain[3] + 2, vote(chain.get(2)), // latest supported: chain[2] -> first scored block + 3, vote(chain.get(0))))); // below the scored chain -> supports none + final FastConfirmationCalculator calculator = calculator(chain.get(5), 5); + + final List scoredChain = + List.of(chain.get(2), chain.get(3), chain.get(4), chain.get(5)); + final Map scores = + calculator.computeChainAttestationScores(scoredChain, balanceSource); + + // score(block) = sum of the buckets at the block and every later chain block. + final UInt64 b0 = effectiveBalance(balanceSource, 0); + final UInt64 b1 = effectiveBalance(balanceSource, 1); + final UInt64 b2 = effectiveBalance(balanceSource, 2); + assertThat(scores.get(chain.get(5))).isEqualTo(b0); + assertThat(scores.get(chain.get(4))).isEqualTo(b0); + assertThat(scores.get(chain.get(3))).isEqualTo(b0.plus(b1)); + assertThat(scores.get(chain.get(2))).isEqualTo(b0.plus(b1).plus(b2)); + } + + @Test + void shouldReturnNoChainScoresForAnEmptyChain() { + final FastConfirmationCalculator calculator = calculatorWithHeadState(genesisState(), 0); + + assertThat(calculator.computeChainAttestationScores(List.of(), genesisState())).isEmpty(); + } + + @Test + void shouldScoreSingleBlockChainLikePerBlockScoring() { + buildLinearChain(4); + final BeaconState balanceSource = genesisState(); + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + 0, vote(chain.get(3)), // descendant -> counts + 1, vote(chain.get(1))))); // ancestor -> excluded + final FastConfirmationCalculator calculator = calculator(chain.get(3), 3); + + final Map scores = + calculator.computeChainAttestationScores(List.of(chain.get(2)), balanceSource); + + assertThat(scores) + .containsExactly( + Map.entry(chain.get(2), calculator.getAttestationScore(chain.get(2), balanceSource))); + } + + @Test + void shouldExcludeChainScoreVoteWhoseSupportedNodeCannotBeResolved() { + buildLinearChain(4); + final BeaconState balanceSource = genesisState(); + final Bytes32 unresolvableRoot = Bytes32.random(); + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(Map.of(0, vote(unresolvableRoot), 1, vote(chain.get(3))))); + // get_supported_node cannot resolve the vote (e.g. the voted block was pruned). + when(forkChoice.getSupportedNode(any(), eq(unresolvableRoot), any(), anyBoolean())) + .thenReturn(Optional.empty()); + final FastConfirmationCalculator calculator = calculator(chain.get(3), 3); + + final Map scores = + calculator.computeChainAttestationScores( + List.of(chain.get(2), chain.get(3)), balanceSource); + + assertThat(scores.get(chain.get(2))).isEqualTo(effectiveBalance(balanceSource, 1)); + assertThat(scores.get(chain.get(3))).isEqualTo(effectiveBalance(balanceSource, 1)); + } + + @Test + void shouldFindLatestSupportedChainIndexAtEveryPrefixBoundary() { + buildLinearChain(7); + final FastConfirmationCalculator calculator = calculator(chain.get(6), 6); + // Scored chain: blocks 1..6 as base nodes (list index i holds block i + 1). + final List chainNodes = + chain.subList(1, 7).stream().map(ForkChoiceNode::createBase).toList(); + + // A vote for block p supports exactly the chain prefix up to block p: list index p - 1, and + // no block at all for p == 0. Walking every position covers the whole-chain fast path + // (p == 6) and every binary-search boundary in between. + for (int votedBlock = 0; votedBlock < 7; votedBlock++) { + final ForkChoiceNode votedNode = ForkChoiceNode.createBase(chain.get(votedBlock)); + assertThat(calculator.findLatestSupportedChainIndex(chainNodes, votedNode)) + .isEqualTo(votedBlock - 1); + } + } + + @Test + void shouldFindLatestSupportedChainIndexAcrossEmptySlotGaps() { + // Consecutive chain blocks separated by runs of empty slots. + buildLinearChainAtSlots(0, 1, 4, 8, 11); + final FastConfirmationCalculator calculator = calculator(chain.get(4), 11); + final List chainNodes = + chain.subList(1, 5).stream().map(ForkChoiceNode::createBase).toList(); + + // Same prefix-boundary sweep as the gap-free test: the search must follow block ancestry, + // with the empty slots skipped by the get_ancestor resolution. + for (int votedBlock = 0; votedBlock < 5; votedBlock++) { + final ForkChoiceNode votedNode = ForkChoiceNode.createBase(chain.get(votedBlock)); + assertThat(calculator.findLatestSupportedChainIndex(chainNodes, votedNode)) + .isEqualTo(votedBlock - 1); + } + } + + @Test + void shouldFindNoSupportedChainIndexForAVoteOutsideTheChain() { + buildLinearChain(4); + final FastConfirmationCalculator calculator = calculator(chain.get(3), 3); + final List chainNodes = + chain.subList(1, 4).stream().map(ForkChoiceNode::createBase).toList(); + + final ForkChoiceNode unknownNode = ForkChoiceNode.createBase(Bytes32.random()); + assertThat(calculator.findLatestSupportedChainIndex(chainNodes, unknownNode)).isEqualTo(-1); + } + + @Test + void shouldFindSupportedChainIndexOnSingleBlockChain() { + buildLinearChain(4); + final FastConfirmationCalculator calculator = calculator(chain.get(3), 3); + final List chainNodes = List.of(ForkChoiceNode.createBase(chain.get(2))); + + // Descendant (and the block itself) support it; an ancestor does not. + assertThat( + calculator.findLatestSupportedChainIndex( + chainNodes, ForkChoiceNode.createBase(chain.get(3)))) + .isEqualTo(0); + assertThat( + calculator.findLatestSupportedChainIndex( + chainNodes, ForkChoiceNode.createBase(chain.get(2)))) + .isEqualTo(0); + assertThat( + calculator.findLatestSupportedChainIndex( + chainNodes, ForkChoiceNode.createBase(chain.get(1)))) + .isEqualTo(-1); + } + + @Test + void shouldMemoizeSlotCommitteesWithinTheSlotRun() { + final BeaconState headState = genesisState(); + final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0); + + assertThat(calculator.getSlotCommittee(UInt64.ZERO)) + .isSameAs(calculator.getSlotCommittee(UInt64.ZERO)); + } + + @Test + void shouldSkipCommitteeComputationEntirelyWhenNoValidatorIsEquivocating() { + // No equivocating votes: the score is zero by definition and no slot committee is materialized + // — a committee query this far beyond the head state's epoch would otherwise throw. + final FastConfirmationCalculator calculator = calculatorWithHeadState(genesisState(), 0); + + assertThat( + calculator.getEquivocationScore( + genesisState(), UInt64.valueOf(1_000_000), UInt64.valueOf(1_000_001))) + .isEqualTo(UInt64.ZERO); + } + @Test void shouldResolveGloasVoteToSupportedNodeBeforeCheckingAncestry() { buildLinearChain(6); @@ -356,6 +559,43 @@ void shouldReduceAdversarialWeightByEquivocationScore() { assertThat(withEquivocation).isLessThan(withoutEquivocation); } + @Test + void shouldMemoizeAdversarialWeightPerSlotRange() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 10); + + // Identity, not just equality: the second call must return the memoized instance. + assertThat( + calculator.computeAdversarialWeight( + balanceSource, UInt64.valueOf(3), UInt64.valueOf(6))) + .isSameAs( + calculator.computeAdversarialWeight( + balanceSource, UInt64.valueOf(3), UInt64.valueOf(6))); + } + + @Test + void shouldMemoizeSafetyThresholdPerBlock() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5); + + // Identity, not just equality: the second call must return the memoized instance. + assertThat(calculator.computeSafetyThreshold(chain.get(3), balanceSource)) + .isSameAs(calculator.computeSafetyThreshold(chain.get(3), balanceSource)); + } + + @Test + void shouldMemoizeHonestFfgSupportForCurrentTarget() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 12); + + // Identity, not just equality: the second call must return the memoized instance. + assertThat(calculator.computeHonestFfgSupportForCurrentTarget()) + .isSameAs(calculator.computeHonestFfgSupportForCurrentTarget()); + } + @Test void shouldDiscountParentSupportInEmptySlotsBeyondAdversarialWeight() { final BeaconState balanceSource = genesisState(); @@ -787,16 +1027,22 @@ private FastConfirmationCalculator gloasCalculator( } private void buildLinearChain(final int length) { - for (int slot = 0; slot < length; slot++) { + buildLinearChainAtSlots(LongStream.range(0, length).toArray()); + } + + /** A canonical linear chain with one block per given (ascending) slot; gaps are empty slots. */ + private void buildLinearChainAtSlots(final long... slots) { + for (final long slot : slots) { final Bytes32 root = Bytes32.random(); + indexByRoot.put(root, chain.size()); chain.add(root); - slotByRoot.put(root, slot); + chainSlots.add(UInt64.valueOf(slot)); } - for (int slot = 0; slot < length; slot++) { - final Bytes32 root = chain.get(slot); - when(forkChoice.blockSlot(root)).thenReturn(Optional.of(UInt64.valueOf(slot))); + for (int i = 0; i < chain.size(); i++) { + final Bytes32 root = chain.get(i); + when(forkChoice.blockSlot(root)).thenReturn(Optional.of(chainSlots.get(i))); when(forkChoice.contains(root)).thenReturn(true); - final Bytes32 parent = slot > 0 ? chain.get(slot - 1) : Bytes32.ZERO; + final Bytes32 parent = i > 0 ? chain.get(i - 1) : Bytes32.ZERO; when(forkChoice.blockParentRoot(root)).thenReturn(Optional.of(parent)); } // get_ancestor(root, slot): walk up the parent chain until reaching a block at or before slot. @@ -820,13 +1066,16 @@ private void buildLinearChain(final int length) { } private Optional ancestorIndex(final Bytes32 root, final UInt64 targetSlot) { - final Integer index = slotByRoot.get(root); + final Integer index = indexByRoot.get(root); if (index == null) { return Optional.empty(); } int i = index; - while (UInt64.valueOf(i).isGreaterThan(targetSlot)) { + while (chainSlots.get(i).isGreaterThan(targetSlot)) { i--; + if (i < 0) { + return Optional.empty(); + } } return Optional.of(i); }