Skip to content

perf(beacon): verify attestations against committee key sums - #742

Open
MegaRedHand wants to merge 6 commits into
perf/block-signature-batchfrom
perf/committee-key-sums
Open

MegaRedHand wants to merge 6 commits into
perf/block-signature-batchfrom
perf/committee-key-sums

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stack 3/4 of the BLS verification work: (a) batched key sums -> (b) per-block signature batch -> (c) committee key sums -> (d) index-keyed pubkey cache. Based on (b).

An attestation aggregate usually covers most of its committee, and its verification key is the sum of the attesters' keys. This PR precomputes each committee's key sum once per shuffling, off the import path, and derives an attestation's key as committee_sum - sum(non-attesters) when more than half the committee attested, and as sum(attesters) otherwise. The group element is the same either way, so the pairing check is unchanged; only the number of point additions differs (absentees instead of attesters).

What it changes on top of the signature batch

  • ethlambda-crypto
    • bls/committee_sums.rs: AggregatePubkey (sum, minus, plus, verify) and CommitteeKeySums. Negation uses blst's safe sub_aggregate, so there is no unsafe.
    • SignatureSet::from_aggregate(key, message, signature): builds a set from an already-summed key (None when the signature fails sig_validate; one affine conversion of the key). AggregatePubkey::verify delegates to it, so both checks share one path, and the batch's identity-key guard covers it.
    • One projective sum helper in bls.rs (sum_validated_projective); the batched-key-sum PR's sum_validated_points is now a thin wrapper that converts to affine once. Committee sums add and subtract projective points, so building them on the affine helper would cost an extra field inversion per committee, about 512 per block.
  • ethlambda-storage: each resident shuffling gets a second OnceLock<Arc<CommitteeKeySums>> beside its committees, so eviction and head pinning stay in one place and the sums die with their shuffling. resident_key_sums is lookup only; get_or_init_key_sums builds into a resident entry and never holds the entry-list lock while building.
  • ethlambda-state-transition
    • CommitteeCacheExt::key_sums (lookup only: import never builds) and warm_key_sums (one iter_validators() pass, then rayon across committees). A committee with an invalid member key gets no sum and falls back to the direct path.
    • attesters_aggregate_pubkey (helpers/electra.rs), with the majority rule, usable by gloas through the same is_set closure as attesting_indices_from_committee_bits. Committees derived from their sums are added one by one; the attesters of every committee summed directly go into one sum. With cold sums that is the single sum over every attester that perf(beacon): verify a block's signatures in one random-weight batch #738 makes, at its cost: one sum per committee would pay blst's pool dispatch once per committee for a few hundred keys each, which made the cold path slower than perf(beacon): verify a block's signatures in one random-weight batch #738's.
    • Electra, fulu and gloas process_attestation build their SignatureSet from it (electra_attestation_signature_set, gloas_attestation_signature_set). The indexed attestation is still built, for the participation flags, and the same structural checks are made on it (non-empty, sorted and unique; gloas's MAX_VALIDATORS_PER_SLOT bound). Same signing root, same push label. Phase0 through deneb are unchanged.
  • ethlambda-blockchain (epoch_precompute.rs): the precomputed state is sent to the actor first, then the sums are warmed in the same blocking task, so the epoch's first block never waits for them. The task warms the epoch's sums and the previous epoch's (for the attestations its blocks carry from it), so a restart mid-epoch or a boundary whose worker did not run is covered at the next one. When the boundary state is already cached (a gloas bid for the epoch's first slot advances it), no worker runs, so a warm-only task runs from that state instead. Warming is skipped before electra, whose attestations never read the sums. New histogram lean_beacon_committee_key_sums_warm_seconds (in docs/metrics.md), recorded only when sums are actually built and covering the sums alone.

Soundness of keying sums by ShufflingKey

The sums are filed under (epoch, decision root), so two states with one key must agree on each member's public key as well as on who the members are. Checked against this repo's spec code:

  • The registry is append-only (process_deposit, electra's pending-deposit path); a validator's index and pubkey are fixed at append and no code assigns pubkey afterwards.
  • A new validator is appended with activation_epoch = FAR_FUTURE_EPOCH. Only process_registry_updates (epoch processing at epoch N) assigns one, compute_activation_exit_epoch(N) = N + 1 + MAX_SEED_LOOKAHEAD.
  • A member of epoch E's committees is active at E, so it was activated at some N <= E - 1 - MAX_SEED_LOOKAHEAD, and appended no later. That precedes the end of E - 2, so the decision-root block already has it at that index with that key, and every descendant inherits it.

The argument is in the CommitteeCacheExt::key_sums doc comment.

Measured

On a heavily loaded M3, blst 0.3.16, single-threaded, block shape of 11 signature sets and 33k signers:

Phase per block Ratio
key aggregation, sequential (before the batched sums) 1.4-2.5x the pairing
key aggregation, 5% missed only 20-40x less (realistic ~10x)

The single-threaded ratio overstates the wall-time gain on a multi-core host, where #738's one large sum already runs on blst's pool. In CPU time, profiling a Hoodi block replay on a Ryzen 9700X (the whole stack, warm sums) puts key summation at 12.3 ms CPU per block before the stack and 1.5 ms after.

Behavior notes

  • Accepted and rejected inputs are unchanged. Cold sums, an evicted shuffling or an invalid member key all take the direct sum of the attesters; a bad signature is rejected under the same label, warm or cold.
  • The sync aggregate is not covered: its keys come from current_sync_committee.pubkeys, which has no committee sums.
  • Warming builds the epoch's committees if they are not resident, and copies every validator's 48-byte key once: roughly 115 MB transient at 2.4M validators, on the blocking worker. It runs on rayon's global pool (and blst's, for each committee's sum), so it shares cores with import; the first warm-up after a start also validates every active key.
  • Import never builds sums. With cold sums (while syncing, when no precompute runs, or before the first warm-up after a start) attestations take the direct path, at perf(beacon): verify a block's signatures in one random-weight batch #738's cost.

Tests

New tests:

  • crypto: compressed-byte equality of sum/minus/plus against blst's direct sum (including duplicate keys, and minus over 40 random masks of a 64-key committee); verify against bls::fast_aggregate_verify with real signatures; the identity aggregate rejected; the empty and invalid-key cases.
  • storage: key_sums never builds or inserts; sums are built once into a resident shuffling only; sums are evicted with their shuffling.
  • state-transition: random masks (empty, one bit, below half, exactly half, half plus one, all, random) over one and two committees, cold and warm, checked by verifying the aggregate of the attesters' real signatures; invalid-member fallback; a block-level test with warm sums where a good attestation gives the same post-state root warm and cold and a bad signature is rejected with the attestation label either way.
  • spec runners: every electra-or-later operations/*/attestation case and every block run (sanity, finality, random) now runs twice, cold and after warm_key_sums (previous, current and next epoch of the state each block applies to); both runs must reach the same verdict and post-state.

Run (--profile release-fast):

  • cargo test -p ethlambda-crypto --lib: 35 passed, 14 ignored
  • cargo test -p ethlambda-storage --lib: 213 passed, 1 ignored
  • cargo test -p ethlambda-state-transition --lib --features beacon-spec-tests: 658 passed
  • beacon_spec_tests -- /attestation/: 356 passed; -- sanity/: 595 passed; -- random/: 1969 passed, 175 ignored
  • full beacon_spec_tests: 7493 passed, 977 ignored
  • cargo fmt --all; clippy -D warnings --all-targets on crypto, storage, state-transition, blockchain: clean
  • ethlambda-blockchain --lib (before the merge): 291 passed, 43 ignored

On this branch after its review fixes: crypto --lib 40 passed; storage 213; state-transition --lib 658; blockchain 291; beacon_spec_tests operations/ 2141, sanity/ 595, finality/ 41, fork_choice/ 206, random/ 1969 (175 ignored), transition/ 172, sync/ 6 passed.

Rerun after the review fixes, on the stack's tip (all four PRs, --profile release-fast): ethlambda-crypto --lib 47 passed, 14 ignored; ethlambda-state-transition --lib --features beacon-spec-tests (with the BLS fixture vectors) 658 passed; storage 213, blockchain 291, p2p 362, rpc 335 passed; full beacon_spec_tests 7493 passed, 977 ignored, 0 failed; make lint clean, also on a trial merge of current main.

An attestation aggregate usually covers most of its committee, so its
verification key is cheaper to derive as the committee's key sum minus the
non-attesters than as the sum of the attesters. The result is the same group
element, so the pairing check is unchanged; only the number of point
additions drops.

Each resident shuffling in the committee cache gets a second slot for its
committee key sums, so eviction and head pinning stay in one place. Block
import only looks the sums up: building them costs a pass over the whole
active set, which is more than they save for committees attested once. The
epoch precompute worker builds them instead, after it has handed the
advanced state to the actor, so the epoch's first block never waits.

Sums are keyed by the shuffling key. That is sound because the registry is
append-only, a pubkey is never reassigned, and a validator active at epoch E
was activated at least MAX_SEED_LOOKAHEAD + 1 epochs earlier, so it is
already in the history the decision root commits to.

Nothing in block processing calls the new key builder yet.
Brings in the per-block signature batch and the batched key sums.

The two parts each added a point-sum helper. The batched one now lives in
bls.rs as a single projective sum, with sum_validated_points a thin wrapper
that converts to affine once, so committee key sums (which add and subtract
projective points) do not pay a field inversion per conversion. The
committee-sum module uses that helper instead of its own, and
AggregatePubkey::verify shares its pairing path with the new
SignatureSet::from_aggregate.
Electra, fulu and gloas process_attestation now build the attestation's
signature set from attesters_aggregate_pubkey, which derives the key as the
warmed committee sum minus the non-attesters when most of the committee
attested, and sums the attesters otherwise. The key is the same group
element either way, so the verdict is unchanged; only the point additions
differ. Import still never builds the sums: with them cold, the builder
sums the attesters directly.

The indexed attestation is still built for the participation flags, and the
structural checks on it (non-empty, sorted and unique, gloas's length bound)
and the push label are the ones the indexed path made.

The spec runners use a fresh committee cache, which only ever runs the cold
branch, so electra-and-later attestation cases and every block run now run
a second time with the sums warmed. Both runs must reach the same verdict
and post-state.
The replay drives import_block directly with no tick loop, so the epoch
precompute worker never runs and committee key sums stay cold; the
committee-sum saving is invisible in a replay although a live node gets
it. The flag warms each epoch's sums before its first block, outside
every measured span, so the warm-up is in no phase and no wall_seconds.
It is recorded in the report params and the human header.
@MegaRedHand
MegaRedHand added this pull request to stack #744 October 30, 2026 16:08
MegaRedHand added a commit that referenced this pull request Oct 30, 2026
perf(beacon): committee key sums, warmed off the import path

An attestation aggregate usually covers most of its committee, so its
verification key is cheaper to derive as the committee's key sum minus the
non-attesters than as the sum of the attesters. The result is the same group
element, so the pairing check is unchanged; only the number of point
additions drops.

Each resident shuffling in the committee cache gets a second slot for its
committee key sums, so eviction and head pinning stay in one place. Block
import only looks the sums up: building them costs a pass over the whole
active set, which is more than they save for committees attested once. The
epoch precompute worker builds them instead, after it has handed the
advanced state to the actor, so the epoch's first block never waits.

Sums are keyed by the shuffling key. That is sound because the registry is
append-only, a pubkey is never reassigned, and a validator active at epoch E
was activated at least MAX_SEED_LOOKAHEAD + 1 epochs earlier, so it is
already in the history the decision root commits to.

Nothing in block processing calls the new key builder yet.

perf(beacon): verify attestations against committee key sums

Electra, fulu and gloas process_attestation now build the attestation's
signature set from attesters_aggregate_pubkey, which derives the key as the
warmed committee sum minus the non-attesters when most of the committee
attested, and sums the attesters otherwise. The key is the same group
element either way, so the verdict is unchanged; only the point additions
differ. Import still never builds the sums: with them cold, the builder
sums the attesters directly.

The indexed attestation is still built for the participation flags, and the
structural checks on it (non-empty, sorted and unique, gloas's length bound)
and the push label are the ones the indexed path made.

The spec runners use a fresh committee cache, which only ever runs the cold
branch, so electra-and-later attestation cases and every block run now run
a second time with the sums warmed. Both runs must reach the same verdict
and post-state.

feat(benchmark): add --warm-committee-sums to the import replay

The replay drives import_block directly with no tick loop, so the epoch
precompute worker never runs and committee key sums stay cold; the
committee-sum saving is invisible in a replay although a live node gets
it. The flag warms each epoch's sums before its first block, outside
every measured span, so the warm-up is in no phase and no wall_seconds.
It is recorded in the report params and the human header.

Cherry-picked from perf/committee-key-sums at 508c43e.
# Conflicts:
#	crates/blockchain/state_transition/tests/beacon_spec/operations.rs
…arm missed epochs

With the sums cold, every committee was summed on its own, and each sum of a
few hundred keys paid blst's pool dispatch separately where the plain path
made one sum over all attesters: a cold multi-committee aggregate got slower
than before this change. Every directly summed committee now goes into one sum,
so the cold path is that plain sum again.

Warming only ran for the epoch a precompute worker advanced into. The worker
now also warms the previous epoch (after a restart mid-epoch, or a boundary
whose worker did not run), and a boundary state someone else already cached
(a gloas bid for the epoch's first slot) gets a warm-only task instead of no
warm-up at all. Pre-electra states skip it, since nothing reads the sums there,
and the warm-up histogram now times the sums alone, only when they are built.
@MegaRedHand
MegaRedHand marked this pull request as ready for review October 30, 2026 21:43
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 742: warm committee key sums and use them in electra and gloas attestation verification

I read the diff but did not build it or run the tests, so everything below comes from reading the code.

Overall: the design is sound and carefully argued. Warming happens off the import path. The cold path stays a single flat sum. The sum-minus-absentees result is the same group element as summing the attesters, so the pairing check can't tell them apart. The correctness argument for sharing sums on the ShufflingKey is stated in full on key_sums. I found no consensus-affecting bugs.

Checked and fine

  • attesters_aggregate_pubkey (helpers/electra.rs):
    • Empty attester sets return None through attestation_signature_set's ??, so they are rejected without a pairing, as before.
    • A committee with None in its sum slot falls back to the direct path. A bad key then only matters if that member actually attested. a_committee_with_an_invalid_member_key_falls_back covers this.
    • The strict-majority gate (attester_count * 2 > committee.len()) and the committee_offset accounting match attesting_indices_from_committee_bits.
  • attestation_signature_set keeps the sorted-and-unique and non-empty checks from the indexed path, and it uses the same domain and signing root.
  • The gloas wrapper keeps the MAX_VALIDATORS_PER_SLOT bound ahead of the shared builder.
  • The tests are good. The randomized masks compare cold and warm caches against an independent aggregate verify. The post-state and verdict equivalence test also covers a bad signature.

Minor points

  1. epoch_precompute.rs, the new decide(...)-with-already_cached: false branch: it can spawn a spawn_blocking warm task on every head or timer trigger while the epoch's state stays cached and the sums are still being built. The resident check makes a task cheap once the sums are filed. Until then, several tasks may overlap and each derive committees and walk the registry. This is only safe if get_or_init_key_sums blocks other callers (OnceCell-like). Please confirm that, or gate the branch the way in_flight gates the worker.
  2. warm_key_sums calls self.committees(state, epoch) outside the get_or_init_key_sums closure. It also builds the full committees for an epoch that may never be attested, and it does so on every call that isn't resident. That is fine for background use. A short doc note that the committee derivation counts as warming cost would help, since the metric deliberately excludes it.
  3. build_committee_key_sums: pubkeys copies every validator's 48-byte key, about 48 MB at roughly 1M validators. It is transient and off the hot path, and par_iter already runs inside spawn_blocking. This is acceptable. Do check that rayon's global pool isn't also the one the BLS and leanVM work relies on, so warming can't starve block import.
  4. replay.rs, warm_committee_sums: when previous_epoch is Some(p) and epoch > p + 1 (a gap in the corpus), only epoch is warmed, not epoch - 1. The live worker does warm both. That is a small benchmark-fidelity difference, and one the first-block case already handles.
  5. tests/beacon_spec/mod.rs, warm_key_sums_around: it warms current + 1, which shuffling_key may legitimately fail to key near genesis. That is harmless because warm_key_sums returns early on None.

Approve, with point 1 worth confirming before merge.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Overall this looks solid. I didn’t spot a consensus/safety bug in the changed logic, and the fallback behavior is conservative: when precomputed sums are missing or unusable, the code falls back to direct key summation rather than accepting anything it shouldn’t.

  • crates/blockchain/src/epoch_precompute.rs:199
    Minor maintainability nit: the branch condition recomputes decide() with already_cached: false purely to distinguish “cached state prevented worker start” from other None cases. That’s correct, but a bit opaque. Consider factoring this into a small helper / named boolean so the “warm only when the worker would otherwise have run” rule is easier to audit later.

  • crates/blockchain/src/epoch_precompute.rs:211
    Potential resource issue, not correctness: tokio::task::spawn_blocking is fired and ignored for cache-warm-only work. On a busy node with repeated cached-state hits around epoch boundaries, this can queue redundant warmers. Because warm_key_sums() is idempotent this should stay correct, but you may still want lightweight deduplication or an in-flight guard for warm-only jobs to avoid avoidable blocking-pool pressure.

  • crates/blockchain/state_transition/src/beacon/helpers/electra.rs:603
    Nice choice to gate the subtraction path on attester_count * 2 > committee.len(). That avoids regressing the cold path and preserves a predictable upper bound on additions. Also good that any invalid/missing pubkey in the absentee list returns Ok(None) and therefore fails verification rather than producing a partial aggregate.

  • crates/blockchain/state_transition/src/beacon/helpers/accessors.rs:369
    Good consensus hygiene here: warming is explicitly disabled pre-Electra, keyed by shuffling_key, and lookup-only on import. That keeps this optimization out of phase0/altair/deneb semantics and avoids accidental cache construction on the hot path.

  • crates/common/crypto/src/bls/committee_sums.rs:53
    AggregatePubkey::minus() looks sound from a memory-safety and crypto-API perspective: validated points only, no unsafe, and identity results are deferred to verification where they are rejected. The tests around duplicates and random masks are especially valuable here.

Looks good to me aside from the small blocking-pool/perf concern in epoch_precompute.rs:211.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which introduces committee key sum caching to optimize attestation verification in Electra and later forks. Let me analyze the changes systematically.

Overall Assessment

This is a well-structured optimization PR that caches BLS public key sums per committee to speed up attestation verification. The core insight is sound: for committees with high attestation rates, subtracting absentee keys from a precomputed sum is cheaper than summing all attesters from scratch.


Critical Issues

1. Potential Race Condition in warm_committee_sums — bin/ethlambda/src/benchmark/import/replay.rs:217-248

fn warm_committee_sums(
    store: &Store,           // parameter name says `store`
    block: &SignedBeaconBlock,
    previous_epoch: Option<u64>,
) -> eyre::Result<()> {

Bug: The function signature declares store: &Store but the call sites pass &clock (a SystemClock). The body uses store.get_state(...) and store.config() etc., but SystemClock doesn't have these methods. This won't compile.

Lines 133-136 and 150-153:

if options.warm_committee_sums {
    warm_committee_sums(&clock, &block, previous_epoch)?;  // `clock` is SystemClock, not Store
}

Fix: Change call sites to pass server.store() or equivalent, and verify the parameter type matches.


2. Typo in Comment — crates/blockchain/src/epoch_precompute.rs:200

// Someone else advanced the state already (a gloas bid for the
// epoch's first slot does), so no worker runs: warm the sums from

"gloas bid" → "gloas block" or "block" (context suggests this refers to a block proposal, not a bid).


3. Logic Error in decide Re-evaluation — crates/blockchain/src/epoch_precompute.rs:198-210

if let Some(state) = cached
    && decide(&DecisionInputs {
        already_cached: false,
        ..inputs
    })
    .is_some()

Issue: The re-evaluation of decide with already_cached: false will always return Some(key) (assuming other conditions hold), because the original decide returned None precisely because already_cached was true. This means the branch always fires when cached.is_some().

However, the comment says "Someone else advanced the state already... so no worker runs: warm the sums from that state instead". The logic is correct but the decide re-call is misleading—it could just check !inputs.already_cached directly or use a clearer condition. The current code works but is confusing.

More critically: The tokio::task::spawn_blocking call at line 206 doesn't use the state from the cached variable correctly. Looking more carefully—the cached is Option<Arc<BeaconState>> from cached_state, and state is bound by if let Some(state) = cached. This seems correct.

Actually, re-reading: the issue is that inputs is consumed by the first decide call (via let Some(key) = decide(&inputs)), but inputs is used again in the if let block. This works because DecisionInputs derives Copy or is reborrowed—need to verify. The ..inputs suggests it's not moved. This seems okay.


Security Considerations

4. BLS Identity Key Handling — crates/common/crypto/src/bls/committee_sums.rs:85-89

pub fn verify(&self, message: Root, signature: &BlsSignature) -> bool {
    SignatureSet::from_aggregate(*self, message, signature).is_some_and(|set| set.verify())
}

The identity element (all keys subtracted away) is handled by SignatureSet::from_aggregate converting to PublicKey and then SignatureSet::verify rejecting it. This matches the comment and is consistent with existing behavior. Verified correct.

5. Invalid Key Fallback — crates/blockchain/state_transition/src/beacon/helpers/electra.rs:598-609

let sum = key_sums.as_ref().and_then(|sums| sums.get(number as usize));
match sum {
    Some(sum) if attester_count * 2 > committee.len() => {
        // subtract absentees
    }
    _ => {
        // direct sum of attesters
    }
}

Correct: When sum is None (invalid member key), falls back to direct summation. The attester_count * 2 > committee.len() majority rule properly guards the subtraction path—subtraction only used when minority absent.

However, line 600: attester_count * 2 > committee.len() can overflow if committee.len() is large and attester_count * 2 exceeds usize::MAX. For mainnet parameters this is impossible (committees are bounded), but the code should use checked_mul or saturating_mul for defense-in-depth, or document the safety assumption.


Performance Issues

6. Unnecessary Clone in build_committee_key_sums — crates/blockchain/state_transition/src/beacon/helpers/accessors.rs:404

let pubkeys: Vec<BlsPubkey> = state.iter_validators().map(|v| v.pubkey).collect();

This clones every validator's 48-byte public key into a flat vector. For 1M validators, that's ~48MB allocation per epoch warmed. The pubkeys vector is then indexed by ValidatorIndex.

Consider: Using state.validator(index) directly in the parallel map, or caching this differently. However, the comment notes state.validator(i) is a tree descent, so the flat copy is a trade-off. Given this runs off the import path, acceptable, but monitor memory pressure.

7. Double Hashing in warm_key_sums Test Path — crates/blockchain/state_transition/tests/beacon_spec/operations.rs:706-716

let warm = apply(&handler, case, &mut warm_state, &config, &committees);
if warm != outcome {

apply calls run which calls electra_stf::process_attestation which builds SignatureBatch and verifies. The comparison warm != outcome compares Result types. For the success case, this is fine. For error cases, this compares String error messages, which may differ in formatting between runs. Low risk but could cause flaky tests if error formatting changes.


Rust Best Practices

8. unwrap() in Test Helper — crates/blockchain/state_transition/src/beacon/helpers/electra.rs:701

let key = attesters_aggregate_pubkey(
    &state,
    slot,
    &bits,
    |bit| mask.get(bit).copied().unwrap_or(false),
    cache,
)
.unwrap();

In test code, acceptable. But unwrap() after ? in production paths should be audited. The production path attestation_signature_set uses ok()?? which is correct.

9. #[cfg(test)] Method on Public Struct — crates/common/crypto/src/bls/committee_sums.rs:97-100

#[cfg(test)]
fn to_bytes(self) -> [u8; 48] {
    self.0.to_public_key().to_bytes()
}

Good practice to keep test-only helpers gated.

10. Missing Debug Implementation — crates/storage/src/committee_cache.rs:126-130

#[derive(Debug, Default)]
struct Resident {
    committees: OnceLock<Arc<EpochCommittees>>,
    key_sums: OnceLock<Arc<CommitteeKeySums>>,
}

OnceLock implements Debug showing its state. Good.


Consensus Correctness

11. Committee Number Indexing — crates/blockchain/state_transition/src/beacon/helpers/accessors.rs:420-421

let first_slot = compute_start_slot_at_epoch(epoch);
// ...
.flat_map(|offset| (0..per_slot).map(move |index| (first_slot + offset, index)))

The committee number formula slot_offset * committees_per_slot + committee_index must match committee_number() in electra.rs. Verified: The flat_map produces exactly this ordering. Correct.

12. Shuffling Key Stability Proof — crates/blockchain/state_transition/src/beacon/helpers/accessors.rs:288-320

The extensive comment proving why key sums can be shared on shuffling key is excellent consensus documentation. The three bullet points cover:

  • Append-only registry with fixed pubkeys
  • Activation epoch lookahead ensuring registry membership at decision block
  • Inheritance through state descent

This is critical consensus infrastructure and the proof appears correct. I'd suggest moving this to a standalone .md document or module-level doc comment for discoverability.


Minor Issues

13. Inconsistent Parameter Naming — bin/ethlambda/src/benchmark/import/replay.rs

fn warm_committee_sums(
    store: &Store,  // actually called with `&clock` at lines 133, 150

As noted in Item 1, the parameter name doesn't match usage.

14. Unused Import — crates/blockchain/state_transition/src/beacon/helpers/electra.rs

use crate::beacon::bls::{AggregatePubkey, SignatureSet};

SignatureSet is used in attestation_signature_set, but verify this is the first import of AggregatePubkey in this module—it appears newly added.

15. Metrics Bucket Choice — crates/blockchain/state_transition/src/metrics.rs:151

vec![0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0]

For key sum warming with parallelization over committees, sub-100ms is expected for typical validator sets. The 8.0 upper bound may be insufficient for worst-case (full registry, single-threaded fallback). Consider adding 16.0.


Documentation

16. docs/benchmarking.md — Good Addition

The --warm-committee-sums documentation is clear about:

  • What it reproduces (only sum warming, not state advance)
  • When it runs (epoch boundaries)
  • That it's outside measured spans

Approved.


Summary

Priority Item File Line
Critical Wrong parameter type in warm_committee_sums calls replay.rs 133, 150
High Overflow risk in majority check electra.rs 600
Medium Typo in comment epoch_precompute.rs 200
Low Metrics bucket upper bound metrics.rs 151
Low decide re-evaluation clarity epoch_precompute.rs 198-210

The core cryptography and consensus logic is well-designed and correct. The main blocker is the compilation error in replay.rs.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

MegaRedHand added a commit that referenced this pull request Oct 30, 2026
…review fixes

perf(beacon): keep cold committee sums at the plain path's cost and warm missed epochs

With the sums cold, every committee was summed on its own, and each sum of a
few hundred keys paid blst's pool dispatch separately where the plain path
made one sum over all attesters: a cold multi-committee aggregate got slower
than before this change. Every directly summed committee now goes into one sum,
so the cold path is that plain sum again.

Warming only ran for the epoch a precompute worker advanced into. The worker
now also warms the previous epoch (after a restart mid-epoch, or a boundary
whose worker did not run), and a boundary state someone else already cached
(a gloas bid for the epoch's first slot) gets a warm-only task instead of no
warm-up at all. Pre-electra states skip it, since nothing reads the sums there,
and the warm-up histogram now times the sums alone, only when they are built.

Updates nightly's #742 from perf/committee-key-sums at 508c43e to 562b369.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Block import benchmark (offline replay of mainnet blocks, steady state)

leg STF per block vs #738 (parent) vs main
main + #737 + #738 145.9 ms -7.5%
+ #742, sums warmed (--warm-committee-sums) 144.1 ms -1.8 ms, CI [-2.4, -1.3] -8.6%
+ #742, sums cold (no flag) 155.6 ms +9.7 ms, CI [+9.1, +10.3] -1.3%

Open question: is the cold path expected to be slower than #738's direct sum? If #742 merges together with #743 the gap does not show; if #742 lands alone, it is worth a look.

Method

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant