Skip to content

perf(beacon): precompute the epoch transition off the import path - #652

Merged
MegaRedHand merged 1 commit into
perf/beacon-justified-balancesfrom
perf/beacon-epoch-precompute
Oct 27, 2026
Merged

MegaRedHand merged 1 commit into
perf/beacon-justified-balancesfrom
perf/beacon-epoch-precompute

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

The first block of every epoch runs process_epoch inline on the chain actor (fork_choice::on_block, then stf::state_transition, then process_slots), followed by the post-epoch rehash the state-root check needs. In the import replay such blocks take ~1.5 s against ~0.58 s for an ordinary block (measured on the replay).

The transition depends only on the parent, which is known by the end of the previous epoch's last slot, so it can run ahead of time on a blocking worker, off the import path.

Design

Part Behaviour
Trigger: head An import leaves a block at the last slot of epoch E as head: precompute (E+1, head_root). Covers a late last-slot block.
Trigger: timer Three quarters into the last slot of E, derived from the configured slot duration. The once-per-slot tick arms a delayed self-message on last slots only (send_after). Starts a worker only if none is running and the key is not cached. Covers a skipped last slot, where the head is an earlier block.
Gate Beacon only. Skipped while the sync tracker says syncing, and for a head more than one slot behind the wall clock (the tracker updates once per slot, so a follower replaying history would otherwise precompute every epoch).
Work spawn_blocking: clone the head state, process_slots to the first slot of E+1, flush pending writes, hash_tree_root so tree-node hashes are memoized, then message the state back. At most one worker in flight; deduped by (epoch, root) through the cache.
Store CacheKey::CheckpointState { epoch: E+1, root: head_root }. checkpoint_state derives exactly that value for the checkpoint (the checkpoint block's post-state advanced to the epoch's first slot), so attestation targets for E+1 hit it too. A unit test asserts equal hash_tree_root.
Consume on_block: when the parent's slot is before the block epoch's first slot and the block is at or after it, look up CheckpointState { block_epoch, parent_root }. A hit clones the entry, advances any remaining skipped slots, and applies the block with the new stf::apply_block (the part of state_transition after process_slots; process_slots rejects a target equal to the current slot, so a boundary block cannot go through state_transition).
Miss / race Today's path, unchanged. A block that arrives while the worker runs imports inline; the late result is stored and unused by that block.

Forks: process_slots performs the fork upgrade at the boundary, so nothing here is fork-specific.

New metrics (documented in docs/metrics.md): lean_beacon_epoch_precompute_lookups_total{result} (epoch-crossing imports only), lean_beacon_epoch_precompute_seconds, lean_beacon_epoch_precompute_started_total{trigger}.

Not in this PR

  • Progressive balances (O(1) justification, and a cheaper compute_pulled_up_tip).
  • Holding the block when it races the worker (it computes inline instead, so no ordering dependency is added).
  • Advancing the head every slot; only epoch boundaries are advanced.

Testing

  • ethlambda-storage lib, ethlambda-state-transition lib, ethlambda-blockchain lib, beacon spec suite (mainnet), clippy and cargo check -p ethlambda: see counts below.
  • New unit tests: resuming from a precomputed state gives the inline post-state (block on the boundary and with 1 and 3 skipped slots, compared with the block's committed state root); the lookup applies only to epoch-crossing imports; the worker's state equals checkpoint_state's for the same key; trigger decisions as a pure function (head, timer, syncing, stale head, in-flight, already cached) and the three-quarter delay.
  • There is no actor-level test: the beacon actor's test harness drives handlers without a Context, so the trigger decision is tested as a pure function instead.
  • Not measured: no benchmark was run for this PR.

Results: storage lib 138 passed (1 ignored); state-transition lib 327 passed (16 ignored); blockchain lib 217 passed; beacon spec suite (mainnet) 5734 passed, 4 failed (the 4 expected gossip matched_fixture_cases on the empty gossip dir), 152 ignored; clippy (workspace, and state-transition with beacon-spec-tests) clean; cargo check -p ethlambda clean.

@MegaRedHand MegaRedHand added the beacon Ethereum Beacon Chain client label Oct 2, 2026
MegaRedHand added a commit that referenced this pull request Oct 2, 2026
…636-638-gloas-live

Conflicted files:
- crates/blockchain/src/lib.rs
- crates/blockchain/state_transition/src/beacon/fork_choice.rs
- crates/blockchain/state_transition/src/metrics.rs
- docs/metrics.md

Gloas: the precompute stays enabled, because the base state cannot differ.
In this branch a gloas block's post-state never holds its own payload: the
child applies it (process_parent_execution_payload) inside process_block,
after process_slots, and on_execution_payload_envelope only records and
checks, it stores no second state. The store keeps one state per root and
both on_block (get_state(parent_root)) and checkpoint_state read it, and the
worker clones get_state(head_root) and caches the result under
CheckpointState { E+1, head_root }, so the entry is process_slots of the very
state an import of a child of that root clones. The fulu-to-gloas boundary
needs no case either: the upgrade runs inside process_slots on both paths.
A new unit test (a_gloas_precompute_starts_from_the_state_an_import_would)
checks, for a gloas parent, that the worker's result equals
checkpoint_state's for the key, that an import finds it, and that advancing
the rest of a skipped first slot from it equals plain process_slots.

Other adaptations:
- transition_block and stf::apply_block take the ActiveBalanceCache that
  #648 added to state_transition; on_block passes the store's own cache.
- on_block's gloas-specific code between the parent read and the
  transition does not use the cloned state, so removing that early clone
  (as #652 does) needed nothing else.
- lib.rs: the NewBlock handler keeps the envelope settling that follows the
  import and triggers the precompute after it, so a head moved by a settled
  envelope's block is seen.
- metrics.rs and docs/metrics.md: both sides' metrics kept.
- Test fixtures of #652 follow this branch's BlockInputs (operations) and
  the extra argument.
- docs/beacon_stf.md notes why gloas shares the key.
MegaRedHand added a commit that referenced this pull request Oct 5, 2026
@MegaRedHand MegaRedHand mentioned this pull request Oct 5, 2026
4 tasks
MegaRedHand added a commit that referenced this pull request Oct 5, 2026
…36-638-gloas-live

Brings gloas validator duties (produceBlockV4, envelope publication, PTC
duties and payload attestations, gloas attestation data and aggregates, VC
gloas support) onto the deployment branch, keeping every behavior of #626,
#633, #636, #638, #646, #647-#652, #656, #658-#660 and the sync-committee and
liveness endpoints.

Conflict resolutions keep both sides: the attestation pool stays in Store
(tmp) while the payload attestation pool is threaded through P2P and the RPC
handles (feature); the aggregate endpoints keep tmp's liveness recording and
attesting indices and add the feature's fork-header check and gloas pooling;
the VC tests and fake execution client serve both fulu blobs and gloas V6.

Semantic fixes:
 a. POST /eth/v2/beacon/blocks (gloas) calls publish_beacon_block(block,
    Vec::new()): gloas columns travel with the envelope. RecordingNetwork
    implements publish_beacon_block(block, sidecars) and both new methods.
 b. produceBlockV4 appends client versions to the graffiti exactly like
    produceBlockV3 (graffiti::execution_client_version run alongside the
    payload build, with_client_versions, Extension<OwnVersion>) and logs it.
 c. Attestation data, aggregate_attestation keep require_execution_client and
    require_validated for gloas slots; payload_attestation_data now applies
    the same two rules (503 without an execution client, or when the voted
    block's payload is unvalidated).
 d. Proposer duties v1 and v2 serve gloas epochs from a fulu or gloas state's
    proposer_lookahead; nothing refuses gloas any more; v2 keeps its
    dependent root.
 e. The VC's per-validator ProposerSettings apply to gloas proposals (graffiti
    in the BlockRequest, fee recipient compared with the bid's); a test pins
    the graffiti. VC tests updated to the ProposerSettings constructors.
 f. gloas production reads the attestation pool from Store and calls the
    stf with the ActiveBalanceCache the perf work added; fulu production and
    pack_operations are untouched (gloas blocks carry no pooled operations).
 g. Chain events are emitted by the chain actor only, so nothing on the RPC
    publish paths needed to move; gloas imports reach it unchanged.
 h. Cargo.lock unchanged; cargo check --locked passes.
Base automatically changed from beacon-chain-integration to main October 20, 2026 16:19
@MegaRedHand
MegaRedHand force-pushed the perf/beacon-epoch-precompute branch from 4d896d2 to 2f83786 Compare October 20, 2026 17:32
MegaRedHand added a commit that referenced this pull request Oct 21, 2026
Squash of perf/beacon-epoch-precompute at 2f83786 (merge-base 35c074b).

Adapted onto #634 and #648: the precompute covers gloas under the same
key, and its slot processing takes the active-balance cache.
zexoverz pushed a commit to zexoverz/ethlambda that referenced this pull request Oct 22, 2026
## 🗒️ Description / Motivation

Merges `beacon-chain-integration` into `main`. This adds an **Ethereum
Beacon Chain** client (phase0 through fulu) to the ethlambda binary,
alongside the Lean consensus client:

- **`ethlambda beacon`**: a beacon node. It checkpoint-syncs, follows
the chain over gossip and req/resp, validates execution payloads through
an execution client's Engine API, and serves the Beacon API, including
the validator endpoints.
- **`ethlambda validator`**: a beacon validator client (attest,
aggregate, propose) that talks to any beacon node over the standard REST
API.
- **`ethlambda node`** (lean) keeps its behavior, apart from the items
under "Behavior changes for lean" below.

Both chains run on the same storage layer, P2P actor, RPC crate and
chain actor. `BeaconState` has a `Lean` variant holding lean's `State`,
and each shared handler dispatches on it once, at the top.

Each piece was reviewed as its own PR into the integration branch
(listed under "Related Issues / PRs"). This PR merges that branch.

## What Changed

| Area | Change |
|---|---|
| `ethlambda-types` (`beacon/`) | Containers for phase0 to fulu, presets
(compile-time `preset-minimal` feature), runtime fork config, and a
`Lean` variant on `ForkName` and `BeaconState` |
| `ethlambda-state-transition` (`beacon/`) | State transition, epoch
processing, fork upgrades, fork choice, BLS (`blst`), KZG (`c-kzg`),
gossip validation rules, DAS custody math |
| `ethlambda-ssz-tree` (**new**) | Persistent Merkle `List`/`Vector` for
the validator registry and balances, so consecutive states share
unchanged subtrees |
| `ethlambda-storage` | One `Store` for both chains. Beacon states are
stored as snapshots plus VCDIFF deltas and written on a background
thread. Adds a committee cache, and `db_version`/`chain`/`preset`
metadata that resume checks |
| `ethlambda-blockchain` | Beacon path in the chain actor
(`BlockChain::spawn_beacon`): import, holding blocks until their parent
or columns arrive, applying gossip aggregates to fork choice, block
production and aggregation |
| `ethlambda-engine` (**new**) | Engine API client with JWT auth:
`newPayload`, `forkchoiceUpdated`, `getPayload`, `getBlobs` |
| `ethlambda-p2p` | Beacon wire: blocks, aggregates, subnet attestations
and data columns are validated before relay;
`beacon_blocks_by_{range,root}/2` served and requested; range sync;
column custody and sampling; attestation subnet backbone; discv5 always
on for `beacon` |
| `ethlambda-rpc` | Beacon API under `/eth/v1` and `/eth/v2`: JSON by
default, SSZ on request. The router is picked by `Store::chain()`, so
`/lean/v0` and the Beacon API are never served together |
| `ethlambda-validator` (**new**) | Validator client: duties,
attestations, aggregation selection, proposals, keymanager API. It has
**no slashing protection** (recorded in `docs/spec_deviations.md`) |
| `bin/ethlambda` | `beacon` and `validator` sub-commands; `--network`
takes a built-in name (`mainnet`, `sepolia`, `hoodi`) or a directory of
published network files; beacon checkpoint sync; `benchmark import`
replays a real block corpus offline; `run_node` is one startup path for
both chains |
| CI / Makefile | One beacon spec-test job per preset, minimal-preset
check, `make test` split into `test-consensus` and `test-node` (one
runner's disk no longer fits both), benchmark-smoke and tooling jobs,
disk-cleanup action |
| Docs | New: `beacon_stf.md`, `beacon_wire.md`, `beacon_engine.md`,
`cli.md`. Updated: `data_storage.md`, `metrics.md`, `rpc.md`,
`checkpoint_sync.md`, `discovery.md`, `benchmarking.md`,
`spec_deviations.md`, README (beacon quickstart with ethrex) |

## Correctness / Behavior Guarantees

- **Separation:** beacon behavior lives in `beacon` modules that lean
code never reads, and those modules never read lean's. The shared crates
dispatch on the chain once per handler.
- **Lean state at a beacon accessor:** this panics, naming the function
it reached (`lean_state_unreachable` / `lean_fork_unreachable`), so the
caller never gets a wrong answer back. `ForkName::Lean` is left out of
`ForkName::ALL`, so neither `parse` nor a fork upgrade can reach it.
- **Spec conformance:** every consensus-specs `v1.6.1` fixture case
passes on both presets: 5705 mainnet and 40009 minimal, including 150
mainnet `fork_choice` cases. Gossip fixtures are pinned to
`v1.7.0-beta.1`.
- **Resume safety:** a data directory written by another chain, preset
or format version is refused at startup, before any state is read.

### ⚠️ Behavior changes for lean (`ethlambda node`)

- **Existing data directories are refused on resume.** A directory
written by `main` has no `db_version` key, so `Store::from_db_state`
returns `DbVersionMismatch { found: 0, expected: 4 }`. There is no
migration: an upgraded node needs a fresh data directory (or checkpoint
sync).
- `blst` and `c-kzg` are now dependencies of the lean binary too, since
the state-transition crate holds both chains' rules. They are not
feature-gated.
- libssz is a git dependency pinned by `rev`: the beacon containers need
`DerefMut`/`IndexMut` on `SszList` (lambdaclass/libssz#33), which no
crates.io release has yet.
- The `release-fast` profile (used for tests) now sets `debug-assertions
= true`.
- `P2P::spawn` now runs before the chain actor is spawned, so gossip
arriving in that short window is dropped.
- `/lean/v0/blocks/finalized` answers JSON when asked for it by name.
SSZ stays the default.

## Tests Added / Run

- **Beacon spec tests:** `make test-beacon` (or `test-beacon-mainnet` /
`test-beacon-minimal`). Each fixture case is a separately named test,
e.g. `cargo test -p ethlambda-state-transition --test beacon_spec_tests
--features beacon-spec-tests -- electra/attester_slashing`. CI runs one
job per preset.
- **`make test`** still needs no consensus-spec download: the beacon
spec target is behind the `beacon-spec-tests` feature, and the BLS/KZG
vector tests report as ignored without it.
- **Minimal preset:** the `Test minimal preset` CI job builds and runs
the `ethlambda-types` unit tests with `preset-minimal` on.
- **Benchmark smoke:** a CI job runs the `benchmark` sub-command end to
end.
- Unit and integration tests in every new crate (`ssz-tree`, `engine`,
`validator`) and in the beacon paths of storage, p2p, rpc and the chain
actor.

## Related Issues / PRs

<details>
<summary>PRs merged into the integration branch (reviewed on
<code>lambdaclass/ethlambda_private</code>)</summary>

**Types and state transition**
- lambdaclass/ethlambda_private#1 Beacon Chain containers under a
`beacon` namespace
- lambdaclass/ethlambda_private#2 Beacon Chain state transition, phase0
through fulu
- lambdaclass/ethlambda_private#37 keep the registry and balances in
persistent Merkle trees

**Follower, sync and storage**
- lambdaclass/ethlambda_private#3 follow mainnet gossip behind an
`ethlambda beacon` sub-command
- lambdaclass/ethlambda_private#6 unify the lean and beacon storage
layer
- lambdaclass/ethlambda_private#7 checkpoint sync for the beacon chain
- lambdaclass/ethlambda_private#8 serve beacon blocks by range and by
root
- lambdaclass/ethlambda_private#9 give the follower a chain, from the
anchor to the tip
- lambdaclass/ethlambda_private#11 fulu data availability sampling,
custody through the availability gate
- lambdaclass/ethlambda_private#13 validate execution payloads over the
Engine API
- lambdaclass/ethlambda_private#15 follow a network described by files
on disk
- lambdaclass/ethlambda_private#26 add sepolia and hoodi as built-in
networks
- lambdaclass/ethlambda_private#48, lambdaclass/ethlambda_private#52,
lambdaclass/ethlambda_private#53, lambdaclass/ethlambda_private#54: held
blocks, their columns and range batches

**P2P**
- lambdaclass/ethlambda_private#18 stop outbound req/resp requests
crossing protocols
- lambdaclass/ethlambda_private#23 rank dial candidates by custody
redundancy
- lambdaclass/ethlambda_private#28 count why peers drop us
- lambdaclass/ethlambda_private#38 validate beacon gossip before
propagating it
- lambdaclass/ethlambda_private#43 run every data column check in p2p
- lambdaclass/ethlambda_private#47 validate beacon aggregates and subnet
attestations before relaying
- lambdaclass/ethlambda_private#19 apply gossip aggregates to fork
choice, backbone two attestation subnets

**API and validator**
- lambdaclass/ethlambda_private#21 serve the Beacon API from `ethlambda
beacon`
- lambdaclass/ethlambda_private#44 report the preset and constants from
`/eth/v1/config/spec`
- lambdaclass/ethlambda_private#16 beacon-chain validator client
- lambdaclass/ethlambda_private#34 serve the validator API
- lambdaclass/ethlambda_private#40 aggregate and propose through
`ethlambda beacon`
- lambdaclass/ethlambda_private#56 pack other nodes' attestations into
produced blocks

**Performance and metrics**
- lambdaclass/ethlambda_private#10, lambdaclass/ethlambda_private#20,
lambdaclass/ethlambda_private#22, lambdaclass/ethlambda_private#24,
lambdaclass/ethlambda_private#33, lambdaclass/ethlambda_private#49:
registry scans, parallel pubkey validation, committee cache, background
state writes, BLS key validation
- lambdaclass/ethlambda_private#17, lambdaclass/ethlambda_private#31:
import timing by phase
- lambdaclass/ethlambda_private#25 benchmark block import by replaying a
real corpus

**CI**
- lambdaclass/ethlambda_private#12, lambdaclass/ethlambda_private#30,
lambdaclass/ethlambda_private#45: split lint/test jobs, benchmark smoke
job, runner selection

</details>

Open PRs based on `beacon-chain-integration`, **not** included here:
lambdaclass#626, lambdaclass#627, lambdaclass#629, lambdaclass#630, lambdaclass#632, lambdaclass#634, lambdaclass#636, lambdaclass#638, lambdaclass#642, lambdaclass#644 to lambdaclass#652,
lambdaclass#656, lambdaclass#658.

## ✅ Verification Checklist

- [ ] Ran `make fmt` (clean)
- [ ] Ran `make lint` (clippy with `-D warnings`, clean)
- [ ] Ran `make test` (`test-consensus` plus `test-node`, at
`release-fast`), all passing
- [ ] Ran `make test-beacon` (both presets), all passing

---------

Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
Co-authored-by: Klaus Lungwitz <klaus.lungwitz@lambdaclass.com>
Co-authored-by: Klaus @ LambdaClass <18153834+klaus993@users.noreply.github.com>
Co-authored-by: Chase Wright <chase.wright@ethereum.org>
@MegaRedHand
MegaRedHand marked this pull request as ready for review October 26, 2026 16:10
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 652, epoch-transition precompute

The design is sound and the correctness argument holds up. The cache key CheckpointState { epoch, root } is what checkpoint_state already derives, and a test asserts the two have the same hash_tree_root. The fallback to the inline path on a miss makes the precompute a pure speed trade. apply_block correctly handles a block sitting exactly on the boundary, where process_slots would reject an equal target slot. The cached entry is never mutated, since the import clones it. The decide function is pure and well tested.

I found no consensus-correctness bugs. A few robustness points:

  1. Worker panic wedges the precompute permanently (epoch_precompute.rs:217-224, :236). epoch_precompute_in_flight is only cleared in the EpochPrecomputed handler. If advance_to_epoch_start panics inside spawn_blocking, no message is sent, in_flight stays Some, and decide returns None forever. Every later epoch silently falls back to the inline path, with no log. The beacon STF has several unreachable!/expect paths, such as the lean-state arms and SpecAssert. Fix: wrap the closure in catch_unwind and send Err. Alternatively, await the JoinHandle in a small spawned task and send the failure from there. A cheap backstop is to store the start Instant alongside the key and ignore an in_flight older than about one slot.

  2. Stale timer after a delayed tick (epoch_precompute.rs:157-165). If the tick is delayed past the three-quarter point, delay saturates to 0 and the check fires immediately. That is fine for the current slot. After a long stall that catches up through older last slots, however, the timer can fire for a slot well behind the wall clock. The Timer arm of decide checks head_slot > slot but not freshness against current_slot, so it could precompute an epoch nobody needs. The syncing gate probably covers most of this. Adding current_slot > slot to the Timer guard (slot is the wall slot when the timer is armed) would make the gate symmetric with Head.

  3. Cache eviction can discard the result before use (epoch_precompute.rs:241). The result lives in the shared state cache, which attestation-target checkpoint states also churn. The docs mention eviction as a possible cause of misses. It would be worth confirming the cache capacity comfortably holds the entry for the roughly 4 to 12 s between precompute and block arrival. If the cache is small, a miss would show up as lookups_total{result="miss"}.

  4. Minor: repeated work per tick (lib.rs:1035-1036). maybe_start_epoch_precompute(Head) runs on every tick and does beacon_head() and a cached_state lookup before decide can reject. It is cheap, but you could short-circuit with the is_last_slot_of_epoch(head_slot) check first. That would skip the cache lookup on 31 of every 32 slots.

  5. Nit: duplicated epoch math (epoch_precompute.rs:175-178 and :101-118). The candidate epoch is computed in maybe_start_epoch_precompute and again in decide. If the two drift apart, already_cached would be checked against the wrong key. Consider a candidate_epoch(trigger, head_slot) helper shared by both.

  6. Nit: three_quarter_slot computes slot_duration_ms / 4 * 3, which drops up to 3 ms of rounding. That is harmless and not worth changing.

The tests cover the happy path, decide's branches, and resume-versus-inline equivalence for boundary and skipped-slot blocks. A test for the worker-failure path (Point 1) would be a good addition once that is handled.

Point 1 is the only one I'd want addressed before merge. The rest are optional.


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Looks mostly solid. The precompute path is well-contained, keeps cache residency out of consensus decisions, and the transition_block fallback preserves correctness.

  • Potential correctness bug: stale worker results can overwrite a newer cached checkpoint state for the same key. In crates/blockchain/src/epoch_precompute.rs:231, the handler unconditionally clears epoch_precompute_in_flight, then caches msg.result at crates/blockchain/src/epoch_precompute.rs:236. If worker A is started for key (epoch, root), later worker B starts for a different key, and A finishes after B has become the current in-flight worker, A’s completion will still clear the in-flight marker and store its result. That opens two races:

    • a second worker can start while B is still running, violating the “at most one worker” invariant;
    • an older result can overwrite a newer cached state for the same CheckpointState key if the head rolls back/reorgs and later returns to the same root.
      Suggestion: only clear/store when self.epoch_precompute_in_flight == Some(msg.key), otherwise ignore the stale completion. See crates/blockchain/src/epoch_precompute.rs:231.
  • Maintainability/readability: maybe_start_epoch_precompute computes candidate_epoch before calling decide, then decide recomputes the epoch from the trigger again. That duplication in crates/blockchain/src/epoch_precompute.rs:170 and crates/blockchain/src/epoch_precompute.rs:190 is easy to drift. Consider deriving already_cached after decide returns the actual key, or refactoring decide to accept less duplicated state.

  • Performance: advance_to_epoch_start deliberately pre-hashes the state in crates/blockchain/state_transition/src/beacon/fork_choice.rs:904, which is sensible for the fast path. No obvious extra clone/hash churn beyond what the design intends.

  • Consensus/security: I don’t see a fork-choice, attestation, justification/finalization, or SSZ correctness regression in the new transition_block/precomputed_epoch_state flow. The fallback remains the canonical stf::state_transition, and the cached path only substitutes an equivalent process_slots prefix at the epoch boundary (crates/blockchain/state_transition/src/beacon/fork_choice.rs:919 and crates/blockchain/state_transition/src/beacon/fork_choice.rs:974).

If you want, I can turn this into ready-to-post GitHub review comments.


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

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR for epoch-transition precompute optimization in the beacon chain. Let me analyze the code carefully.

Overall Assessment

This is a well-structured optimization PR that precomputes epoch-boundary states to reduce import latency. The design is sound, but I've identified several issues ranging from minor correctness concerns to potential race conditions.


Critical Issues

1. Integer Overflow in three_quarter_slot (Line 147, epoch_precompute.rs)

pub(crate) fn three_quarter_slot(slot_duration_ms: u64) -> Duration {
    Duration::from_millis(slot_duration_ms / 4 * 3)
}

Problem: The multiplication slot_duration_ms / 4 * 3 can overflow if slot_duration_ms is large. While slot_duration_ms / 4 is safe, the subsequent * 3 could overflow for values > u64::MAX / 3 (~6.1 million seconds, unlikely for slot durations but a correctness issue).

Fix: Use checked arithmetic or reorder: slot_duration_ms.saturating_mul(3).saturating_div(4) or Duration::from_millis(slot_duration_ms / 4 * 3) → Duration::from_millis(slot_duration_ms.saturating_mul(3) / 4) with explicit comment.

Actually, re-reading: slot_duration_ms / 4 * 3 is evaluated left-to-right as (slot_duration_ms / 4) * 3. For typical values (12_000 ms) this is fine. But for defense in depth:

Duration::from_millis(slot_duration_ms.saturating_mul(3) / 4)

Or use checked_mul and panic/return default on overflow since this indicates a config error.


2. Race Condition: epoch_precompute_in_flight Not Cleared on Actor Restart (Lines 534-540, lib.rs)

epoch_precompute_in_flight: None,

This is set in multiple places (BlockChain::new, from_store, test constructors). However, if the BlockChainServer is reconstructed from store (restart), any previously in-flight precompute is lost. More critically, if a precompute worker completes after the actor has been dropped and recreated, the EpochPrecomputed message will arrive at a fresh actor with epoch_precompute_in_flight: None, which is harmless. But if the actor is restarted while a worker is running, the new actor won't know about it and could start a duplicate.

This is acceptable given the "at most one" is best-effort and decide() also checks already_cached. But document this explicitly.


3. ms_into_slot Can Return Negative, Cast to u64 Loses Information (Line 160-161, epoch_precompute.rs)

let into_slot = Duration::from_millis(self.ms_into_slot(slot).max(0) as u64);

Problem: ms_into_slot returns some signed type (presumably i64). If it's significantly negative (clock went backwards, or logic error), .max(0) clamps to 0, which is correct behavior. However, the as u64 cast from i64 is safe here due to .max(0).

Minor concern: What if ms_into_slot returns i64::MIN? The .max(0) handles it. This looks okay but verify ms_into_slot's return type is bounded.


Security / Consensus-Critical Issues

4. Precompute State Could Diverge from Canonical Path (Lines 95-101, fork_choice.rs)

pub fn advance_to_epoch_start(
    parent_state: &BeaconState,
    epoch: Epoch,
    config: &Config,
) -> Result<BeaconState> {
    let target_slot = compute_start_slot_at_epoch(epoch);
    let mut state = parent_state.clone();
    if state.slot() < target_slot {
        stf::process_slots(&mut state, target_slot, config)?;
    }
    state.apply_pending_mutations();
    let _ = state.hash_tree_root();
    Ok(state)
}

Critical observation: The comment says "A state already at or past the epoch's first slot comes back unchanged." But the condition is state.slot() < target_slot. If state.slot() > target_slot, it returns unchanged, which violates the invariant that the returned state should be at target_slot.

However, looking at callers: this is only called from the precompute worker with parent_state at a last-slot-of-epoch, so state.slot() should always be < target_slot. The guard is defensive.

More serious: The let _ = state.hash_tree_root(); discards the result. If hashing fails (e.g., due to malformed state after process_slots), this is silently ignored. The state is still cached with uncomputed/memoized hashes, which could cause issues when later cloned.

Fix:

state.hash_tree_root().map_err(|e| Error::HashTreeRoot(e))?;

Or if the hash is purely for memoization side effects and failure is impossible, document why let _ is safe.


5. transition_block Clones Precomputed State Without Verifying Hash Integrity (Lines 137-138, fork_choice.rs)

let mut state = (*precomputed).clone();

The precomputed state was hashed in advance_to_epoch_start, but cloning an Arc<BeaconState> and then (*precomputed).clone() does a deep clone. The memoized hashes in the tree should be preserved, but this depends on BeaconState::clone() implementation. If hash_tree_root() memoization is in an Arc<Mutex<_>> or similar, cloning should preserve it. Verify this assumption holds.


6. Metrics Counter Can Be Incremented for Non-Epoch-Crossing Blocks (Lines 1015-1018, fork_choice.rs)

fn precomputed_epoch_state(...) -> Option<Arc<BeaconState>> {
    // ...
    crate::metrics::inc_epoch_precompute_lookups(if cached.is_some() { "hit" } else { "miss" });
    cached
}

Wait, re-reading: the metric is only incremented when this function is called, which is only from transition_block, which is only called from on_block when... actually transition_block is always called. Let me re-check.

Looking at on_block (line 2544), transition_block is always called. Inside transition_block, precomputed_epoch_state is called, and it increments the metric. But the metric comment says "Counts a hit or a miss for every such import and for no other." The function returns None early for non-epoch-crossing, but the metric is incremented before the early return!

Wait no, re-reading lines 1009-1018:

fn precomputed_epoch_state(
    store: &Store,
    parent_slot: Slot,
    parent_root: Root,
    block_slot: Slot,
) -> Option<Arc<BeaconState>> {
    let epoch = compute_epoch_at_slot(block_slot);
    let epoch_start = compute_start_slot_at_epoch(epoch);
    if !(parent_slot < epoch_start && epoch_start <= block_slot) {
        return None;
    }
    let cached = store.cached_state(CacheKey::CheckpointState {
        epoch,
        root: parent_root,
    });
    crate::metrics::inc_epoch_precompute_lookups(if cached.is_some() { "hit" } else { "miss" });
    cached
}

Bug: The metric is not incremented for non-epoch-crossing blocks because of the early return. Good. But wait—transition_block calls precomputed_epoch_state unconditionally, so for non-epoch-crossing blocks, the function returns None immediately without touching metrics. For epoch-crossing blocks, it always increments hit or miss. This matches the documented behavior. ✓


Correctness Issues

7. decide Function: head_slot >= compute_start_slot_at_epoch(epoch) Check Is Redundant? (Lines 116-118, epoch_precompute.rs)

if inputs.head_slot >= compute_start_slot_at_epoch(epoch) {
    return None;
}

Given epoch = head_slot / SLOTS_PER_EPOCH + 1, compute_start_slot_at_epoch(epoch) is (head_slot / SLOTS_PER_EPOCH + 1) * SLOTS_PER_EPOCH. For head_slot = 2*SPE - 1 (last slot), this is 2*SPE. So head_slot >= 2*SPE is 2*SPE - 1 >= 2*SPE, false.

For head_slot = 2*SPE (first slot of epoch 2), epoch = 2 + 1 = 3, compute_start_slot_at_epoch(3) = 3*SPE. Check: 2*SPE >= 3*SPE, false.

Actually this seems like a sanity check that head_slot hasn't already reached or passed the target. But given how epoch is computed, head_slot is always in the previous epoch, so head_slot < compute_start_slot_at_epoch(epoch) always holds. This check is defensive but could indicate a logic error elsewhere if triggered.

Suggestion: Add a debug_assert! or comment explaining this is a defensive invariant.


8. Timer Trigger Uses slot Not head_slot for Epoch Calculation (Lines 108-113, epoch_precompute.rs)

Trigger::Timer { slot } => {
    // ...
    slot / preset::SLOTS_PER_EPOCH + 1
}

But in maybe_start_epoch_precompute (line 178-181):

let candidate_epoch = match trigger {
    Trigger::Head => head_slot / preset::SLOTS_PER_EPOCH + 1,
    Trigger::Timer { slot } => slot / preset::SLOTS_PER_EPOCH + 1,
};

The decide function recomputes epoch internally. The candidate_epoch in maybe_start_epoch_precompute is only used for already_cached check. This is correct but slightly redundant. Not a bug.


9. apply_block is Public But Documents Internal Use (Lines 147-163, stf/mod.rs)

/// [`state_transition`] from the point where `state` is already at the block's
/// own slot...
pub fn apply_block(...)

Making this pub exposes an internal API. The documentation says "For a caller that advanced the state itself" which is exactly the precompute use case. This is acceptable but consider pub(crate) unless external crates need it.


Performance Issues

10. tokio::task::spawn_blocking Without Handle Tracking (Line 228, epoch_precompute.rs)

tokio::task::spawn_blocking(move || {
    let result = {
        let _timing = stf_metrics::time_epoch_precompute();
        fork_choice::advance_to_epoch_start(&head_state, key.epoch, &config)
    };
    let result = result.map(Arc::new).map_err(|err| err.to_string());
    let _ = actor.send(EpochPrecomputed { key, result });
});

Problem: The spawned task is not tracked. If the actor is shut down, the task continues running (wasted work). More importantly, under heavy load, multiple spawn_blocking calls could exhaust the blocking thread pool.

Mitigation: The epoch_precompute_in_flight flag prevents multiple precomputes for the same key, but doesn't prevent queueing if the blocking pool is saturated. Given the "at most one" design, this is acceptable. Consider using spawn_blocking with a named task or tracking the JoinHandle for shutdown.

Also: let _ = actor.send(...) silently drops send failures. If the actor is shut down, the result is lost and epoch_precompute_in_flight is never cleared. However, the actor's Drop or restart would reset this. In practice, this is acceptable.


11. head_state is Cloned Twice (Lines 220-228, epoch_precompute.rs)

let Ok(Some(head_state)) = self.store.get_state(&head_root) else {
    return;
};
// ...
tokio::task::spawn_blocking(move || {
    let result = {
        // ...
        fork_choice::advance_to_epoch_start(&head_state, key.epoch, &config)
    };

head_state is cloned from the store (first clone), then advance_to_epoch_start does parent_state.clone() (second clone). The first clone happens on the actor thread; the second on the blocking thread. This is unavoidable given the API, but consider if get_state can return an Arc<BeaconState> to avoid the first clone.

Actually, looking at the type: get_state returns Result<Option<BeaconState>>, not Arc. So the first clone is required to move into the blocking task. The second clone in advance_to_epoch_start is also required since process_slots mutates. This is correct but expensive for large states.


Error Handling Issues

12. get_state Error Silently Ignored (Line 220, epoch_precompute.rs)

let Ok(Some(head_state)) = self.store.get_state(&head_root) else {
    return;
};

If get_state returns Err(_), the precompute is silently skipped. This is correct behavior (retry later), but consider logging at debug level to help diagnose store issues.


13. advance_to_epoch_start Error Converted to String (Line 229, epoch_precompute.rs)

let result = result.map(Arc::new).map_err(|err| err.to_string());

This loses error context and type information. The EpochPrecomputed result uses String for errors, which is fine for logging but prevents programmatic handling. Given the handler just logs, this is acceptable.


Code Quality / Maintainability

14. Inconsistent Naming: epoch_precompute_in_flight vs PrecomputeKey (Lines 543-545, lib.rs)

epoch_precompute_in_flight: Option<epoch_precompute::PrecomputeKey>,

The field name suggests a boolean or worker handle, but it stores the key. This is correct (stores what is in flight, not whether something is in flight), but slightly confusing. Consider epoch_precompute_key_in_flight or document.


15. Test last_slot_parent Uses expect Heavily (Lines 3940-3955, fork_choice.rs)

fn last_slot_parent() -> BeaconState {
    let mut state = with_signing_validators_at(crate::beacon::ForkName::Fulu, 64);
    let lookahead =
        crate::beacon::helpers::fulu::initialize_proposer_lookahead(&state).expect("lookahead");
    // ...
}

Multiple expect calls in test setup. Acceptable for tests, but if any fails, the panic message is just the string. Consider unwrap_or_else(|e| panic!("...: {e:?}")) for better diagnostics.


16. block_on Function Name Conflicts with tokio::runtime::Runtime::block_on (Line 3959, fork_choice.rs)

fn block_on(parent: &BeaconState, slot: Slot) -> (SignedBeaconBlock, Root) {

This shadows the well-known block_on from async runtimes. In a test module it's acceptable, but consider build_block_at or propose_block_at to avoid confusion.


Documentation Issues

17. Doc Comment on advance_to_epoch_start Claims "Already At or Past" (Lines 888-895, fork_choice.rs)

/// A state already at or past the epoch's first slot comes back unchanged, as
/// `checkpoint_state` returns it.

But the code only checks state.slot() < target_slot. If state.slot() > target_slot, it returns unchanged (not at target). The comment should say "already at the epoch's first slot" or the code should handle > target_slot by erroring or clamping.

Fix: Change comment to "A state already at the epoch's first slot comes back unchanged" since > target_slot shouldn't happen in correct usage.


Positive Acknowledgments

  1. Excellent test coverage for decide logic with clear, isolated test cases covering all branches.
  2. Metrics integration is thorough with appropriate labels and buckets.
  3. The transition_block abstraction cleanly separates precomputed vs inline paths without duplicating apply_block logic.
  4. Cache key reuse with CheckpointState is elegant and avoids cache fragmentation.
  5. Defensive programming with multiple guard conditions (syncing, stale head, in-flight, already cached).
  6. Documentation in beacon_stf.md and metrics.md is comprehensive.

Summary Table

Priority Item File Line Issue
High 4 fork_choice.rs 888-901 hash_tree_root() error silently ignored
Medium 1 epoch_precompute.rs 147 Potential overflow in three_quarter_slot
Medium 10 epoch_precompute.rs 228 spawn_blocking task not tracked
Medium 5 fork_choice.rs 137-138 Clone of precomputed state assumes hash memoization preserved
Low 17 fork_choice.rs 888-895 Doc comment overstates "at or past" behavior
Low 16 fork_choice.rs 3959 block_on name shadows tokio function
Low 12 epoch_precompute.rs 220 Store error silently ignored, consider debug log
Low 9 stf/mod.rs 147 apply_block is pub, consider pub(crate)

The PR is well-designed and the core logic is correct. The most important fix is Item 4 (handling hash_tree_root failure), followed by the overflow check in Item 1.


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

@ilitteri ilitteri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Timer-triggered precompute can replay an unbounded number of historical epochs.

The Timer arm at crates/blockchain/src/epoch_precompute.rs:105-112 checks only that the timer names an epoch's last slot and that head_slot <= slot. Unlike the Head arm, it never uses current_slot or otherwise bounds the head's lag.

During a network-wide stall, the sync tracker deliberately reports syncing = false once the freshest-known block is over eight slots old (crates/blockchain/src/sync_status.rs:98-100). A timer several epochs ahead of the head can therefore start advance_to_epoch_start, replaying every intervening slot and epoch on a blocking worker. If the stall continues, later epochs use new cache keys and repeat increasingly expensive work from the same old head. This also contradicts the general freshness gate documented in docs/beacon_stf.md:578.

Please bound Timer precompute to a head within the trigger's epoch, or another explicitly bounded lag that still supports skipped slots. Please also add a stale-Timer test; the existing stale-head test covers only Trigger::Head.

@pablodeymo
pablodeymo force-pushed the perf/beacon-epoch-precompute branch from 575d88b to b79beac Compare October 27, 2026 19:38
@pablodeymo
pablodeymo changed the base branch from main to perf/beacon-justified-balances October 27, 2026 19:38
@MegaRedHand
MegaRedHand added this pull request to stack #711 October 27, 2026 19:59
@MegaRedHand
MegaRedHand merged commit 59f7873 into feat/prune-finalized-states Oct 27, 2026
12 checks passed
@MegaRedHand
MegaRedHand deleted the perf/beacon-epoch-precompute branch October 27, 2026 20:23
MegaRedHand added a commit that referenced this pull request Oct 27, 2026
…st from the snapshot (#665)

## Motivation

On the eth-3 followers (48h of import timings, 10-03 to 10-05), the head
recompute at an epoch-start tick sometimes took 0.5-4 s, and the epoch's
first block queued behind it:

| network | head computes > 0.5 s | slot-0 block queue wait |
|---|---|---|
| Hoodi | 8 (7 in 2-4 s, 1 in 4-12 s) | up to 2.3 s |
| Plataberget | 22 | up to 1.1 s |

At that tick the justified checkpoint moves to (E-1, R), and the first
head walk builds the justified-balances snapshot from
`checkpoint_state((E-1, R))`. When that state has left the 32-entry
state cache, the chain actor rebuilds it inline: a full snapshot decode
(~0.3 s on Hoodi), plus a whole epoch transition when R's block is
before the epoch's first slot (an empty first slot, or an epoch-start
block orphaned by a late block). A replay of the logs through a model of
the state cache puts the rebuilds at ~100 of ~410 Hoodi epoch ticks.

Lighthouse never reads a state at that tick:
`BeaconForkChoiceStore::on_verified_block` builds the justified balances
at block import, for the block's epoch-boundary checkpoint, into a small
cache keyed by (boundary root, epoch), from a state import already has.
The tick only looks them up. Prysm keeps a per-checkpoint balances
vector too, but builds it from the checkpoint block's un-advanced
post-state, which is not what the specification's `checkpoint_states`
holds; this PR follows Lighthouse.

## Changes

This branch carries two open PRs it builds on, merged in with the
resolutions `tmp/bci-...-gloas-live` already uses for gloas (minus
#648's active-balance cache and #646's block-production operations,
which are not on this branch):

- #649, the justified-balances snapshot (`86b07388`)
- #652, the epoch precompute and its `transition_block` (`b3017e4b`)

The change itself is `6badb4f3`:

1. **A balances cache instead of one snapshot.** The store keeps up to 8
`JustifiedBalances` keyed by checkpoint. When full it evicts the lowest
epoch first (ties: oldest insert), and never the current justified
checkpoint. Not Lighthouse's plain 4-entry FIFO: on a network with many
forks (Plataberget), several blocks crossing one boundary from different
parents could flush the canonical entry within an epoch.
2. **Built at import.**
- A block at its epoch's first slot caches the balances from its own
post-state, under (epoch, block_root): that post-state is what
`checkpoint_state` returns for it.
- A block crossing from an earlier-epoch parent (empty first slot)
caches them from the boundary state, under (epoch, parent_root), inside
`transition_block`: from the precomputed state on a precompute hit, or
by advancing the cloned parent to the epoch start once on a miss (each
slot is still processed once). The boundary state depends only on the
parent, so building it before the block is validated cannot cache a
wrong value.
- `justified_balances()` looks the current checkpoint up and keeps the
lazy `checkpoint_state` path for a miss (a restart, or a boundary this
node never imported).
3. **The gloas boost reads the snapshot.** `compute_node_weights`
fetched the justified checkpoint state on every head walk, only for the
gloas boost gate. `is_head_weak`, `is_parent_strong` and
`should_apply_proposer_boost` now use the snapshot: the total active
balance and vote weights were already there; the equivocator term in
`is_head_weak` needs the raw `effective_balance` at the justified state,
which the vote weights zero for slashed validators, so the snapshot
gains an exact copy as a `u16` count of `EFFECTIVE_BALANCE_INCREMENT`
per validator (asserted at build). Committees still come from the
head/parent block states. The spec-literal `get_weight`,
`gloas_get_weight` and `get_attestation_score` stay state-based as
references.

On a snapshot hit, no head walk (pre-gloas or gloas) reads the justified
state.

From review:

4. **The committee total leaves slashed validators out.** The snapshot's
total, and the spec-literal `calculate_committee_fraction`, now sum
active, unslashed validators only, as consensus-specs #5679 does. Fork
choice weighs no vote from a slashed validator, so a boost or reorg
threshold sized against their stake outweighs a larger share of the
votes that count. #5679 first shipped in v1.7.0-beta.4 and the fixtures
are pinned to beta.2, so `docs/spec_deviations.md` records it until the
pin catches up; no beta.2 fork-choice fixture tells the two apart.
Lighthouse already sums the same way. The raw `effective_balance` copy
is unchanged, since `is_head_weak` still needs it for slashed
equivocators.
5. **The timer precompute is bounded to a head in its own epoch.** The
timer trigger checked only that the head was not past its slot. In a
network-wide stall the sync tracker reports synced, so a timer epochs
past a stalled head replayed every epoch in between, and again under a
new key at each later epoch. A worker now crosses one epoch boundary at
most, on either trigger.

## Cost

- One in-order registry pass (~25 ms on Hoodi,
`lean_beacon_justified_balances_build_seconds`) per epoch-boundary
checkpoint, at import, on the importer, as in Lighthouse.
- 10 bytes per validator per cached snapshot (8 for the vote weight, 2
for the new raw effective balance): ~12 MB per entry on Hoodi, so ~96 MB
with all 8 entries filled. Worth reviewing whether 8 entries (vs 4) earn
their memory.

## Testing

- New: the cache's eviction policy (bound, lowest epoch, ties, protected
justified, replace); a crossing import caches the boundary balances on
both precompute paths with the block's state root still verifying; a
first-slot import caches from its post-state and other slots cache
nothing; a snapshot hit reads no justified state on every head path
under both fork rules; a slashed equivocator still counts toward head
weakness, matching the state-based reference; a slashed-but-active
validator is left out of the proposer score and the snapshot total but
keeps its raw balance; a timer whose epoch holds no block, or a stall
several epochs long, starts no precompute.
- `cargo test --profile release-fast --lib --bins` for storage,
state-transition, blockchain and types: 1083 passed, 0 failed.
- Beacon `fork_choice` fixtures, consensus-specs v1.7.0-beta.2, mainnet:
206 passed, 0 failed (before items 4 and 5; not re-run locally since, so
CI's `test-beacon` jobs are the check).
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.

## Not covered

- Live effect on the epoch-tick head recompute: worth re-measuring on a
follower after deploy.
- An `on_block` unit test of the first-slot hook needs a BLS-signed
block; it is tested through `transition_block` and the cache helper
instead.

---------

Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
tcoratger pushed a commit to tcoratger/ethlambda that referenced this pull request Oct 30, 2026
…-gloas-live

The import-time justified balances hook into the precompute's
`transition_block`. Taken from tmp/bci-...-gloas-live's resolution of the
same merge (23986cf), which also enables the precompute for gloas
blocks, minus the active-balance cache (lambdaclass#648) and the block-production
operations (lambdaclass#646) that branch threads through `apply_block` and the
precompute tests: neither is on this branch.
@MegaRedHand MegaRedHand added the performance Performance improvements or possible performance improvements label Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beacon Ethereum Beacon Chain client performance Performance improvements or possible performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants