Repository navigation
perf(beacon): resolve pubkeys through a cached index and reuse the total active balance in churn - #759
perf(beacon): resolve pubkeys through a cached index and reuse the total active balance in churn#759MegaRedHand wants to merge 4 commits into
Conversation
…tal active balance in churn On mainnet (~2.4M validators) the block STF spent 72 ms per block in process_sync_aggregate and 29 ms in process_execution_requests. The first walked the whole registry probing a SipHash map per validator to turn 512 sync committee pubkeys into indices; the second repeated a linear pubkey search per withdrawal and consolidation request, and recomputed the total active balance (another O(n) pass) several times per request through the churn helpers. Lighthouse keeps a pubkey -> index cache on the state (append-only registry, extended incrementally) and a per-epoch total active balance. This ports both ideas: - PubkeyIndexCache in ethlambda-storage, held by Store like the active balance cache: a sorted base of u64 key prefixes (bytes 1..9 of the compressed pubkey) and u32 indices, 12 bytes per validator, plus a small hash tail merged past a threshold. The table is process-wide, so every hit is verified against the asking state's own validator at that index, and a miss falls back to the old scan for that query alone; the table is never rebuilt on a miss, so a request naming an unregistered pubkey costs one scan. - The sync aggregate resolves seats through the cache and keeps the single-pass inverted scan as the fallback. Withdrawal and consolidation requests (electra, fulu, and gloas's parent-payload requests) use it too. - The exit and consolidation churn helpers take an optional ActiveBalanceCache and use the *_for(total, config) formulas, so a block shares one registry pass. Epoch processing and spec-shaped callers pass None and keep the uncached total, since the cache key is not sound after the epoch's effective-balance update. Slashings still use the uncached total, and epoch pending-deposit processing is untouched.
|
Combined result for the four Lighthouse ports (#759 pubkey index, #760 column KZG, #761 progressive target balances, #762 tree-backed participation), squashed onto Offline import replay on ethlambda-5, same three mainnet corpora, both legs built at the commit and run with
Per STF step, ordinary blocks (n=162): Live on the ethlambda-2 mainnet follower (nightly 1d06bf9): steady |
🤖 Codex Code ReviewAutomated review by OpenAI Codex |
🤖 Claude Code ReviewI reviewed the pubkey-index cache code in SummaryThe design is sound and safe. Every cache candidate is checked against the asking state's own validator at that index ( Findings
The code looks good. Item 1 is a doc fix, and Item 2 is worth a decision before merge. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Kimi Code ReviewI'll review this PR which introduces a Overall AssessmentThis is a well-structured performance optimization that introduces a process-wide cache for pubkey-to-index lookups. The design correctly handles fork safety by verifying cached candidates against the caller's state. However, I've identified several issues ranging from correctness concerns to code quality improvements. Critical Issues1. Race condition in
|
| Severity | Count | Description |
|---|---|---|
| Critical | 0 | No consensus-breaking bugs found |
| High | 1 | Potential duplicate entries in bulk extend (Item 1) |
| Medium | 2 | Suboptimal sync committee fallback (Item 6), test helper issue (Item 11) |
| Low | 4 | Documentation nits, clippy attribute, naming, bounds |
The PR is correct and safe from a consensus perspective. The cache verification mechanism properly handles all edge cases (forks, prefix collisions, missing pubkeys). The main concern is the potential for duplicate entries during concurrent bulk extends, which wastes memory but doesn't affect correctness.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
|
Per-PR attribution on
#760 does not run in the replay (no custody columns), so the second step is #761's. Regressions found by the per-step split, and what the follow-up commits (now pushed to #761 and #762) did about them:
ethlambda-2 (mainnet follower) runs nightly 2b51a33 since 2026-11-03 14:42 UTC. |
get_head tallied every validator's latest message on every call, about 1M on mainnet, and a live import pays it twice for the first timely block of a slot (on_block's boost-gate walk, then the actor's get_head). That tally was most of get_head's ~17 ms in the eth-5 replay. The store now keeps the vote weights in a node array (Lighthouse unstable's proto-array layout: one node per block, carrying its PENDING, EMPTY and FULL weights). One attestation is one vote batch: Store::record_latest_messages records the messages and moves the attesters' weight under one lock, and the next read refreshes the subtree totals in one reverse pass over the blocks. The head walk reads them through cached_node_weights. The proposer boost, gloas's previous-slot rule and the walk bound depend on the clock, so they stay read-time. There are no best-child pointers: under gloas the choice moves without any vote, which is why Lighthouse stopped maintaining them. What the cache cannot follow incrementally (finality pruning, a live-window removal, a restart, a new justified balances snapshot, the gloas rule switch, a block stored without its node) marks it stale, and the next read rebuilds it from the latest messages at the old tally's cost, about once per epoch. A rebuild rescans LiveChain under the lock, so an API read that scanned before a removal cannot rebuild the removed blocks back in. compute_node_weights stays, as the reference. Equivalence is checked by a proptest against a naive tally (with the incremental state also equal to a fresh rebuild after every operation), on the random trees at every step of every seed, by a new test that feeds votes, blocks and equivocations through update_latest_messages, on_block's insertion order and insert_equivocating_index, and at every head check of every fork-choice fixture. Local benchmark (1M validators, 73 blocks, laptop): the tally 27 ms; a warm cached read 7 us; a read after a 15.6k-vote batch 49 us; the batch itself 1.3 ms; a rebuild 17 ms. Cherry-picked from perf/beacon-proto-array at b4adbaf. Adapted onto nightly: both vote writers (set_latest_message and the new record_latest_messages) grow the dense vote table through one grow_latest_messages helper, which keeps #740's exact growth plus headroom; the storage and fork-choice imports are merged with #759's and #761's caches. Replaces #767, reverted in the previous commit.
Block production now resolves pubkeys through the store's shared PubkeyIndexCache. assemble_block, assemble_gloas_block(_on_bid) and gloas_payload_inputs take the table, and the RPC passes store.pubkey_index_cache(), so a proposal no longer ingests and sorts the whole registry into a fresh table on every state transition run. A fallback scan now repairs the table. validator_index records the index it found (PubkeyIndexCache::insert), process_sync_aggregate scans only the seats the cache could not verify and records them, and the tail keeps every index under a key (a BTreeSet instead of a first-writer-wins map), so a prefix twin or a position taken in from another fork costs one scan rather than one per block. extend builds and sorts its batch before taking the write lock; under the lock it only re-checks covered_len, drops what a racing caller ingested, and splices. Slashings thread the ActiveBalanceCache into slash_validator's exit, so a block slashing many validators reads the total active balance once, and pack_operations shares one table with its slashings. The churn helpers pick the per-fork formula first and compute the total once. The STF entry points (state_transition, apply_block, every fork's process_block, transition_block) take one stf::BlockCaches instead of three cache arguments, with OwnedBlockCaches for callers with no Store. That drops the eight too_many_arguments allows the PR added. switch_to_compounding_source replaces is_valid_switch_to_compounding_request and returns the source index, so the request resolves its pubkey once. The soundness notes on active_balance_key and in docs/beacon_stf.md now name every cached caller. TAIL_MERGE_THRESHOLD is private and heap_bytes is gone (a test helper measures the base instead). Claude-Session: https://claude.ai/code/session_011vghNj5DNZw778LTBJPURC
🗒️ Description / Motivation
Profiling the mainnet beacon follower (~2.4M validators) against Lighthouse v8.1.2 on the same blocks showed our block STF at ~200 ms vs Lighthouse's ~92 ms (on a slower CPU). The two largest steps were pubkey scans Lighthouse avoids:
sync_aggregate: 72 ms per block.apply_sync_aggregatewalked the whole registry every block to map the 512 sync committee pubkeys to indices (95 of 117 perf samples in SipHash of&BlsPubkey). Lighthouse resolves them throughBeaconState::pubkey_cache, an incrementally extended pubkey -> index map; itsprocess_sync_aggregatehad 3 samples in the same window.execution_requests: 29 ms per block. Withdrawal and consolidation requests looked validators up withiter_validators().position(..), and most of the cost wascompute_total_active_balance(a full registry pass) recomputed several times per request through the churn helpers. Lighthouse caches the total active balance per epoch.What Changed
ethlambda-storage: newPubkeyIndexCache(pubkey_index_cache.rs), held byStorelikeActiveBalanceCache. A sorted base of(u64 key, u32 index)plus an ordered tail (aBTreeSet, merged into the base at 8192 entries). The base costs 12 bytes an entry, ~29 MB at 2.4M validators. The key is 8 bytes of the compressed pubkey. A bulk ingest (the first sight of a registry) is sorted before the write lock is taken.ethlambda-state-transition:PubkeyIndexCacheExt(helpers/accessors.rs) ingests only the part of the registry the cache has not seen, then verifies every candidate against the state (state.validator(i).pubkey == pubkey). A miss or a failed verification falls back to a linear scan for that query, and an index the scan finds is written back (PubkeyIndexCache::insert). The table is never rebuilt.ActiveBalanceCacheand use*_for(total, config)formulas, so one registry pass serves the block instead of several per request. That covers voluntary exits, withdrawal and consolidation requests, and the exits that proposer and attester slashings initiate. Epoch processing still passesNone.stf::BlockCachesthroughstate_transition/apply_block/process_block. Callers without aStore(spec tests, era replay, unit tests) hold anstf::OwnedBlockCaches.assemble_block,assemble_gloas_block,assemble_gloas_block_on_bid,gloas_payload_inputs) resolves pubkeys through the store's shared table. Its committee and active-balance tables are still fresh for each build, andpack_operationsshares one active-balance table across the slashings and exits it tries.lean_beacon_pubkey_index_lookups_total{result="hit"|"fallback"}.docs/beacon_stf.mdanddocs/metrics.mddescribe both caches.Correctness / Behavior Guarantees
ActiveBalanceCachewas already used for the sync aggregate and attestation rewards. It is keyed by the epoch and the block root at the last slot of the previous epoch, so it is sound for block processing only:active_balance_key's doc lists every caller that passesSome, and epoch processing and ejections passNone.Benchmarks
Offline
ethlambda benchmark import replayon ethlambda-5 (Ryzen 9700X), three mainnet corpora (slots 15567200-15567385), 2 rounds, steady state n=164, paired per block against main012dc4f49:Live, on the ethlambda-2 mainnet follower (nightly with this change squashed in, gossip imports):
sync_aggregate74.9 -> 1.6 ms,voluntary_exits55.1 -> 0.2 ms,stf175.4 -> 103.5 ms.These numbers were measured before the review commit
bbec4ba2and have not been re-measured. That commit changes the import path only for slashings, switch-to-compounding requests and cache misses.Tests Added / Run
pubkey_index_cache.rs: tail and bulk ingest, the merge at the threshold, covered positions skipped and gaps refused, a racing bulk ingest, equal keys in the base and in the tail,insert(new and already in the base), the 12-byte base.accessors.rs: hit after ingest, tail ingestion after growth, a wrong index from another fork failing verification, a fallback scan recording its find, an absent pubkey not growing the table, a key-prefix collision resolving both validators, agreement with a registry scan for every validator.altair.rs: a sync committee seat the cache cannot verify is scanned for and recorded.cargo test --profile release-fast --libforethlambda-storage,ethlambda-state-transition,ethlambda-blockchainandethlambda-rpc, and clippy with thebeacon-spec-testsfeatures on.bbec4ba2passes: Test (consensus), Test (node), Test minimal preset, Beacon spec tests (mainnet and minimal, v1.7.0-beta.2 fixtures), Benchmark smoke, Lint.Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test— not run locally; CI's Test (consensus) and Test (node) jobs, its two halves, pass onbbec4ba2https://claude.ai/code/session_011vghNj5DNZw778LTBJPURC