Skip to content

Caplin: the epoch transition takes ~1 s under the fork-choice lock and runs on several paths #25136

Open

Description

@domiwei

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 ProcessEpoch and the following state-root hash inside AddChainSegment, under the fork-choice write lock f.mu.
  • attestation_data. When the head is still in the previous epoch, the handler copies the head state and runs ProcessSlots across the boundary. It holds attCacheMutex exclusively 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:

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

  1. Rewards and penalties. processRewardsAndPenaltiesPostAltair (process_rewards_and_penalties.go:106) runs on runtime.NumCPU() workers. Each worker writes balances through SetValidatorBalance → MerkleTree.MarkLeafAsDirty, and every call takes the tree's RWMutex read 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.
  2. Effective balance updates. ProcessEffectiveBalanceUpdates (process_effective_balance_update.go:48) calls SetEffectiveBalanceForValidatorAtIndex every 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:

  1. Block import, in place on the fork graph's current state, under f.mu (fork_graph_disk.go:335, called from on_block.go:435).
  2. Gossip validation of the boundary block. computeBlockValidationContext copies (or reconstructs) the parent state under f.mu.RLock (block_service.go:718, forkchoice.go:831), then runs ProcessSlots after the lock is released (:743). It only needs the expected proposer, latest_block_hash and 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's proposer_lookahead already contains the proposer for the next epoch, and GetBeaconProposerIndexForSlot already reads it (cache_accessors.go:152). Exceptions: at the Fulu activation epoch the parent has no lookahead, and at the Gloas activation epoch UpgradeToGloas creates latest_block_hash and the initial bid, so the copy is still needed there.
  3. attestation_data when 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 after attCacheMutex is taken, so concurrent misses each run the full fill, one after another.
  4. 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

  • ProcessEpoch never 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 hits ShuffledIndiciesCacheGlobal through the precache in postForkchoiceOperations (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 GetBlockRootAtSlot rejects 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 first GetTotalActiveBalance of E+1 can still scan all validators, because nothing warms it before the boundary.
  • Copy drops 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)

  1. Remove the two inefficiencies (high gain, low complexity, consensus-neutral).
    • Skip SetEffectiveBalanceForValidatorAtIndex when 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: OnNewValidatorEffectiveBalance is no longer called for no-op writes and OnNewValidatorBalance order changes; no production consumer depends on either today. Add a regression test that compares post-epoch roots on a large state.
  2. attestation_data without 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 taking attCacheMutex, so that no new lock-order edge is added (postForkchoiceOperations holds the MDBX write transaction and then takes attCacheMutex).
  3. 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_lookahead and skip the copy and ProcessSlots. This extends cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523. It saves one copy under f.mu.RLock and one epoch transition before the block is forwarded and imported.
  4. 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 currentState in place; and TransitionState must accept a state that is already at the block slot.

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 ProcessEpoch does not change latest_block_hash. The existing copy path is still needed at the Gloas fork epoch.

Related

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added
    CaplinCaplin: Consensus Layer, Beacon API
    on Oct 20, 2026
  3. lystopad commented on Oct 21, 2026

    @lystopad
    Member

    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):

    first slot of an epoch block imported head event gap
    boundary 1 2.63 3.66 1.03
    boundary 2 2.88 3.99 1.11
    boundary 3 (previous day) 2.98 4.21 1.23

    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:

    • Most misses are shared with the network (late proposals): network head 98.76%, target 99.58%.
    • In 15 of ~225 epoch boundaries the node's attesters of the first slot voted the wrong head and target, while >98% of the network voted correctly in the same slot. These boundary-only misses are about 36% of the node's target misses and 16% of its head misses for the day; all are at slot 0 of an epoch (or slot 1 after an empty slot 0).

    The 1.0-1.2 s import-to-head gap matches the AddChainSegment transition 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.

  4. added this to the 3.8.1 milestone on Oct 21, 2026
  5. VBulikov commented on Oct 21, 2026

    @VBulikov
    Member

    yperbasis added Imp1 as Importance

  6. awskii commented on Oct 22, 2026

    @awskii
    Member

    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 chain and head validated lines against block timestamps. "post-EL" is EL start to FCU, minus EL execution. Neither node has #23981.

    node slots n EL start EL exec post-EL head head > 4 s
    A: main of 2026-10-15 + #24985, #25003; 8 cores other 4196 1831 49 40 1925 0.3%
    A first of epoch 138 2408 46 892 3237 20.3%
    B: main of 2026-09-17; 16 cores other 3123 1957 50 98 2113 0.6%
    B first of epoch 103 2193 48 748 2927 16.5%

    Epoch start vs other, Mann-Whitney over hourly windows: p 1.5e-06 (A), 3.7e-05 (B).

    • Direction 3 is the pre-EL column: +577 ms on A, which copies only at the boundary since cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523, and +236 ms on B, which copies on every block. I tried to break the in-place read for a Fulu+ parent exactly one epoch back with no fork at the block's epoch and could not. ProcessProposerLookahead only shifts new[i] = old[i+SLOTS_PER_EPOCH], and the context reads nothing that ProcessEpoch writes. The exceptions are the Fulu and Gloas activation epochs listed above.
    • The transition alone (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.
    • Paired on blocks both nodes saw (26,239,188–26,242,517: 3,122 other, 103 epoch-start), A is 202 ms earlier than B on other slots and 204 ms later at epoch starts (p 0.00048, hourly windows). The hosts differ and this is not bisected, so it is a lead, not a regression claim.
  7. domiwei commented on Oct 22, 2026

    @domiwei
    MemberAuthor

    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 postForkchoiceOperations of that slot. Values are p50.

    binary epoch boundaries boundary block other blocks extra at the boundary
    main of 2026-10-19/20 (no #23981, #25144, #25151) 343 2.33-2.47 s 0.53-0.57 s ~1.8 s
    main with #23981, #25144, the first part of #25151 and a few unmerged changes 192 1.79-1.90 s 0.73-0.77 s ~1.1 s
    release/3.8 rehearsal build, including #25163, #25171 and #25151 16 1.62 s 0.59 s ~1.0 s (small sample)

    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

    cost now before direction
    transition in AddChainSegment under f.mu ~0.43 s 0.82-0.85 s 4
    state copy + ProcessSlots across the boundary ~0.45 s (copy 50-60 ms, ProcessSlots ~400 ms) ~1.06 s 3
    not attributed ~0.1-0.2 s

    The gossip validation path has no probe. The direction 3 row uses the same operation in the attestation_data fill (copy the head state, ProcessSlots across the boundary), so treat it as an estimate.

    Expected gains on this node

    • Direction 3: ~0.4-0.45 s. The extra at the boundary drops from ~1.0 s to ~0.6 s, and the block is forwarded earlier.
    • Direction 4: another ~0.43 s from import. It could also cover the direction 3 cost, but it is the most complex.
    • Direction 2: the boundary fill now costs ~0.45 s (it was ~1.06 s). Removing it makes attestation_data faster at the boundary, but the head does not change sooner.

    Votes. The beacon_block_root that attestation_data returns 4 s into each slot, compared with the canonical chain:

    slot 0 wrong other slots wrong
    before 12/385 (3.1%) 126/11,909 (1.1%)
    after 4/289 (1.4%) 114/8,967 (1.3%)

    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.

  8. modified the milestones: 3.8.1, 3.8.2 on Oct 23, 2026
  9. domiwei commented on Oct 23, 2026

    @domiwei
    MemberAuthor

    Two more costs under f.mu after #25163

    Hoodi 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.mu for 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 releases f.mu around the EL call and takes it back with a deferred Lock (on_execution_payload.go:466-467), so the probe charges the rest of onBlock to it. The EL is not the cause: execution= is about 10 ms and flush+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.

    Time (UTC) Hold Cause
    10-22 04:29 937 ms first block of epoch 131328, sync-committee period boundary; transition 928 ms
    10-23 05:23 1008 ms block on a side branch: parent state read from disk (85 ms) and decoded (283 ms), light-client branch hashing 595 ms; transition 34 ms
    10-23 07:47 967 ms first block of epoch 131584, period 514 boundary; transition 959 ms
    10-23 07:48 1953 ms the epoch-start block 4210688 arrived ~20 s late, after its sibling 4210689: parent from disk 358 ms, light-client branch 634 ms, transition 950 ms

    During the 07:48 hold, other f.mu users waited up to: ProcessAttestingIndicies 1643 ms, OnTick 842 ms, GetHead 838 ms, the next onBlock 658 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). ComputeNextSyncCommittee shuffles 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 ComputeShuffledIndex call per candidate. The sync_committee_updates spec 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, AddChainSegment reads and decodes the parent state from disk (fork_graph_disk.go:271-273) and hashes the light-client branch (:299) before the transition, all under f.mu. Together this is 0.9–1 s on the node. The hashing is not extra work (process_slot would 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 onBlock with no yield, because Gloas blocks carry no payload. applyEnvelope follows the same pattern: it takes f.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 main 2cedbab.

  10. domiwei commented on Oct 23, 2026

    @domiwei
    MemberAuthor

    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_gossip events only, 2-minute windows every 4 hours from 2026-10-08 to 2026-10-22 (mainnet, Fulu). Lateness is the node's block_gossip time 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.

    Node Version Blocks (other slots) Lateness, other slots Blocks (first slot of epoch) Lateness, first slot of epoch
    US v3.7.1 787 +451 ms 42 +662 ms
    US v3.8.0 61 +23 ms 3 +666 ms
    Finland v3.7.1 758 +220 ms 41 +368 ms
    Finland v3.8.0 81 -62 ms 4 +345 ms

    Other clients (~130 nodes) change by -10 to +3 ms on the first slot of an epoch.

    • On other slots, v3.8.0 is in line with other clients. In v3.7.1 every block went through the copy and ProcessSlots path before block_gossip; cl/phase1/network/services: read same-epoch parent state in place for gossip block validation #24523 removed it for same-epoch parents.
    • On the first slot of an epoch, v3.8.0 is still 400-650 ms later than on other slots on these nodes. This matches direction 3: the boundary block still copies the parent state and runs ProcessSlots before block_gossip is emitted.
    • The v3.8.0 boundary sample is small (7 blocks). I will re-measure when more v3.8.0 data is available.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

CaplinCaplin: Consensus Layer, Beacon APIperformance

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions