Skip to content

perf(beacon): stop re-verifying stored custody columns at block import - #760

Draft
MegaRedHand wants to merge 2 commits into
mainfrom
perf/skip-column-kzg-reverify
Draft

MegaRedHand wants to merge 2 commits into
mainfrom
perf/skip-column-kzg-reverify

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

At block import, data_availability_for read every custody column sidecar back from DataColumns (decoding each), and fork choice's is_data_available_columns then re-ran the structural check and the KZG cell batch on each of them. Every column in DataColumns was already fully verified in p2p before the chain actor stored it, so this repeated work on the import thread: ~8 ms of CPU per block in verify_cell_kzg_proof_batch under on_block on the mainnet follower, plus the reads. Lighthouse verifies a column once and carries that in its type (KzgVerifiedCustodyDataColumn -> AvailableBlock).

What Changed

  • fork_choice.rs: new DataAvailability::ColumnsVerified, documented as a promise its constructor makes (every sampled sidecar passed verify_data_column_sidecar, the inclusion proof and the KZG batch, and is stored under the block's root). is_data_available_columns accepts it without running anything; Columns(sidecars) still runs the full spec check, so the fork_choice fixtures keep exercising it.
  • crates/blockchain/src/lib.rs: data_availability_for returns ColumnsVerified once custody_columns_present holds, with no sidecar reads or decodes.
  • Gloas envelopes: check_execution_payload_envelope_columns_verified skips only the availability step; beacon_envelope.rs no longer reads the sampled columns back.
  • docs/data_storage.md note.

Correctness / Behavior Guarantees

Audit before the change:

  • The only writer of DataColumns is keep_data_column, reached only through on_checked_data_columns (NewDataColumnSidecars).
  • Its senders: gossip Accept (cheap + stateful checks: signature, proposer, inclusion proof, KZG); gossip Queue/Overloaded, fetched columns and replayed parked columns through column::chain_checks_for.
  • One exception: this node's own proposal's custody columns (publish_beacon_block) are built locally and handed over unchecked; debug builds still re-check every write (on_checked_data_columns runs chain_checks_for under debug_assertions).
  • Unverified columns live in PendingDataColumns, which the availability gate never reads.

Benchmarks

The import replay cannot measure this: BlockChainServer::for_replay has an empty custody set, so neither path reads or verifies a column (the leg came out -2.1%, consistent across rounds, but its code does not run there). The expected saving is the ~8 ms of import-thread CPU per block seen in the live perf profile, plus the sidecar decodes; it needs a live measurement.

Tests Added / Run

  • Updated/added in lib.rs: complete custody columns yield ColumnsVerified; undecodable stored rows still yield ColumnsVerified (proves nothing is read); a missing column still yields None.
  • cargo test --profile release-fast -p ethlambda-state-transition --lib (630), -p ethlambda-blockchain --lib (293), and with --features beacon-spec-tests (336, gloas envelope fixtures)
  • Beacon spec fixtures: fork_choice/ 206, networking/ 34

Related Issues / PRs

  • Part of a set of Lighthouse ports from the same profile, each its own PR.

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test — not run; the crate lib tests and spec filters above were run instead

The mainnet profile showed about 8 ms of CPU per block in
verify_cell_kzg_proof_batch under on_block, plus a DB read and SSZ decode of
every custody column, all repeating work each sidecar already paid for on
arrival: DataColumns only receives sidecars the p2p layer accepted (gossip
validation or column::chain_checks, which run the structural check, the
inclusion proof and the KZG batch), keyed by the root of the header those
checks judged.

Add DataAvailability::ColumnsVerified, a caller's promise that every sampled
column was verified, and have data_availability_for return it once the custody
columns are present, without reading them. DataAvailability::Columns keeps the
full spec check, so the fork_choice fixtures still exercise it. The gloas
envelope path gets the same treatment through
check_execution_payload_envelope_columns_verified. The debug-build safety net
stays on the write side (on_checked_data_columns re-runs chain_checks).

Mirrors lighthouse's verified-column types, which carry the proof of
verification so import never re-runs KZG.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Combined result for the four Lighthouse ports (#759 pubkey index, #760 column KZG, #761 progressive target balances, #762 tree-backed participation), squashed onto nightly b1fc143 as 080bd40..1d06bf9.

Offline import replay on ethlambda-5, same three mainnet corpora, both legs built at the commit and run with --warm-committee-sums, 2 rounds, steady state n=164, paired per block:

nightly b1fc143 + all four (1d06bf9) change 95% CI
STF per block 140.7 ms 20.7 ms -120.0 ms (-85.3%) [-123.5, -117.3]
wall (import + fork choice) 162.5 ms 43.8 ms -118.7 ms (-73.0%) [-122.1, -116.0]

Per STF step, ordinary blocks (n=162): sync_aggregate 71.4 -> 0.9 ms (#759), execution_requests 5.3 -> 0.0 ms (#759), state_root 24.8 -> 4.0 ms (#762), unattributed 28.0 -> 1.3 ms (#761's removed state clone and scan, #762's cheaper clone). Regressions being fixed: attestations +2.9 ms, process_epoch +9.2 ms at epoch boundaries, get_head +1.0 ms; a per-item attribution run is in progress.

Live on the ethlambda-2 mainnet follower (nightly 1d06bf9): steady stf ~30-40 ms per gossip block (nightly before the ports: ~175 ms).

@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Per-PR attribution on nightly, where the four ports are stacked in the order #759, #760 + #761, #762. Offline import replay on ethlambda-5, three mainnet corpora, --warm-committee-sums, 2 rounds, steady state n=164, paired per block:

nightly at STF per block change vs before the ports 95% CI
b1fc143 (before the ports) 140.1 ms
+ #759 (080bd40) 65.1 ms -75.0 ms (-53.5%) [-78.5, -72.3]
+ #760, #761 (33551eb) 39.9 ms -100.2 ms (-71.5%) [-103.7, -97.5]
+ #762 (1d06bf9) 20.8 ms -119.4 ms (-85.2%) [-122.9, -116.6]
+ follow-ups in #761 and #762 (2b51a33) 19.1 ms

#760 does not run in the replay (no custody columns), so the second step is #761's.

Regressions found by the per-step split, and what the follow-up commits (now pushed to #761 and #762) did about them:

step from added after the follow-ups
attestations, ordinary blocks #761: second validator read per attester +1.4 ms 5.8 -> 4.3 ms together (before the ports: 2.9)
attestations, ordinary blocks #762: per-attester tree read and write +1.5 ms
process_epoch, epoch boundaries #762 +9.1 ms 106.2 -> 104.8 ms (before the ports: 97.2)
get_head the #760 + #761 step +1.3 ms unchanged

get_head's code is untouched by either PR; a perf profile puts the extra time in walk_head's own code, so it looks like codegen rather than work. The same profile shows std SipHash in fork choice's vote tally as most of get_head's ~17 ms per block; that is a separate change, not part of these PRs.

ethlambda-2 (mainnet follower) runs nightly 2b51a33 since 2026-11-03 14:42 UTC.

@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