Skip to content

perf(blockchain): run the beacon state transition while the engine verdict is in flight - #755

Open
MegaRedHand wants to merge 3 commits into
mainfrom
perf/engine-stf-overlap
Open

MegaRedHand wants to merge 3 commits into
mainfrom
perf/engine-stf-overlap

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

With an execution client, a pre-gloas beacon 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.

This is possible because the transition reads the engine's verdict in one place (process_execution_payload), and on_block maps every verdict except INVALIDATED to ExecutionEngine::valid(). A transition computed assuming a valid payload is therefore the one on_block would run for every verdict it imports.

What Changed

  • fork_choice.rs: speculate_transition computes the block's transition against its parent's post-state with a valid() engine and returns it as a local value (SpeculativeTransition). on_block_speculative is on_block with an optional speculation: every check runs 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 keeps 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) and ask_request.
  • lib.rs (process_block): spawns the request, runs speculate_transition, awaits the task, then hands the speculation to on_block_speculative.
  • import_timing.rs: 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.
  • 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

Verdict Speculative post-state
VALID, or SYNCING/ACCEPTED for an optimistic candidate used
INVALIDATED dropped; on_block recomputes with invalid(), the invalidation path is unchanged
no answer, or SYNCING/ACCEPTED for a non-candidate dropped; the block is held as before
computed for another root or parent dropped
  • Nothing reaches the store, a non-content-addressed cache or the network until on_block_speculative accepts the speculation.
  • A panicked or cancelled request task is treated as no verdict, like an exhausted retry ladder.
  • Without an execution client, and for forks with no request to send (pre-merge, gloas), nothing overlaps and the path is the old one. The gloas envelope path (beacon_envelope.rs) is unchanged: it still runs the consensus checks before asking the execution client.
  • The overlap is real on the production runtime: the chain actor runs on Backend::Thread, whose thread blocks on a multi-thread tokio Runtime, so the spawned request progresses on that runtime's workers while the actor computes.

Tests Added / Run

  • New fork_choice tests: a matching speculation leaves the store exactly as importing without one; it is the transition on_block uses; a speculation for another root or parent is ignored; an INVALIDATED verdict ignores it and matches today's result; no speculation without a parent state.
  • cargo fmt --all -- --check: clean
  • make lint: clean
  • cargo test --profile release-fast -p ethlambda-state-transition -p ethlambda-blockchain --lib --bins: state-transition 635 passed (15 ignored), blockchain 293 passed (43 ignored)
  • Not run locally: make test (spec suites); CI covers them.
  • Not yet run against a live execution client in isolation. It runs on ethlambda-5's mainnet node (nightly with this batch, paired with ethrex v30.0.1), where 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 edits docs/benchmarking.md; #754 and the STF phase timing PR edit fork_choice.rs in different regions; most batch PRs edit docs/metrics.md.

✅ 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) — not run locally (spec suites); unit tests of the touched crates pass

…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.
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 755: run the beacon state transition while the engine verdict is in flight

The change is sound overall. The approach is conservative and the correctness argument is explicit. I found no blocking bugs.

What checks out

  • on_block_speculative keeps every on_block check in its original order. The speculation only replaces the single transition_block call, so rejection behaviour is unchanged.
  • The speculation is dropped for Invalidated, the one verdict whose engine (invalid()) differs from the valid() engine it assumed. It is also re-checked against (block_root, parent_root) inside on_block_speculative, so lib.rs cannot pass one that doesn't fit.
  • SpeculativeTransition is opaque and nothing reaches the store before the checks, so dropping one costs only the work.
  • The Held paths (no verdict, non-candidate optimistic) return before on_block. Nothing leaks and engine_start is left untouched, so the timing rows stay honest.
  • A panicked or cancelled task is treated as "no verdict" and the block is held, which matches the optimistic-sync rule for an exhausted retry ladder.
  • The new timing test shows the rows still tile the span. The test refactor into store_with_valid_child is a clean extraction.

Points to consider

  1. Overlap depends on the runtime. lib.rs:1820-1843 calls tokio::spawn and then runs the STF synchronously on the actor's thread. If the actor runs on a current-thread runtime, or all workers are busy, the spawned task can't make progress during speculate_transition. The result is correct but gains nothing. Please confirm the runtime is multi-threaded. The lean_engine_speculation_total{outcome="used"} metric will not reveal a missing overlap. The engine timing row would, because it should shrink toward zero.
  2. STF now runs before on_block's cheap rejections. Before this PR, a block failing an early on_block check (slot or finalization bounds, future block, and so on) never reached the STF. Now an execution-client deployment pays for the full STF on such blocks. Gossip validation mostly filters them first. Blocks arriving through BlockSource::Sync or by-root fetches are less filtered, so a peer feeding junk descendants costs one STF each. The doc comment already says "a block that then fails one of on_block's earlier checks still counts". A cheap pre-filter (slot and finalized-descendant) before the spawn would remove the exposure. It is acceptable as is if you judge the exposure low.
  3. Payload clone. into_owned (beacon_engine.rs) clones the whole ExecutionPayload on every block, including all transactions, which can be megabytes. This is small next to the STF, but you could avoid it by moving the payload out of the block, or by sharing it through Arc, if the block type allows that.
  4. Dead mismatch branch. The discarded_mismatch metric and the is_for pre-check in lib.rs cover a case the code says is "not expected from the actor". on_block_speculative already repeats the check. That is fine as defence in depth, but the metric label will probably always read zero.
  5. Minor. verdict is a Result<Option<_>, String> whose error is stringified only to be logged. Keeping the error type, or logging at the match site, would be a little more idiomatic. It is not important.

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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Looks good overall. I did not find a consensus-safety or memory-safety bug in the speculative engine_newPayload overlap path, and the added tests cover the key invariants well.

  • The strongest part of the change is the guard that only reuses speculation when both block_root and parent_root match and the verdict is not Invalidated; that preserves the old invalidation path and avoids contaminating fork choice with a transition run under the wrong engine assumption. See crates/blockchain/state_transition/src/beacon/fork_choice.rs:4828 and crates/blockchain/src/lib.rs:1912.
  • The held-block correctness fix remains intact: on_block_speculative still guards on has_state rather than has_block, which is the right distinction for persisted-but-unimported blocks. See crates/blockchain/state_transition/src/beacon/fork_choice.rs:4907.
  • Timing/reporting math also looks internally consistent: when speculation is used, engine_start is shifted to spec_end and the speculative span is added back into stf, so the rows still tile end-to-end time without double counting. The unit test at crates/blockchain/src/import_timing.rs:923 exercises that directly.

One minor point to consider:

  • OwnedNewPayloadRequest always clones a full Deneb execution payload before spawning the task. That is functionally correct, but it can be a noticeable per-block allocation/copy on busy import paths. If profiling ever shows this path hot, it may be worth exploring an Arc-backed request object or moving request construction into the spawned task to reduce copying. Current code is still acceptable and safe. See crates/blockchain/src/beacon_engine.rs:189.

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 which introduces speculative state transitions that run concurrently with engine_newPayload round trips. This is a performance optimization for beacon block import with execution clients.

Overall Assessment

The design is sound: compute the state transition with ExecutionEngine::valid() while waiting for the execution client's verdict, then reuse if the verdict matches. The safety arguments in documentation are well-reasoned. However, I've identified several issues ranging from correctness concerns to code quality.


Critical Issues

1. Race condition in speculate_transition — store mutation between speculation and use

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 4848–4877

pub fn speculate_transition(
    store: &Store,
    // ...
) -> Option<SpeculativeTransition> {
    // ...
    let parent_state = store.get_state(&parent_root).expect("get")?;
    // ...
    let (state, outcome) = transition_block(
        store,
        // ...
        &stf::ExecutionEngine::valid(),
        // ...
    );

The documentation states: "The chain actor handles one message at a time, so nothing mutates the store between this computation and on_block's own." This is an architectural invariant, not an enforced one. If this ever changes (e.g., async message handling, background tasks), the speculation could use stale cache state.

Recommendation: Add a debug-only assertion or store generation check in on_block_speculative to verify the store hasn't changed. At minimum, document this invariant more prominently in the code itself, not just in docs/beacon_engine.md.


2. expect("get") can panic on database errors

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, line 4859

let parent_state = store.get_state(&parent_root).expect("get")?;

This .expect("get") will panic if the store returns a database error, not just None. The ? handles None (parent not found), but expect aborts on Err.

Recommendation: Change to proper error handling:

let parent_state = store.get_state(&parent_root).ok().flatten()?;

Or if get_state returns Result<Option<...>>, propagate the error appropriately rather than panicking.


3. tokio::spawn without abort handle or timeout — potential task leak

File: crates/blockchain/src/lib.rs, lines 1826–1828

let task = tokio::spawn(async move {
    beacon_engine::ask_request(&client, request).await
});

If the chain actor is dropped or the block import is cancelled, this task continues running. The client is cloned (Arc presumably), so the connection pool stays alive. More critically, if ask_request hangs indefinitely due to an execution client bug, the task leaks.

Recommendation: Consider using tokio::time::timeout around the task or the ask_request call, or at minimum store the JoinHandle to abort on actor shutdown. The existing retry ladder in ask_request should handle most cases, but defense in depth matters for consensus code.


Security & Correctness Issues

4. SpeculativeTransition does not validate the block's state root

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 4830–4841

The SpeculativeTransition stores state and outcome (which is Result<()>), but transition_block returns (BeaconState, Result<()>). The outcome here is the entire transition result — if this is Ok(()), the state transition passed all checks including the state root verification.

However, looking at on_block_speculative (line 5062+), the usable check only verifies is_for and !Invalidated. It does not check that speculation.outcome.is_ok() before using it.

Wait — re-reading: speculation.outcome is stored as Result<()>, but in the destructuring:

let (mut state, transition) = match usable {
    Some(speculation) => (speculation.state, speculation.outcome),
    None => transition_block(...),
};

Then transition? is called at line 5085. So a failed speculation would fail here. But this means we did run a failed transition speculatively, wasting work. More importantly, what if transition_block with valid() succeeds but invalid() would have caught something? The docs argue this can't happen, but...

Actually, re-checking: the ExecutionEngine only affects payload validation. The state root check is independent. So a speculation that fails state root would fail here and fall through to the same error path. This seems correct.

However: The poisoned test at line 7435 verifies this works. No issue here after deeper analysis.


5. Missing block_root validation in speculate_transition return value

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 4850–4851

let block_root = signed_block.message_hash_tree_root();

This recomputes the block root. In on_block_speculative, the same computation happens. These should match, but if SignedBeaconBlock::message_hash_tree_root() has any side effects or depends on mutable state (it shouldn't), this could diverge. The is_for check catches this, but only after the fact.

Recommendation: Not a bug, but consider whether block_root should be computed once and passed to both functions, or if SpeculativeTransition should be constructed by on_block_speculative itself to ensure consistency. The current design is probably fine.


Performance Issues

6. Unnecessary clone of execution_payload in into_owned

File: crates/blockchain/src/beacon_engine.rs, lines 195–206

pub fn into_owned(self) -> OwnedNewPayloadRequest {
    OwnedNewPayloadRequest {
        execution_payload: self.execution_payload.clone(),
        versioned_hashes: self.versioned_hashes,
        parent_beacon_block_root: self.parent_beacon_block_root,
        execution_requests: self.execution_requests,
    }
}

The execution_payload is cloned, but versioned_hashes and execution_requests are moved. However, Bytes32 and Vec<Vec<u8>> may also be expensive. More importantly, into_owned takes self by value — why clone execution_payload instead of moving it? Because NewPayloadRequest borrows it.

Wait, re-reading: NewPayloadRequest<'_> has execution_payload: &'a containers::deneb::ExecutionPayload. So it must clone to own it. This is correct but expensive for large payloads (Deneb blobs).

Recommendation: Consider using Arc<containers::deneb::ExecutionPayload> or ensuring the payload can be cheaply shared. For now, document the cost. Not blocking.


7. CommitteeCache::default() in tests may hide real cache requirements

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 7405, 7448, 7480

let committees = CommitteeCache::default();

Multiple test helpers use CommitteeCache::default(). If the real on_block or speculate_transition requires a populated cache for certain operations, these tests may pass incorrectly.

Recommendation: Verify that CommitteeCache::default() is sufficient for all test paths, or construct proper caches from the test state. The existing test on_block_imports_a_block_already_persisted_with_no_post_state was refactored to use store_with_valid_child — ensure the committee cache is still correct.


Rust Best Practices & Idiomatic Patterns

8. match on bool in usable filter — prefer if or matches!

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 5062–5065

let usable = speculation.filter(|speculation| {
    speculation.is_for(block_root, parent_root)
        && !matches!(payload_validity, PayloadValidity::Invalidated { .. })
});

This is fine, but the !matches! with && is slightly hard to read. Consider:

let usable = speculation.filter(|spec| {
    spec.is_for(block_root, parent_root) && !payload_validity.is_invalidated()
});

If PayloadValidity doesn't have is_invalidated(), add it for clarity.


9. Option<(SpeculativeTransition, Instant, Instant)> tuple is unwieldy

File: crates/blockchain/src/lib.rs, lines 1811–1814

let mut speculation: Option<(
    fork_choice::SpeculativeTransition,
    Instant,
    Instant,
)> = None;

This tuple type is used once and immediately destructured. Consider a small struct:

struct SpeculationTiming {
    transition: SpeculativeTransition,
    start: Instant,
    end: Instant,
}

This improves readability and makes the metrics::inc_engine_speculation calls clearer.


10. verdict type annotation is unnecessary and complex

File: crates/blockchain/src/lib.rs, line 1815

let verdict: Result<Option<fork_choice::PayloadValidity>, String> =

This explicit type annotation is required because the match arms return different types that unify to String errors. However, String as an error type is unidiomatic — consider using EngineError or a dedicated enum.

Actually, looking closer: task.await returns Result<Result<PayloadValidity, EngineError>, JoinError>, and both error cases are converted with .to_string(). This loses structured error information.

Recommendation: Define a small enum or use EngineError throughout. Converting to String early prevents downstream matching and logging of specific error kinds.


11. if speculation.is_some() repeated — use is_some_and or map

File: crates/blockchain/src/lib.rs, lines 1869–1871, 1896–1898

if speculation.is_some() {
    metrics::inc_engine_speculation("discarded_held");
}

This appears twice. Consider extracting to a helper or using:

speculation.as_ref().map(|_| metrics::inc_engine_speculation("discarded_held"));

Though the if is clearer. Not a bug.


Memory Safety & Error Handling

12. tokio::spawn move closure captures client by clone, but what about request?

File: crates/blockchain/src/lib.rs, lines 1826–1828

let client = client.clone();
let task = tokio::spawn(async move {
    beacon_engine::ask_request(&client, request).await
});

client is cloned (presumably EngineClient is Arc-based or cheap). request is moved. This is correct.

But what if the actor is dropped while the task runs? The client clone keeps the connection alive. If EngineClient holds a reference to the actor, this could prevent cleanup. Verify EngineClient doesn't hold actor references.


13. task.await after speculate_transition — sequential, not truly parallel

File: crates/blockchain/src/lib.rs, lines 1826–1841

let task = tokio::spawn(async move { ... });  // spawn first
let spec_start = Instant::now();
let computed = fork_choice::speculate_transition(...);  // then compute
// ...
match task.await { ... }  // then await

This is correct: the spawn starts the network I/O, then we do CPU work, then await. The task runs on another thread/core. Good.

However, if speculate_transition is faster than the task setup, we might await immediately. Not a bug, but the overlap is maximized when speculate_transition is slow.


Documentation & Maintainability

14. docs/beacon_engine.md typo: "gloas" should be "gloss" or "gloas" is jargon?

File: docs/beacon_engine.md, line 114

Without an engine, and for forks that have no request to send (pre-merge, gloas)

"gloas" appears in code comments too (crates/blockchain/src/lib.rs:1810). If this is internal jargon, define it. If it's a typo for "gloss" or "gloas" (some fork name?), clarify.


15. Test no_speculation_without_a_parent_state mutates block — side effects?

File: crates/blockchain/state_transition/src/beacon/fork_choice.rs, lines 7512–7523

let SignedBeaconBlock::Phase0(inner) = &mut block else {
    unreachable!("phase0");
};
inner.message.parent_root = Root::repeat_byte(0xcd);

This mutates block after store_with_valid_child() returns it. If store_with_valid_child caches or shares the block, this could affect other tests. Currently safe since it's the last use, but fragile.

Recommendation: Clone before mutate, or have store_with_valid_child return an owned block that can be modified.


Minor Issues

16. OwnedNewPayloadRequest fields are pub but could be crate-private

File: crates/blockchain/src/beacon_engine.rs, lines 188–193

pub struct OwnedNewPayloadRequest {
    execution_payload: containers::deneb::ExecutionPayload,
    // ...
}

Fields have no visibility modifier, so they're pub in a pub struct. They could be pub(crate) since only ask_request uses them.


17. stf_spec_start/stf_spec_end naming is slightly confusing

File: crates/blockchain/src/import_timing.rs, lines 199–211

"spec" could mean "speculative" or "specification". Given the context it's "speculative", but consider stf_overlapped_start or stf_concurrent_start for clarity.


Tests Analysis

The tests are comprehensive and well-designed:

  • a_matching_speculation_leaves_the_store_as_importing_without_one: Verifies equivalence for NotRequired, Validated, Optimistic. Good coverage.
  • a_matching_speculation_is_the_transition_on_block_uses: Uses poisoned outcome to verify the speculation is actually used. Clever.
  • a_speculation_for_another_root_or_parent_is_ignored: Tests both block_root and parent_root mismatch.
  • an_invalidated_verdict_ignores_the_speculation_and_matches_today: Critical path for safety.

One gap: No test for PayloadValidity::Optimistic with is_optimistic_candidate_block returning false — the speculation discard path on non-candidate optimistic blocks. The metrics call exists (discarded_held) but no test verifies the speculation is dropped in this specific case.


Summary

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:

  1. expect("get") panic (Item 2)
  2. Verify task cleanup on actor drop (Item 3)
  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

MegaRedHand added a commit that referenced this pull request Oct 30, 2026
…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.
MegaRedHand added a commit that referenced this pull request Oct 30, 2026
…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.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Pushed b95be16: the engine import phase now means only the wait for the verdict left once the overlapped state transition finished. It is a recorded 0 when there was no request (no execution client, pre-merge, gloas) and when the answer arrived before the transition finished; it is non-zero only when the execution client was slower than the transition, or gave no answer (the rest of the retry ladder, block held). The speculative transition is charged to stf whether it was used or discarded, so the rows still tile the end-to-end time.

The execution client's own time is a new histogram, lean_engine_new_payload_seconds{outcome} (valid, optimistic, invalidated, no_verdict), observed in ask_request from the request to the answer, retry ladder included.

Checks: cargo fmt, make lint clean; cargo test --profile release-fast -p ethlambda-blockchain --lib 295 passed (43 ignored), including two new import_timing tests (a held block keeps its speculation in stf; an engine with nothing to wait for is a recorded zero).

MegaRedHand added a commit that referenced this pull request Nov 2, 2026
…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.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Pushed 088dfc2: lean_engine_new_payload_seconds now also times the gloas envelope's engine_newPayloadV5. Every newPayload request goes through one helper, timed_new_payload (ask_request, ask, and ask_envelope_status, which ask_envelope uses); an envelope refused before a request is sent (block mismatch) is not observed. Same labels, so one series covers both forks. make lint clean; cargo test --profile release-fast -p ethlambda-blockchain --lib 295 passed (43 ignored), the envelope tests going through the timed path.

MegaRedHand added a commit that referenced this pull request Nov 2, 2026
…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.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

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.

leg STF per block difference
main 012dc4f 157.7 ms
main + #755 157.1 ms -0.6 ms (-0.4%), within the ±1.5% round-to-round noise

Measuring this PR needs a live node with an execution client.

Method

MegaRedHand added a commit that referenced this pull request Nov 2, 2026
…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.
@MegaRedHand MegaRedHand added performance Performance improvements or possible performance improvements beacon Ethereum Beacon Chain client labels Nov 3, 2026
MegaRedHand added a commit that referenced this pull request Nov 3, 2026
…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.
MegaRedHand added a commit that referenced this pull request Nov 3, 2026
…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).
MegaRedHand added a commit that referenced this pull request Nov 3, 2026
…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.

This branch has not been deployed

No deployments
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.

1 participant