Repository navigation
perf(blockchain): run the beacon state transition while the engine verdict is in flight - #755
MegaRedHand wants to merge 3 commits into
Conversation
…rdict is in flight
With an execution client, an import waited for engine_newPayload and then
ran the state transition, so the two summed. The request is now built on the
actor and sent from a spawned task, and the actor computes the transition
meanwhile, so the import costs the larger of the two.
The speculative post-state is a local value (SpeculativeTransition) until
on_block_speculative accepts it. on_block's checks run unchanged and in the
same order; only the transition_block call is replaced, and only when the
speculation was computed for this block root and parent root and the verdict
is not Invalidated. on_block derives a valid() engine for every other verdict,
so the speculation is the transition it would have run. Invalidated, held
blocks (no verdict, non-candidate optimistic) and a failed or panicked request
task behave as before. on_block keeps its signature as a wrapper, so spec-test
runners and the replay harness (no engine) are untouched.
Timings: the transition that overlapped the round trip is reported under stf,
and engine reports only the wait left after it, so the rows still tile the
end-to-end time. A discarded speculation leaves engine covering the overlap.
lean_engine_speculation_total{outcome} counts used and discarded attempts.
🤖 Claude Code ReviewReview of PR 755: run the beacon state transition while the engine verdict is in flightThe change is sound overall. The approach is conservative and the correctness argument is explicit. I found no blocking bugs. What checks out
Points to consider
Verdict: correct and well documented. I'd confirm Point 1 and decide on Point 2 before merging. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewLooks good overall. I did not find a consensus-safety or memory-safety bug in the speculative
One minor point to consider:
If you want, I can turn this into ready-to-post GitHub review comments. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Kimi Code ReviewI'll review this PR which introduces speculative state transitions that run concurrently with Overall AssessmentThe design is sound: compute the state transition with Critical Issues1. Race condition in
|
| Severity | Count | Categories |
|---|---|---|
| Critical | 2 | Race condition (architectural), panic on DB error |
| Security/Correctness | 1 | Task leak potential |
| Performance | 2 | Payload clone, cache usage in tests |
| Rust/Idiomatic | 4 | Error types, struct vs tuple, visibility, match readability |
| Minor | 4 | Typos, naming, test fragility, documentation |
Must fix before merge:
expect("get")panic (Item 2)- Verify task cleanup on actor drop (Item 3)
- Consider adding defensive check for store mutation (Item 1)
Should fix:
4. String error type in verdict (Item 10)
5. SpeculationTiming struct (Item 9)
6. Test gap for non-candidate optimistic discard
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
…rdict is in flight (#755) With an execution client, an import waited for engine_newPayload and then ran the state transition, so the two summed. The request is now built on the actor and sent from a spawned task, and the actor computes the transition meanwhile, so the import costs the larger of the two. The speculative post-state is a local value (SpeculativeTransition) until on_block_speculative accepts it. on_block's checks run unchanged and in the same order; only the transition_block call is replaced, and only when the speculation was computed for this block root and parent root and the verdict is not Invalidated. on_block derives a valid() engine for every other verdict, so the speculation is the transition it would have run. Invalidated, held blocks (no verdict, non-candidate optimistic) and a failed or panicked request task behave as before. on_block keeps its signature as a wrapper, so spec-test runners and the replay harness (no engine) are untouched. Timings: the transition that overlapped the round trip is reported under stf, and engine reports only the wait left after it, so the rows still tile the end-to-end time. A discarded speculation leaves engine covering the overlap. lean_engine_speculation_total{outcome} counts used and discarded attempts. Cherry-picked from perf/engine-stf-overlap at 06e3306.
…and resources (#756) The import replay reported `stf` as one number, which is most of a mainnet block's import but does not say which step of the transition to attack, nor whether an epoch-crossing block, a block after skipped slots and an ordinary block differ. The beacon STF had no internal timing at all. lean_beacon_stf_phase_seconds{context,phase} is observed around the steps of the transition: process_slots (excluding epoch work), process_epoch, the block signature, header (including the body hash it commits to), randao, eth1 data, execution payload (gloas: parent payload, withdrawals, bid), each operation list, the sync aggregate and the post-state root check. One Instant pair per step, none per attestation; an empty list observes nothing. The phase list is generated with the enum that the code observes through, so the two cannot disagree. The epoch precompute worker runs process_epoch on another thread while an import runs, and the replay reads the histogram before and after each block, so one series would charge the worker to whichever block was importing. The series is split by a context label instead: only fork_choice's transition_block sets `import` (a thread-local guard, since the steps are observed deep inside the per-fork block processing), and every other caller writes `other`. The replay reads only `import`. The replay report (schema_version 2) gains per block: the STF steps, the stf time outside any step, a class (epoch_boundary, after_skip, ordinary), process CPU seconds and RSS; and per run: p95 and p99, a per-class summary and the deltas of the cache lookup counters, so precompute hit rates appear in it. Consensus behavior is unchanged. Adapted onto nightly: block processing now defers every signature but the proposer's into one `SignatureBatch` (#738), so the old inline checks the timers wrapped no longer exist. Every call site keeps nightly's `signatures` argument and order, with the timers wrapped around the same steps as before; the proposer signature stays immediate and is still timed as `block_signature`. A new phase `batch_signatures` times `SignatureBatch::verify`, the one random-weight check of everything the steps deferred, so the verify cost that used to sit inside `randao`, the operation lists and `sync_aggregate` is now its own series. Those phases now cover building the signature sets and the state changes, not verification, and docs/metrics.md and docs/benchmarking.md say so. The phase is declared in the `StfPhase` enum, so the label list stays generated from it. replay.rs keeps both `--warm-committee-sums` (warm-up before the timers start) and the report changes; the report test for warm_committee_sums gets the new `Caches` argument. Verdicts, order and errors are nightly's. docs/benchmarking.md keeps #755's paragraph on the overlapped transition ahead of this section. Cherry-picked from perf/beacon-stf-phase-timing at c891134, adapted in 5c385e084.
…s engine wait
The import's engine section is meant to say how long the consensus block's
import waited on the execution client, but it also carried the execution
client's own time whenever nothing hid it: the whole round trip for a
discarded speculation, and a few microseconds of scheduling noise for every
block that had nothing to ask. Its meaning is now only the wait left once the
import's own state transition was done. It is a recorded zero when there was
no request to send (no execution client, or a block with no payload: pre-merge
or gloas) and when the answer was already in once the transition finished, so
it is non-zero only when the execution client was slower than the transition,
or never answered.
The speculative transition is charged to stf whether the import used it or
not, so the rows still sum to the end-to-end time: a held block keeps the
transition it paid for, and an INVALIDATED verdict shows its transition twice.
How long the execution client took is now its own histogram,
lean_engine_new_payload_seconds{outcome}, observed in ask_request from the
request to the answer, retry ladder included, with outcome valid, optimistic,
invalidated or no_verdict.
|
Pushed b95be16: the The execution client's own time is a new histogram, Checks: |
…rdict is in flight (#755): payload metric The import's engine section is meant to say how long the consensus block's import waited on the execution client, but it also carried the execution client's own time whenever nothing hid it: the whole round trip for a discarded speculation, and a few microseconds of scheduling noise for every block that had nothing to ask. Its meaning is now only the wait left once the import's own state transition was done. It is a recorded zero when there was no request to send (no execution client, or a block with no payload: pre-merge or gloas) and when the answer was already in once the transition finished, so it is non-zero only when the execution client was slower than the transition, or never answered. The speculative transition is charged to stf whether the import used it or not, so the rows still sum to the end-to-end time: a held block keeps the transition it paid for, and an INVALIDATED verdict shows its transition twice. How long the execution client took is now its own histogram, lean_engine_new_payload_seconds{outcome}, observed in ask_request from the request to the answer, retry ladder included, with outcome valid, optimistic, invalidated or no_verdict. Updates nightly's #755 from perf/engine-stf-overlap at 06e3306 to b95be16.
…tric lean_engine_new_payload_seconds covered only a pre-gloas block's payload, so from gloas on, when the payload is revealed in an envelope and asked about with engine_newPayloadV5, the execution client's time went unmeasured. Every newPayload request now goes through one helper, timed_new_payload, which observes the time from sending it to its answer under the verdict it reads as: the block path (ask_request, and ask) and the envelope path (ask_envelope_status, which ask_envelope also goes through) alike. An envelope refused before a request is sent, because it does not match its block, is not observed.
|
Pushed 088dfc2: |
…rdict is in flight (#755): envelope newPayload metric lean_engine_new_payload_seconds covered only a pre-gloas block's payload, so from gloas on, when the payload is revealed in an envelope and asked about with engine_newPayloadV5, the execution client's time went unmeasured. Every newPayload request now goes through one helper, timed_new_payload, which observes the time from sending it to its answer under the verdict it reads as: the block path (ask_request, and ask) and the envelope path (ask_envelope_status, which ask_envelope also goes through) alike. An envelope refused before a request is sent, because it does not match its block, is not observed. Updates nightly's #755 from perf/engine-stf-overlap at b95be16 to 088dfc2.
Block import benchmark (offline replay of mainnet blocks, steady state)The replay runs without an execution client, so there is no engine verdict for the STF to overlap with, and this PR's code path does not run there. The leg served as a noise control.
Measuring this PR needs a live node with an execution client. Method
|
…lled-up tip compute_pulled_up_tip ran on every imported block and, at ~2.4M validators, cloned the whole post-state (~2.5 ms) and scanned the registry for the participation totals (~6 ms), plus an uncached total active balance. Lighthouse avoids both with its ProgressiveBalancesCache. The per-fork states derive SSZ, so the cache lives in the Store, keyed by block root (TargetBalanceCache: raw previous/current unslashed TIMELY_TARGET sums, bounded by capacity and pruned below the finalized epoch). process_attestation in every fork from altair on records into a TargetBalanceDelta, carried in BlockProcessingOptions, when a validator newly earns the target flag. A block in the same epoch as its parent has the parent's totals plus that delta; an epoch boundary, a cache miss or a block carrying slashings takes one registry scan, cached for the children. The justification weighing now runs on a small JustificationState extracted from the state, so the pulled-up tip clones nothing and reads the total active balance from the store's ActiveBalanceCache. weigh_justification_and_finalization delegates to the same logic, so epoch processing is unchanged. Debug builds also run the old full path and assert the checkpoints and totals agree, which the fork_choice fixtures exercise on every block. On nightly the transition runs speculatively before `on_block` (#755), so the delta also travels in `SpeculativeTransition` and `on_block_speculative` uses the speculation's delta exactly when it uses the speculation's state. Pairing a speculative state with the empty delta of a transition that never ran would extend the parent's totals by nothing and report wrong totals, so two tests import through a used and a dropped speculation and compare against a full scan. Cherry-picked from perf/progressive-target-balances at 1490e27.
…events layout (#765) Opt-in per-block telemetry: with --telemetry.clickhouse-url (or ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL), a node writes one ClickHouse row per point in each block's life, and per data column arrival, in Silver's block_events table layout, so both clients' timings can be compared block by block from one table. Off by default: without the flag, emit builds nothing. See docs/telemetry.md. Cherry-picked from feat/clickhouse-block-events at 119e70e. Adapted to three nightly-only changes the PR's base (main 4992d8d) lacks: - #755 sends engine_newPayload from a task while the state transition runs speculatively, so the `engine` pair is only the wait left afterwards. The el_sent and el_verdict rows now come from new engine_sent/engine_answered instants that bracket the request itself, and the speculative transition writes stf_spec_start/stf_spec_end. docs/telemetry.md describes the overlap. - #760 answers a block whose stored columns are complete with DataAvailability::ColumnsVerified, which also counts as columns required, so such a block writes da_available rather than da_check_end. - #754 checks a block's sidecars as one group with a batched KZG, so the column rows are written from GroupVerdicts::record, once each sidecar's verdict is known.
…ility exhaustively Two rows depended on facts the compiler could not keep true: - el_sent and el_verdict read the `engine` section pair, whose meaning is the time the import waited for the answer. That equals the request's own round trip only while nothing runs during it; an import that overlaps the state transition with the request (as nightly's #755 does) would turn both rows into the wait without any type changing. The answer now carries its own sent/answered instants and verdict (`EngineAnswer`). - da_available vs da_check_end came from `matches!(..., Columns(_))`, so a new kind of availability evidence silently counts as nothing required. Merging into nightly hit exactly that with #760's `ColumnsVerified`. An exhaustive match makes a new variant a compile error here. Also warn at startup when the ClickHouse URL sends credentials over plain http to a host that is not loopback (suggested in review).
…events layout (#765): review fixes fix(telemetry): give the el rows their own instants and match availability exhaustively - el_sent and el_verdict come from an `EngineAnswer` (sent, answered, verdict) instead of the `engine` section pair, which is the time the import waited and, with #755's overlap, not the request's round trip. - da_available vs da_check_end comes from an exhaustive match on the availability evidence, so a new variant is a compile error rather than a silent "nothing required". - A startup warning when the ClickHouse URL sends credentials over plain http to a host that is not loopback. Updates nightly's #765 from feat/clickhouse-block-events at 119e70e to f2eb961. On nightly the answer's instants are the speculative path's request (spawned) and answer (received) instants, which replaces the nightly-only engine_sent/engine_answered fields, and the exhaustive match also counts #760's ColumnsVerified as columns required.
🗒️ Description / Motivation
With an execution client, a pre-gloas beacon import waited for
engine_newPayloadand then ran the state transition, so the two summed. The request is now built on the actor and sent from a spawned task, and the actor computes the transition meanwhile, so the import costs the larger of the two.This is possible because the transition reads the engine's verdict in one place (
process_execution_payload), andon_blockmaps every verdict exceptINVALIDATEDtoExecutionEngine::valid(). A transition computed assuming a valid payload is therefore the oneon_blockwould run for every verdict it imports.What Changed
fork_choice.rs:speculate_transitioncomputes the block's transition against its parent's post-state with avalid()engine and returns it as a local value (SpeculativeTransition).on_block_speculativeison_blockwith an optional speculation: every check runs unchanged and in the same order; only thetransition_blockcall is replaced, and only when the speculation was computed for this block root and parent root and the verdict is notINVALIDATED.on_blockkeeps its signature as a wrapper, so spec-test runners and the replay harness (no engine) are untouched.beacon_engine.rs:OwnedNewPayloadRequest(copies only the payload, never the block, so the request can move into a task) andask_request.lib.rs(process_block): spawns the request, runsspeculate_transition, awaits the task, then hands the speculation toon_block_speculative.import_timing.rs: the transition that overlapped the round trip is reported understf, andenginereports only the wait left after it, so the rows still tile the end-to-end time. A discarded speculation leavesenginecovering the overlap.metrics.rs:lean_engine_speculation_total{outcome}counts used and discarded attempts.docs/beacon_engine.md,docs/benchmarking.md,docs/metrics.md.Correctness / Behavior Guarantees
VALID, orSYNCING/ACCEPTEDfor an optimistic candidateINVALIDATEDon_blockrecomputes withinvalid(), the invalidation path is unchangedSYNCING/ACCEPTEDfor a non-candidateon_block_speculativeaccepts the speculation.beacon_envelope.rs) is unchanged: it still runs the consensus checks before asking the execution client.Backend::Thread, whose thread blocks on a multi-thread tokioRuntime, so the spawned request progresses on that runtime's workers while the actor computes.Tests Added / Run
fork_choicetests: a matching speculation leaves the store exactly as importing without one; it is the transitionon_blockuses; a speculation for another root or parent is ignored; anINVALIDATEDverdict ignores it and matches today's result; no speculation without a parent state.cargo fmt --all -- --check: cleanmake lint: cleancargo test --profile release-fast -p ethlambda-state-transition -p ethlambda-blockchain --lib --bins: state-transition 635 passed (15 ignored), blockchain 293 passed (43 ignored)make test(spec suites); CI covers them.lean_engine_speculation_total{outcome}shows how often the speculation is used.Related Issues / PRs
Part of a beacon import perf batch, each PR independent and based on
main: #752, #753, #754. Overlaps: the STF phase timing PR also editsdocs/benchmarking.md; #754 and the STF phase timing PR editfork_choice.rsin different regions; most batch PRs editdocs/metrics.md.✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — not run locally (spec suites); unit tests of the touched crates pass