Repository navigation
perf(fork_choice): build the parent-to-children map once in get_filtered_block_tree - #746
Conversation
…ead of scanning the whole block index at every node of filter_block_tree. The scan ran once per node of the justified subtree over the full live window since finality, so the head walk was quadratic in the number of unfinalized blocks, exactly what a finality stall or a range catch-up grows. walk_head and invalidate_subtree already build the map this way; this brings the filter in line with them. A new test keeps the spec-literal scan as a reference and checks the two agree on random trees, including ones where a branch is filtered out.
🤖 Claude Code ReviewReview of PR 746: build the parent-to-children map once in
|
🤖 Kimi Code ReviewI'll review this PR which optimizes Overall AssessmentThis is a solid performance optimization with good test coverage. The core idea—pre-building Issues Found1. Critical: Potential Stack Overflow in Deep Trees (fork_choice.rs, ~line 1908-1910)for &child in children {
if filter_block_tree(store, index, children_of, child, blocks, config)? {The recursive Recommendation: Consider adding 2. Bug:
|
| Severity | Issue | Line |
|---|---|---|
| Medium | Stack overflow risk in deep recursion | ~1908 |
| Medium | children_of may include stale/orphaned entries |
~1962 |
| Low | HashMap hasher choice for hot path | ~1908 |
| Low | Test "reference" claim slightly overstated | ~9933 |
| Trivial | Typo in variable name | ~9970 |
| Trivial | Doc function name consistency | ~1956 |
The PR is correct and should ship after addressing or documenting items 1 and 2.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewLooks good overall. This is a targeted fork-choice performance fix, and the new regression test meaningfully checks semantic equivalence against the literal spec-style scan.
Minor note:
Validation note:
Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
…red_block_tree (#746) Build the parent-to-children map once in get_filtered_block_tree instead of scanning the whole block index at every node of filter_block_tree. The scan ran once per node of the justified subtree over the full live window since finality, so the head walk was quadratic in the number of unfinalized blocks, exactly what a finality stall or a range catch-up grows. walk_head and invalidate_subtree already build the map this way; this brings the filter in line with them. A new test keeps the spec-literal scan as a reference and checks the two agree on random trees, including ones where a branch is filtered out. Cherry-picked from fork-choice-children-map at f772f03.
Block import benchmark (offline replay of mainnet blocks, steady state)
No change at this size, as expected: the replay's tree never holds more than about 90 blocks (one warm-up epoch plus 58 measured), and a healthy mainnet node's is similar (finality 2 to 3 epochs back), so the old per-node scan cost tens of µs. Synthetic trees (finality stall)A temporary Single chain:
With a one-block orphan every 32 blocks:
Method
|
#746 was squash-merged, so this branch's copy of its commits no longer matches main. The tree is main plus only this branch's own commit.
Brings in #751 (bump rustls, quick-xml, zstd-sys, ruint and quinn-proto past RustSec advisories, and drop libp2p's unused transports), #763 (the DeepSeek, Gemini and Grok AI reviewers) and #766 (the Telegram links). #746 is main's version of a PR nightly already carries as squash 2b0f720. Its reference test sits elsewhere in fork_choice.rs on main, so the merge added a second, identical copy; that copy is dropped, which leaves fork_choice.rs exactly as nightly had it. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
…#758) filter_block_tree recursed once per block below the justified checkpoint. That depth grows by a block every slot for as long as the chain does not justify, and get_head runs on the chain actor's thread, which spawned-concurrency starts with std::thread::spawn and so the default 2 MiB stack. On that stack the recursive walk overflowed between 5,500 and 6,000 blocks of depth (release-fast): about 18 to 20 hours of a stall with no new justification. A stack overflow aborts the process, and the next head computation after a restart walks the same tree. The walk now keeps its own stack of the path from the justified block to the block in hand. It visits the same blocks in the same order, asserts `block_root in store.blocks` at the same points and returns the same tree. The leaf check moved unchanged into is_viable_leaf, which returns the verdict and leaves the insertion to the walk. Cherry-picked from fork-choice-iterative-filter at de7bb98 (b102e35; the branch's other commits are #746, already on nightly, and merges of main). Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
🗒️ Description / Motivation
filter_block_treefound a block's children by scanning the whole block index(
Store::block_index, the fullLiveChainwindow since the finalized block) atevery node it visited. The recursion visits every node of the justified subtree,
so the head walk was quadratic in the number of unfinalized blocks. That runs on
every block import and every tick through
get_head, and the cost grows exactlywhen the node is under pressure: a finality stall or a range catch-up from a
checkpoint anchor.
walk_headandinvalidate_subtreealready build a parent-to-children map onceper call; this brings the filter in line with them. Found by Kimi during an
improvement review.
What Changed
crates/blockchain/state_transition/src/beacon/fork_choice.rsget_filtered_block_treebuildsHashMap<Root, Vec<Root>>from the index inone pass and hands it down.
filter_block_treetakes that map and does one lookup per node instead of ascan per node.
a
BTreeMap" section, thefilter_block_treedoc, one cross-reference incompute_head).compute_headandget_node_childrenkeep their per-level scans on purpose:they are the spec-literal references the tests compare the walk against.
Correctness / Behavior Guarantees
into a map, so it cannot change the result.
calls (
filter_block_treeispubbut has no callers outside this file).Tests Added / Run
the_filtered_tree_matches_the_spec_literal_children_scan_on_random_treeskeeps the old scan as a reference and asserts the map-based result equals it on
100 random trees, including slots where a branch is filtered out (asserts that
happened at least once, so it cannot pass vacuously).
already cover
get_filtered_block_tree.make fmt,make lint, fork_choice unit tests (94 passed), mainnetfork-choice fixture cases (206 passed),
make test-consensus(1324 passed).Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test—test-consensusrun locally;test-nodeleft to CI(unrelated to this change)