perf(blockchain): bound attestation ancestry walks by fork depth - #610
perf(blockchain): bound attestation ancestry walks by fork depth#610MegaRedHand wants to merge 4 commits into
Conversation
`validate_attestation_data` made three unbounded parent-chain walks per
attestation, and every step of a walk is a fresh read view, a RocksDB point
get, and an SSZ decode. A walk's length is the slot distance between the two
checkpoints, so it grows without limit as finalization falls behind: with
finalized at slot 928 and the head near 64000, the source-to-target and
finalized-to-head walks together came to roughly 119,000 header reads for a
single attestation, repeated for every gossip attestation, every aggregate,
and every self-delivered vote.
That turns a finality lag into a feedback loop. Validation slows, attestations
are dropped, finality falls further behind, and the walks grow longer still.
`BlockRoots` already indexes the canonical chain by slot, written atomically
with the head in `update_checkpoints`, so it always describes the branch
ending at the stored head. Since that chain is a single path, a walk that
reaches any canonical block above the ancestor's slot can stop there: the
ancestor lies on the chain exactly when the index names it at its own slot.
Cost is now bounded by how deep the descendant's branch has forked, a handful
of slots, instead of by the distance to the ancestor. The finalized-to-head
check settles with no walk at all, since the finalized checkpoint is canonical
by construction.
Two things the short circuit must not do, both covered by a regression test:
- Conclude "not an ancestor" from an index miss. A `None` is either a slot
the canonical chain skipped or a slot below this store's anchor, and the
index cannot tell those apart, so a miss leaves the full walk as the
answer.
- Fire on a canonical block at or below the ancestor's slot. A branch that
skips the ancestor's slot and rejoins the canonical chain below it does
not contain the ancestor, so the slot guards have to settle the walk
before the index is consulted.
Check ordering in `validate_attestation_data` is left alone. Hoisting the
storage-free checks ahead of the block lookups would save reads on malformed
votes, but it changes which reason a vote failing several checks at once is
rejected for, and five leanSpec gossip-validation fixtures pin those reasons.
A test now records that constraint so the ordering is not mistaken for
something to optimize.
🤖 Kimi Code ReviewI'll review this PR which optimizes Overall AssessmentThe optimization is sound and well-motivated, but I found several issues ranging from minor correctness concerns to a potential infinite loop bug. Critical Issues1. Infinite Loop Risk in
|
| Priority | Issue | Location |
|---|---|---|
| Critical | Add loop termination guard against infinite loop | crates/blockchain/src/store.rs:209-242 |
| High | Clarify let-else + expect intent or panic on None |
crates/blockchain/src/store.rs:232-237 |
| Medium | Consider single read transaction for consistency | crates/blockchain/src/store.rs:217-225 |
| Low | Propagate errors instead of expect in canonical_root_at_slot |
crates/storage/src/store.rs:1334-1337 |
| Low | Simplify test helper | crates/blockchain/src/store.rs:1555-1560 |
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewFindings
No other obvious correctness problems stood out in the diff. I couldn’t run the targeted Rust tests in this environment because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview SummaryOverall this is a well-reasoned, well-tested optimization. I traced the Strengths:
Finding worth a look (reported above, not necessarily blocking):
Nothing else stood out as a correctness, safety, or performance issue — the new tests are appropriately adversarial (they specifically target the two failure modes called out in the PR description), and the Automated review by Claude (Anthropic) · sonnet · custom prompt |
Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
Problem
validate_attestation_datamakes three unbounded parent-chain walks per attestation, and every step of a walk is a fresh read view, a RocksDB point get, and an SSZ decode (get_block_headerhas no cache, unlikeget_state).A walk's length is the slot distance between its two checkpoints, so it grows without limit as finalization falls behind. On a node with finalized at slot 928 and the head near 64000:
That is ~119,000 header reads for a single attestation, and it runs once per gossip attestation, once per aggregate, and for the node's own votes via self-delivery. At a few hundred attestations per slot it exceeds the slot duration by orders of magnitude.
The result is a feedback loop rather than merely slow validation: validation slows, attestations are dropped, finality falls further behind, and the walks grow longer still.
Fix
BlockRootsalready indexes the canonical chain by slot, written atomically with the head inupdate_checkpoints, so it always describes the branch ending at the stored head (it can lag a freshly imported block that fork choice has not selected, but never runs ahead).Since the canonical chain is a single path, a walk that reaches any canonical block above the ancestor's slot can stop there: the ancestor lies on that chain exactly when the index names it at its own slot.
Cost is now bounded by how deep the descendant's branch has forked (a handful of slots) rather than by the distance to the ancestor. The
finalized ← headcheck settles with no walk at all, since the finalized checkpoint is canonical by construction.New storage API, the only addition:
The judgment about how far the index can be trusted stays inside the
Store, so no caller can widen it.Two ways the short circuit can be wrong
Both are load-bearing and both have a regression test.
1. An index miss must not mean "not an ancestor." A
Noneis either a slot the canonical chain skipped or a slot below this store's anchor, and the index cannot tell those apart. A miss therefore leaves the full walk as the answer. This is also what keeps the change correct if historical backfill ever lands.2. The short circuit must not fire at or below the ancestor's slot. I had this wrong at first, and the entire existing suite passed:
A branch that skips the ancestor's slot and rejoins canonical below it does not contain the ancestor. The slot guards have to settle the walk before the index is consulted.
checkpoint_is_ancestor_rejects_ancestor_skipped_by_fork_branchcovers it — verified to fail on the broken ordering and pass on the fixed one.Check ordering left alone
I tried hoisting the four storage-free checks (source > target, head < target, slot < head.slot, and the time check) ahead of the three block lookups, so malformed and future-dated votes would cost no storage reads. It works, but it changes which reason a vote failing several checks at once is rejected for, and five leanSpec gossip-validation fixtures pin those reasons:
Rejection reasons are cross-client observable, so the ordering is not ours to optimize. Reverted, with
validate_attestation_reports_availability_before_cheaper_failuresand a note on the function recording the constraint so it is not mistaken for an oversight later.Testing
update_headran, and the rejection-precedence fence.make lintclean;make testgreen, including all 122 forkchoice spec fixtures.Not in scope
get_live_chainis rebuilt from a fullLiveChainiteration on every head update, so at a 63k-slot finality gap it materializes a ~63,000-entryHashMapper call. Same underlying cause, separate hot path.