Repository navigation
Description
Summary
On Hoodi (~1.14M active validators, 1.67M registry, Fulu), the epoch transition costs about one second on our test node (Xeon E-2288G), and it runs on several paths in the same slot. Two of these paths hold important locks while they run:
- Block import. The first block of each epoch runs
ProcessEpochand the following state-root hash insideAddChainSegment, under the fork-choice write lockf.mu. attestation_data. When the head is still in the previous epoch, the handler copies the head state and runsProcessSlotsacross the boundary. It holdsattCacheMutexexclusively and the synced-data read lock the whole time.
Most of the cost comes from two inefficiencies inside epoch processing, not from the spec work itself. The remaining cost is the same transition computed again on several state copies.
Code references are on origin/main 292d836.
This already happens on Fulu today (Hoodi, and mainnet has a similar validator count); Gloas makes it worse. One PR will not fix all of it, so this issue tracks the directions below:
- Direction 1: remove the two inefficiencies in rewards/penalties and effective-balance updates. Fixed by cl/transition/impl/eth2/statechange: avoid contended and no-op dirty marks in epoch processing #25144 (release/3.8: [r3.8] cl/transition/impl/eth2/statechange: avoid contended and no-op dirty marks in epoch processing #25163).
- Direction 2:
attestation_datawithout a full state advance. Fixed by cl: answer attestation_data from fork choice without waiting for the head state #25359 (release/3.8: [r3.8] cl: answer attestation_data from fork choice without waiting for the head state #25458). - Direction 3: gossip validation of the boundary block without a copy and
ProcessSlots. Fixed by cl: validate boundary blocks without a state copy #25253 (release/3.8: [r3.8] cl: stop fsyncing fork-graph dumps, validate boundary blocks without a state copy, and republish moved heads and queued envelopes #25287). - Direction 4: precompute the next-epoch state for the head (evaluate after 1-3). Fixed for block import by cl, cmd/caplin: precompute the next-epoch state off the fork-choice lock #25473 (release/3.8: [r3.8] cl: reduce head latency and fork-choice lock hold time #25704). The other paths do not need it: after cl: answer attestation_data from fork choice without waiting for the head state #25359,
attestation_datano longer advances the state at the boundary, and payload preparation already advances its own copy outsidef.mu.
Measurements
Test node (Hoodi, Fulu, probe build)
| What | Value |
|---|---|
AddChainSegment transition, first block of an epoch |
p50 822 ms, p95 884 ms, max 2.3 s (101 epochs); recent epochs 831-869 ms |
f.mu hold at site newPayloadForBlockWhileYieldingForkChoiceLock |
~0.9 s, once per epoch |
| Gossip arrival to fork-choice update, first block of an epoch | p50 1478 ms, p90 1584 ms (39 epochs) |
| Same metric, all other slots | p50 265-275 ms, p90 507-654 ms (1158 slots) |
attestation_data cache fill with stateEpoch < reqEpoch |
copy 241-404 ms + ProcessSlots 747-969 ms, up to 1.37 s (25 fills) |
postForkchoiceOperations waiting on attCacheMutex during such a fill |
437 ms, 700 ms (while it holds the MDBX write transaction) |
The fill can run twice for one boundary. The cache entry expires after half a slot, so if the head is still in the old epoch one slot later, the handler fills it again (for example slots 4190816 and 4190817, 1.08 s and 1.14 s).
Local profile (Apple M3 Pro, 12 threads, real Hoodi state at slot 4188112)
These are wall-clock medians. Epoch processing runs on a warm, hashed state, the same way block import runs it.
| Step | Local ms | Node estimate ms |
|---|---|---|
| eligible + unslashed participation sets | 15 | 30-45 |
| justification and finalization | 4 | ~10 |
| inactivity scores | 10 | 20-30 |
| rewards and penalties | 114 (CPU 1.2 s) | 250-350 |
| registry updates | 14 | 30-40 |
| slashings | 4 | ~10 |
| pending deposits / consolidations | ~0 | ~0 |
| effective balance updates | 12 | 25-35 |
| resets, participation rotation, sync committee (non-period epoch) | ~0 | ~0 |
| proposer lookahead (E+2 active-set scan 5 + proposer sampling 11) | 14 | 30-40 |
ProcessSlots across the boundary, total |
~195 | 450-550 |
post-epoch HashSSZ (block state-root check, or the handler's extra slot) |
102 | 300-450 |
CachingBeaconState.Copy (handler, gossip validation) |
115-125 | 241-404 (measured) |
The node estimates use the ratio between the node's measured totals and the local totals: about 2.5-3x for computation and 3-5x for hashing. Locally, GC takes about 3% of CPU.
Two inefficiencies cause more than half of the cost
- Rewards and penalties.
processRewardsAndPenaltiesPostAltair(process_rewards_and_penalties.go:106) runs onruntime.NumCPU()workers. Each worker writes balances throughSetValidatorBalance→MerkleTree.MarkLeafAsDirty, and every call takes the tree'sRWMutexread lock (merkle_tree.go:55). Readers do not block each other, but they all update the same reader count, so about 90% of the loop's CPU is cache-line contention on that counter. This applies when the balances Merkle tree is initialized, which is the normal case for a warm Fulu state; Gloas hashes balances without that tree. cl/transition: restore parallel rewards and inactivity epoch loops #23966 already noted this lock and left it out of scope. - Effective balance updates.
ProcessEffectiveBalanceUpdates(process_effective_balance_update.go:48) callsSetEffectiveBalanceForValidatorAtIndexevery time the hysteresis condition is true, even when the new value equals the old one. Validators that are already at the maximum and hold excess balance meet this condition in every epoch. On this state, 120,912 validator leaves become dirty, but only 9 effective balances change. About 70% of the post-epoch hash is spent rehashing these unchanged validator leaves.
Experiment in a scratch copy, not a PR:
- compute the reward/penalty deltas in parallel and apply them serially;
- skip the effective-balance write when the value is unchanged.
The post-epoch state root stays identical in all four combinations on the Fulu state. On the same state upgraded to Gloas, the root is also identical between the baseline and both fixes together. The state bytes are the same because eligible indices are unique and no worker reads a balance that another worker writes. What changes is callback behaviour: OnNewValidatorEffectiveBalance is no longer called for no-op writes, and OnNewValidatorBalance is called in a different order. The antiquary installs neither callback and computes history from the final state bytes (state_antiquary.go:264, :569).
| Local wall ms | Baseline | Rewards fix | EB fix | Both |
|---|---|---|---|---|
ProcessSlots across the boundary |
194 | 92 | 196 | 92 |
post-epoch HashSSZ |
102 | 102 | 20 | 20 |
| rewards and penalties step | 114 (CPU 1.2 s) | 18 (CPU 56 ms) | 119 | 18 |
handler path: copy + ProcessSlots(start+1) |
413 | — | — | 228 |
So the block-import transition would drop from about 296 ms to 112 ms locally, roughly 0.85 s to 0.3 s on the node. With fewer threads (4) the result is the same, so the gain does not depend on the core count.
Where the same transition runs again
For one boundary on one chain, the transition from E to E+1 runs on these paths:
- Block import, in place on the fork graph's current state, under
f.mu(fork_graph_disk.go:335, called fromon_block.go:435). - Gossip validation of the boundary block.
computeBlockValidationContextcopies (or reconstructs) the parent state underf.mu.RLock(block_service.go:718,forkchoice.go:831), then runsProcessSlotsafter the lock is released (:743). It only needs the expected proposer,latest_block_hashand the latest bid. cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523 removed the copy only for same-epoch parents. In Fulu and later, the parent state'sproposer_lookaheadalready contains the proposer for the next epoch, andGetBeaconProposerIndexForSlotalready reads it (cache_accessors.go:152). Exceptions: at the Fulu activation epoch the parent has no lookahead, and at the Gloas activation epochUpgradeToGloascreateslatest_block_hashand the initial bid, so the copy is still needed there. attestation_datawhen the head is in the previous epoch (attestation_producer.go:165-181). It also advances one slot past the epoch start, which adds a full post-epoch hash. The target root is the same for start and start+1, and the source is the same as well. The cache is not checked again afterattCacheMutexis taken, so concurrent misses each run the full fill, one after another.- Payload preparation and block production when we propose the first slot of an epoch (
payload_preparation.go:582,block_production.go:846). These repeat the same work only while the head stays the same.
Gossip validation and import always repeat the boundary transition. The other paths are conditional. Checkpoint states advance only to the epoch start and are singleflighted and cached (forkchoice/utils.go:293), and normal one-epoch attestations reuse the head-state shuffling instead (on_attestation.go:63), so they are rarely an extra cost.
The attestation handler only needs the post-epoch current_justified_checkpoint. Fork choice already computes it for every block: OnBlock runs ProcessJustificationBitsAndFinality on the post-block state and stores the result as unrealizedJustifications[blockRoot] (on_block.go:551-561). When the head is in epoch E and the request is for E+1, this is the same computation on the same data for the same head. Empty slots do not change participation or balances. When more than one boundary is crossed, the value is different, and a fallback is needed. Today the cache is keyed by epoch only, so a cached entry can outlive a head change.
Shuffling and caches
ProcessEpochnever computes the E+1 attester shuffle. The E+1 shuffle is needed later, for the second block of E+1 and for E+1 attestations. It hitsShuffledIndiciesCacheGlobalthrough the precache inpostForkchoiceOperations(stages/forkchoice.go:533-605). A miss costs about 270 ms CPU locally.- The proposer lookahead uses the E+2 active set. It cannot use the global cache because the cache key needs the block root at the state's current slot, and
GetBlockRootAtSlotrejects that slot (raw/getters.go:360). The cost is small: about 14 ms locally on every path. This is the same key problem as in Caplin: Gloas performance at Hoodi/mainnet scale #25120 item 1. The firstGetTotalActiveBalanceof E+1 can still scan all validators, because nothing warms it before the boundary. Copydrops the per-state caches and rebuilds the pubkey index (copy.go:44). This is about 75% of the copy CPU and is tracked in cl/phase1/forkchoice: race on the fork graph's shared current state between block import and head-state readers #25111.
Proposed directions (ranked by gain/complexity)
- Remove the two inefficiencies (high gain, low complexity, consensus-neutral).
- Skip
SetEffectiveBalanceForValidatorAtIndexwhen the value does not change. - Do not mark balance leaves dirty through a contended read lock from many goroutines. Either compute deltas in parallel and apply them in one pass, or make dirty marking lock-free or batched per chunk.
- Risks:
OnNewValidatorEffectiveBalanceis no longer called for no-op writes andOnNewValidatorBalanceorder changes; no production consumer depends on either today. Add a regression test that compares post-epoch roots on a large state.
- Skip
attestation_datawithout a full state advance (high gain for validators, low-medium complexity).- When the head is exactly one epoch behind the request, use the head block's unrealized justified checkpoint as the source and (E+1, head root) as the target. Otherwise fall back.
- In the fallback, advance only to the epoch start, not start+1.
- Do the work outside the synced-data read lock and outside the exclusive
attCacheMutex, for example with singleflight. - Key the cached base data by (epoch, head root) instead of a 6 s TTL, and check the cache again after taking the lock.
- Risks: prove the equivalence with tests against
ProcessSlots(epoch-start head block, genesis epochs, multi-epoch gaps). Read the fork-choice value before takingattCacheMutex, so that no new lock-order edge is added (postForkchoiceOperationsholds the MDBX write transaction and then takesattCacheMutex).
- Gossip validation of the boundary block (medium gain, low complexity). In Fulu and later, except at the Fulu and Gloas activation epochs, read the proposer from the parent's
proposer_lookaheadand skip the copy andProcessSlots. This extends cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523. It saves one copy underf.mu.RLockand one epoch transition before the block is forwarded and imported. - Precompute the next-epoch state for the head (largest gain, medium-high complexity).
- During the last slot of an epoch, advance a copy of the head state across the boundary outside
f.mu, including the next state-root hash. - Share it with block import (swap it in under the lock when the parent is still that head), the attestation handler, and payload preparation/production.
- Risks: one more full state in memory; wasted work when the head changes late in the slot; strict ownership, because the fork graph mutates
currentStatein place; andTransitionStatemust accept a state that is already at the block slot.
- During the last slot of an epoch, advance a copy of the head state across the boundary outside
Gloas
Gloas adds ProcessPtcWindow, which reshuffles the full set for E+2 on every state that crosses the boundary. This is about 300 ms CPU locally and the largest single step (#25120 item 1, being fixed separately).
Directions 1-4 apply unchanged to Gloas:
- With the effective-balance fix, the Gloas post-epoch hash drops from 244 to 96 ms CPU locally.
- Every duplicate path in the list above also repeats the PTC shuffle until that fix lands, so removing duplicates is worth more after the fork.
- For gossip validation, the bid is already read before the advance, and
ProcessEpochdoes not changelatest_block_hash. The existing copy path is still needed at the Gloas fork epoch.
Related
- Caplin: Gloas performance at Hoodi/mainnet scale #25120 (Gloas performance tracking; item 1 is the E+2 PTC shuffle; the copy cost is in cl/phase1/forkchoice: race on the fork graph's shared current state between block import and head-state readers #25111)
- cl/phase1/network/services: gossip block validation copies and advances the full parent state, adding ~0.4s to every block #24522 / cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523 (gossip validation copied the parent state; fixed only for same-epoch parents)
- cl/transition: restore parallel rewards and inactivity epoch loops #23966 (restored the parallel rewards loop; left the
MarkLeafAsDirtyread lock out of scope) - cl: bound payload preparation work and recover post-PTC retries #24119 (payload preparation does expensive state work close to the deadline; benefits from directions 1 and 4)
Activity
lystopad commentedon Oct 21, 2026
Mainnet data for this issue, from a Caplin validator node on release/3.8 (Fulu, ~1M active validators).
Head lag at the boundary. From the beacon event stream (seconds after slot start):
On the other slots of the same windows the gap is ~0.16 s (median, 150 slots).
Effect on attestations. Over 24 h I compared the head and target votes of the validators on this node with the whole network, per slot, from attestations included on chain:
The 1.0-1.2 s import-to-head gap matches the
AddChainSegmenttransition measured on Hoodi; when the block itself arrives late (~2.6-3 s), the head lands at or after the 4 s attestation deadline. So direction 1 (#25144) helps, but the remaining ~0.8 s needs directions 2-4 to keep the head ahead of the deadline on mainnet.VBulikov commentedon Oct 21, 2026
yperbasis added Imp1 as Importance
[r3.8] cl/transition/impl/eth2/statechange: avoid contended and no-op…
cl/transition/impl/eth2/statechange: avoid contended and no-op dirty …
awskii commentedon Oct 22, 2026
Mainnet data from two embedded-Caplin nodes, 2026-10-21: A from 02:40 to 17:40 UTC, B from 06:30 to 17:40 UTC. Times are p50 ms after slot start, from the EL's
validating chainandhead validatedlines against block timestamps. "post-EL" is EL start to FCU, minus EL execution. Neither node has #23981.Epoch start vs other, Mann-Whitney over hourly windows: p 1.5e-06 (A), 3.7e-05 (B).
ProcessProposerLookaheadonly shiftsnew[i] = old[i+SLOTS_PER_EPOCH], and the context reads nothing thatProcessEpochwrites. The exceptions are the Fulu and Gloas activation epochs listed above.full_block_processing_time, 10 epochs per node, 17:50–18:45 UTC): 204.7 / 194.9 ms at epoch start, against 24.0 / 22.7 ms on other slots.domiwei commentedon Oct 22, 2026
Hoodi data after direction 1, #23981 and #25151, to see how much directions 2-4 can still save.
Setup: Caplin on a Hoodi node (Fulu, ~1.14M active validators), 8 cores / 16 threads, with local log-only probes. The first 10 minutes after each restart are excluded. "Processing" is the time from the first gossip arrival of the slot's block to the first
postForkchoiceOperationsof that slot. Values are p50.Boundary blocks also arrive later: p50 ~1.5 s after slot start, against ~0.85 s for other blocks. That part comes from the proposers, not from us.
What the remaining ~1.0 s is made of
AddChainSegmentunderf.muProcessSlotsacross the boundaryProcessSlots~400 ms)The gossip validation path has no probe. The direction 3 row uses the same operation in the
attestation_datafill (copy the head state,ProcessSlotsacross the boundary), so treat it as an estimate.Expected gains on this node
attestation_datafaster at the boundary, but the head does not change sooner.Votes. The
beacon_block_rootthatattestation_datareturns 4 s into each slot, compared with the canonical chain:Slot 0 is now close to the other slots, but the boundary sample is small.
Next: direction 3 first, then measure again before deciding on direction 4.
domiwei commentedon Oct 23, 2026
Two more costs under
f.muafter #25163Hoodi node again (1.14M active validators, Fulu), now on a release/3.8 rehearsal build that includes #25163, with the same lock probe. The first block of an epoch now holds
f.mufor about 425 ms (p50). Over about 2 days there were only 4 holds of 700 ms or more. They come from two causes that this issue does not list yet.Note on the probe site: these holds are reported as
newPayloadForBlockWhileYieldingForkChoiceLock. That function releasesf.muaround the EL call and takes it back with a deferredLock(on_execution_payload.go:466-467), so the probe charges the rest ofonBlockto it. The EL is not the cause:execution=is about 10 ms andflush+commit=about 12 ms for these blocks. The "~0.9 s once per epoch" row above is the same misattribution: it was the epoch transition.During the 07:48 hold, other
f.muusers waited up to:ProcessAttestingIndicies1643 ms,OnTick842 ms,GetHead838 ms, the nextonBlock658 ms.1. Sync-committee period boundary (once every 256 epochs, ~27 h)
A goroutine dump taken during the 07:48 stall shows the lock holder in
ComputeShuffledIndex←ComputeNextSyncCommittee(cache_accessors.go:438) ←ProcessSyncCommitteeUpdate←ProcessEpoch←AddChainSegment(on_block.go:439).ComputeNextSyncCommitteeshuffles each candidate index on its own (90 rounds each). With Electra balance-weighted selection, tens of thousands of candidates are tried to fill 512 seats. The transition at these two boundaries took 928–959 ms instead of ~425 ms, so this step costs about 0.5 s on the node. This is inferred from one stack sample and the timing difference; it was not profiled.Possible fix: compute the shuffled indices in one batch (or in parallel) instead of one
ComputeShuffledIndexcall per candidate. Thesync_committee_updatesspec tests cover the result. The Gloas selection path needs the same check.2. Block whose parent is not the current state
When the parent is not the fork graph's current state,
AddChainSegmentreads and decodes the parent state from disk (fork_graph_disk.go:271-273) and hashes the light-client branch (:299) before the transition, all underf.mu. Together this is 0.9–1 s on the node. The hashing is not extra work (process_slotwould hash the state anyway), but it now happens under the lock. Direction 4 does not help here, because it prepares only the head's next state.After Gloas
The same work moves into
onBlockwith no yield, because Gloas blocks carry no payload.applyEnvelopefollows the same pattern: it takesf.mu(on_execution_payload.go:780), may load the state from disk (getStateLocked), and processes and dumps the envelope under the lock, while the EL call itself is yielded.Line references are on
main2cedbab.domiwei commentedon Oct 23, 2026
Mainnet data for direction 3 (gossip validation of the boundary block), from public ethPandaOps data.
Source: Lab table
fct_block_first_seen_by_node,block_gossipevents only, 2-minute windows every 4 hours from 2026-10-08 to 2026-10-22 (mainnet, Fulu). Lateness is the node'sblock_gossiptime minus the median of all observer nodes (~140) for the same block. Two independent Caplin observer nodes upgraded from v3.7.1 to v3.8.0 on 2026-10-20, so the same nodes can be compared before and after #24523.Other clients (~130 nodes) change by -10 to +3 ms on the first slot of an epoch.
ProcessSlotspath beforeblock_gossip; cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523 removed it for same-epoch parents.ProcessSlotsbeforeblock_gossipis emitted.cl: validate boundary blocks without a state copy (erigontech#25253)
cl/phase1/forkchoice/fork_graph: skip re-dumping an already dumped he…
cl, cmd/caplin: precompute the next-epoch state off the fork-choice l…