perf(fork-choice): early-break window walk in getCanonicalPayloadCounts - #9824
Conversation
Demo for ChainSafe#9815 (discussion_r3779437582): keep the circuit-breaker payload scan O(window) instead of O(chain-to-anchor). Instead of materializing the whole canonical chain via getAllAncestorNodes(), count the resolved head inline, then iterate ancestors and break once a node falls below `fromSlot` (ancestors are slot-descending, so everything past that is already out of the window). This handles the head-inclusion gotcha: iterateAncestorNodes() alone drops the start node, so the head is counted explicitly before walking `.parent` via iterateAncestorNodesFromNode(). Behavior is identical to the previous full-scan (same canonical node sequence, same filters); only the traversal is lazy with an early exit. Matters mainly under prolonged non-finality. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @ensi321 🙏 Since #9824 targets @nflaig — your call on the mechanism: merge #9824 onto the branch directly, or if you'd rather fold it into your own commit for authorship/history I'll close this out. Either works for me. (Also retitled it — |
| } | ||
|
|
||
| /** Count blocks selected as FULL or EMPTY by the supplied head chain in the inclusive slot range. */ | ||
| getCanonicalPayloadCounts( |
There was a problem hiding this comment.
I think while this is correct, I find it hard to follow with countNode
Something like
const headIndex = this.getNodeIndexByRootAndStatus(headRoot, headPayloadStatus);
let node = headIndex !== undefined ? this.nodes[headIndex] : undefined;
while (node !== undefined && node.slot >= fromSlot) {
if (
node.slot !== GENESIS_SLOT &&
node.slot <= toSlot &&
isGloasBlock(node) &&
node.payloadStatus !== PayloadStatus.PENDING
)
...
node = this.nodes[this.getParentNodeIndex(node)]
}
would be more straightforward
There was a problem hiding this comment.
Good call — done in 95082e3 (pushed to the branch). Flattened to the single while loop walking getParentNodeIndex, dropping the countNode closure + iterateAncestorNodesFromNode generator. Reads much better.
Confirmed equivalent: the full protoArray unit suite is still green (131/131), including the tricky getCanonicalPayloadCounts cases — keeps-EMPTY-after-late-FULL, competing branches, genesis skip, PENDING head, inclusive bounds. The only mechanical difference is that this walks purely via getParentNodeIndex (no node.parent !== undefined pre-check), which coincides for the canonical in-window chain since the anchor's parent isn't in the array → getParentNodeIndex returns undefined and the loop ends.
Per @ensi321's review on ChainSafe#9824 — replace the `countNode` closure + `iterateAncestorNodesFromNode` generator with a single flat `while` loop walking `getParentNodeIndex` directly. Functionally identical (131/131 protoArray unit tests pass); just easier to follow. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@lodekeeper is this ready to merge into my branch? |
|
Yeah, ready from my side 👍
Still your call on the mechanism — merge #9824 straight onto |
nflaig
left a comment
There was a problem hiding this comment.
LGTM, this will be helpful during long non-finality
fd84843
into
ChainSafe:nflaig/circuit-breaker-canonical-counts
|
🙏 thanks — and thanks @ensi321 for the review + the cleaner flat-loop suggestion. |
Demo requested in #9815 (discussion_r3779437582) — showing how the O(window) early-break variant of
getCanonicalPayloadCountswould look. Targetsnflaig/circuit-breaker-canonical-counts, notunstable.Approach
Instead of materializing the whole canonical chain via
getAllAncestorNodes(), walk it newest-first and stop once a node drops belowfromSlot. Ancestors are strictly slot-descending, so everything past that point is already out of the window — the previous full scan justcontinued over those.The head-inclusion gotcha (your note in r3779406069)
iterateAncestorNodes()starts one hop up (iterateAncestorNodesFromNodebegins at.parent), so a direct swap drops the resolved head and undercounts by one. Handled here by counting the head explicitly first, then walking.parentviaiterateAncestorNodesFromNode(head). A PENDING head is still skipped bycountNode(same asgetAllAncestorNodesnot pushing a PENDING start node) — it only seeds the ancestor walk.Behavior
Identical to the current full-scan: same canonical node sequence (head + ancestors), same filters (genesis / out-of-window / non-gloas / PENDING), same
full/emptycounts. Only the traversal is lazy with an early exit. The win (O(window)vsO(chain-to-anchor)) only shows under prolonged non-finality, when the chain-to-anchor grows well past the fault window.Tests
Ran the fork-choice protoArray suite locally against this change — 131/131 pass (
packages/fork-choice/test/unit/protoArray, 7 files), including everygetCanonicalPayloadCountscase: genesis skip, "keeps EMPTY after a late FULL arrives", PENDING head, inclusive bounds, uses-supplied-head-branch. CI here re-runs the full suite.Not attached to it — happy to close if you'd rather keep the simpler full-scan; just wanted to show the shape as you asked.
🤖 Generated with AI assistance