Skip to content

Commit 68ec147

Browse files
committed
Build children index once in find_head to fix O(n²)
Add a build_children_index() helper that creates a parent→children mapping in O(n), then pass it through find_head_walk, filter_block_tree, and get_node_children to replace per-node full scans with O(1) lookups. Convert filter_block_tree from recursive to an iterative reverse pass. Proto_array nodes are stored in insertion order (children always have higher indices than parents), so a single reverse iteration processes every child before its parent — matching the spec's recursive post-order semantics without recursion or an explicit stack. This avoids stack overflow on long chains without finality (500k+ blocks). Adds criterion benchmarks for find_head with chain lengths up to 518k.
1 parent 27af0ed commit 68ec147

4 files changed

Lines changed: 237 additions & 68 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

consensus/proto_array/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@ superstruct = { workspace = true }
1919
typenum = { workspace = true }
2020
types = { workspace = true }
2121
yaml_serde = { workspace = true }
22+
23+
[dev-dependencies]
24+
criterion = { workspace = true }
25+
fixed_bytes = { workspace = true }
26+
27+
[[bench]]
28+
name = "find_head"
29+
harness = false
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
2+
use fixed_bytes::FixedBytesExtended;
3+
use proto_array::{Block, ExecutionStatus, JustifiedBalances, ProtoArrayForkChoice};
4+
use std::collections::BTreeSet;
5+
use std::time::Duration;
6+
use types::{
7+
AttestationShufflingId, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256,
8+
MainnetEthSpec, Slot,
9+
};
10+
11+
fn get_root(i: u64) -> Hash256 {
12+
Hash256::from_low_u64_be(i)
13+
}
14+
15+
fn get_hash(i: u64) -> ExecutionBlockHash {
16+
ExecutionBlockHash::from_root(get_root(i))
17+
}
18+
19+
/// Build a linear chain of `num_blocks` blocks.
20+
fn build_chain(num_blocks: u64, gloas: bool) -> (ProtoArrayForkChoice, types::ChainSpec) {
21+
let mut spec = MainnetEthSpec::default_spec();
22+
spec.proposer_score_boost = Some(50);
23+
let gloas_fork_slot = 32;
24+
if gloas {
25+
spec.gloas_fork_epoch = Some(Epoch::new(1));
26+
}
27+
28+
let finalized_checkpoint = Checkpoint {
29+
epoch: Epoch::new(0),
30+
root: get_root(0),
31+
};
32+
let junk_shuffling_id = AttestationShufflingId::from_components(Epoch::new(0), Hash256::zero());
33+
34+
let mut fork_choice = ProtoArrayForkChoice::new::<MainnetEthSpec>(
35+
Slot::new(0),
36+
Slot::new(0),
37+
Hash256::zero(),
38+
finalized_checkpoint,
39+
finalized_checkpoint,
40+
junk_shuffling_id.clone(),
41+
junk_shuffling_id.clone(),
42+
ExecutionStatus::Optimistic(ExecutionBlockHash::zero()),
43+
None,
44+
None,
45+
0,
46+
&spec,
47+
)
48+
.expect("should create fork choice");
49+
50+
for i in 1..=num_blocks {
51+
let is_gloas = gloas && i >= gloas_fork_slot;
52+
let block = Block {
53+
slot: Slot::new(i),
54+
root: get_root(i),
55+
parent_root: Some(get_root(i - 1)),
56+
state_root: Hash256::zero(),
57+
target_root: get_root(0),
58+
current_epoch_shuffling_id: junk_shuffling_id.clone(),
59+
next_epoch_shuffling_id: junk_shuffling_id.clone(),
60+
justified_checkpoint: finalized_checkpoint,
61+
finalized_checkpoint,
62+
execution_status: ExecutionStatus::Optimistic(ExecutionBlockHash::zero()),
63+
unrealized_justified_checkpoint: Some(finalized_checkpoint),
64+
unrealized_finalized_checkpoint: Some(finalized_checkpoint),
65+
execution_payload_parent_hash: if is_gloas {
66+
Some(get_hash(i - 1))
67+
} else {
68+
None
69+
},
70+
execution_payload_block_hash: if is_gloas { Some(get_hash(i)) } else { None },
71+
proposer_index: Some(0),
72+
};
73+
74+
fork_choice
75+
.process_block::<MainnetEthSpec>(block, Slot::new(i), &spec, Duration::ZERO)
76+
.expect("should process block");
77+
}
78+
79+
(fork_choice, spec)
80+
}
81+
82+
fn bench_find_head(c: &mut Criterion) {
83+
let mut group = c.benchmark_group("find_head");
84+
let equivocating_indices = BTreeSet::new();
85+
86+
// Must survive extended non-finality (500k+ blocks).
87+
for &num_blocks in &[100, 1_000, 10_000, 50_000, 216_000, 518_000] {
88+
let (mut fork_choice, spec) = build_chain(num_blocks, false);
89+
let finalized_checkpoint = Checkpoint {
90+
epoch: Epoch::new(0),
91+
root: get_root(0),
92+
};
93+
let balances = JustifiedBalances::from_effective_balances(vec![1; 64]).unwrap();
94+
95+
group.bench_function(BenchmarkId::new("pre_gloas", num_blocks), |b| {
96+
b.iter(|| {
97+
fork_choice
98+
.find_head::<MainnetEthSpec>(
99+
finalized_checkpoint,
100+
finalized_checkpoint,
101+
&balances,
102+
Hash256::zero(),
103+
&equivocating_indices,
104+
Slot::new(num_blocks),
105+
&spec,
106+
)
107+
.expect("should find head")
108+
});
109+
});
110+
}
111+
112+
// 216k = ~1 month non-finality mainnet, 518k = ~1 month non-finality Gnosis
113+
for &num_blocks in &[100, 1_000, 10_000, 50_000, 216_000, 518_000] {
114+
let (mut fork_choice, spec) = build_chain(num_blocks, true);
115+
let finalized_checkpoint = Checkpoint {
116+
epoch: Epoch::new(0),
117+
root: get_root(0),
118+
};
119+
let balances = JustifiedBalances::from_effective_balances(vec![1; 64]).unwrap();
120+
121+
group.bench_function(BenchmarkId::new("gloas", num_blocks), |b| {
122+
b.iter(|| {
123+
fork_choice
124+
.find_head::<MainnetEthSpec>(
125+
finalized_checkpoint,
126+
finalized_checkpoint,
127+
&balances,
128+
Hash256::zero(),
129+
&equivocating_indices,
130+
Slot::new(num_blocks),
131+
&spec,
132+
)
133+
.expect("should find head")
134+
});
135+
});
136+
}
137+
138+
group.finish();
139+
}
140+
141+
criterion_group!(benches, bench_find_head);
142+
criterion_main!(benches);

consensus/proto_array/src/proto_array.rs

Lines changed: 86 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,20 @@ impl ProtoArray {
11081108
Ok((best_fc_node.root, best_fc_node.payload_status))
11091109
}
11101110

1111+
/// Build parent→children index in O(n). Returns a vec where `result[i]` contains
1112+
/// the indices of all children of node `i`.
1113+
fn build_children_index(&self) -> Vec<Vec<usize>> {
1114+
let mut children = vec![Vec::new(); self.nodes.len()];
1115+
for (i, node) in self.nodes.iter().enumerate() {
1116+
if let Some(parent_idx) = node.parent()
1117+
&& parent_idx < children.len()
1118+
{
1119+
children[parent_idx].push(i);
1120+
}
1121+
}
1122+
children
1123+
}
1124+
11111125
/// Spec: `get_filtered_block_tree`.
11121126
///
11131127
/// Returns the set of node indices on viable branches — those with at least
@@ -1118,6 +1132,7 @@ impl ProtoArray {
11181132
current_slot: Slot,
11191133
best_justified_checkpoint: Checkpoint,
11201134
best_finalized_checkpoint: Checkpoint,
1135+
children_index: &[Vec<usize>],
11211136
) -> HashSet<usize> {
11221137
let mut viable = HashSet::new();
11231138
self.filter_block_tree::<E>(
@@ -1126,71 +1141,66 @@ impl ProtoArray {
11261141
best_justified_checkpoint,
11271142
best_finalized_checkpoint,
11281143
&mut viable,
1144+
children_index,
11291145
);
11301146
viable
11311147
}
11321148

11331149
/// Spec: `filter_block_tree`.
1150+
///
1151+
/// Proto_array stores nodes in insertion order — children always have higher
1152+
/// indices than their parents. A single reverse pass therefore processes every
1153+
/// child before its parent, matching the spec's recursive post-order semantics
1154+
/// without recursion (required to survive 500k+ blocks of non-finality).
11341155
fn filter_block_tree<E: EthSpec>(
11351156
&self,
1136-
node_index: usize,
1157+
start_index: usize,
11371158
current_slot: Slot,
11381159
best_justified_checkpoint: Checkpoint,
11391160
best_finalized_checkpoint: Checkpoint,
11401161
viable: &mut HashSet<usize>,
1141-
) -> bool {
1142-
let Some(node) = self.nodes.get(node_index) else {
1143-
return false;
1144-
};
1145-
1146-
// Skip invalid children — they aren't in store.blocks in the spec.
1147-
let children: Vec<usize> = self
1148-
.nodes
1149-
.iter()
1150-
.enumerate()
1151-
.filter(|(_, child)| {
1152-
child.parent() == Some(node_index)
1153-
&& !child
1154-
.execution_status()
1155-
.is_ok_and(|status| status.is_invalid())
1156-
})
1157-
.map(|(i, _)| i)
1158-
.collect();
1162+
children_index: &[Vec<usize>],
1163+
) {
1164+
for node_index in (start_index..self.nodes.len()).rev() {
1165+
let Some(node) = self.nodes.get(node_index) else {
1166+
continue;
1167+
};
11591168

1160-
if !children.is_empty() {
1161-
// Evaluate ALL children (no short-circuit) to mark all viable branches.
1162-
let any_viable = children
1169+
// Spec: children = [root for root in blocks if blocks[root].parent_root == block_root]
1170+
// Skip execution-invalid children (not in store.blocks in the spec).
1171+
let children = children_index
1172+
.get(node_index)
1173+
.map(|c| c.as_slice())
1174+
.unwrap_or(&[]);
1175+
let valid_children: Vec<usize> = children
11631176
.iter()
1164-
.map(|&child_index| {
1165-
self.filter_block_tree::<E>(
1166-
child_index,
1167-
current_slot,
1168-
best_justified_checkpoint,
1169-
best_finalized_checkpoint,
1170-
viable,
1171-
)
1177+
.copied()
1178+
.filter(|&i| {
1179+
!self.nodes.get(i).is_some_and(|child| {
1180+
child
1181+
.execution_status()
1182+
.is_ok_and(|status| status.is_invalid())
1183+
})
11721184
})
1173-
.collect::<Vec<_>>()
1174-
.into_iter()
1175-
.any(|v| v);
1176-
if any_viable {
1177-
viable.insert(node_index);
1178-
return true;
1179-
}
1180-
return false;
1181-
}
1185+
.collect();
11821186

1183-
// Leaf node: check viability.
1184-
if self.node_is_viable_for_head::<E>(
1185-
node,
1186-
current_slot,
1187-
best_justified_checkpoint,
1188-
best_finalized_checkpoint,
1189-
) {
1190-
viable.insert(node_index);
1191-
return true;
1187+
if !valid_children.is_empty() {
1188+
// Spec: if any(children): if any(filter_block_tree_result): blocks[block_root] = block
1189+
if valid_children.iter().any(|c| viable.contains(c)) {
1190+
viable.insert(node_index);
1191+
}
1192+
} else {
1193+
// Spec: leaf — check correct_justified and correct_finalized
1194+
if self.node_is_viable_for_head::<E>(
1195+
node,
1196+
current_slot,
1197+
best_justified_checkpoint,
1198+
best_finalized_checkpoint,
1199+
) {
1200+
viable.insert(node_index);
1201+
}
1202+
}
11921203
}
1193-
false
11941204
}
11951205

11961206
/// Spec: `get_head`.
@@ -1211,12 +1221,15 @@ impl ProtoArray {
12111221
payload_status: PayloadStatus::Pending,
12121222
};
12131223

1224+
let children_index = self.build_children_index();
1225+
12141226
// Spec: `get_filtered_block_tree`.
12151227
let viable_nodes = self.get_filtered_block_tree::<E>(
12161228
start_index,
12171229
current_slot,
12181230
best_justified_checkpoint,
12191231
best_finalized_checkpoint,
1232+
&children_index,
12201233
);
12211234

12221235
// Compute once rather than per-child per-level.
@@ -1225,7 +1238,7 @@ impl ProtoArray {
12251238

12261239
loop {
12271240
let children: Vec<_> = self
1228-
.get_node_children(&head)?
1241+
.get_node_children(&head, &children_index)?
12291242
.into_iter()
12301243
.filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index))
12311244
.collect();
@@ -1384,6 +1397,7 @@ impl ProtoArray {
13841397
fn get_node_children(
13851398
&self,
13861399
node: &IndexedForkChoiceNode,
1400+
children_index: &[Vec<usize>],
13871401
) -> Result<Vec<(IndexedForkChoiceNode, ProtoNode)>, Error> {
13881402
if node.payload_status == PayloadStatus::Pending {
13891403
let proto_node = self
@@ -1397,25 +1411,29 @@ impl ProtoArray {
13971411
}
13981412
Ok(children)
13991413
} else {
1400-
Ok(self
1401-
.nodes
1402-
.iter()
1403-
.enumerate()
1404-
.filter(|(_, child_node)| {
1405-
child_node.parent() == Some(node.proto_node_index)
1406-
&& child_node.get_parent_payload_status() == node.payload_status
1407-
})
1408-
.map(|(child_index, child_node)| {
1409-
(
1410-
IndexedForkChoiceNode {
1411-
root: child_node.root(),
1412-
proto_node_index: child_index,
1413-
payload_status: PayloadStatus::Pending,
1414-
},
1415-
child_node.clone(),
1416-
)
1414+
Ok(children_index
1415+
.get(node.proto_node_index)
1416+
.map(|indices| {
1417+
indices
1418+
.iter()
1419+
.filter_map(|&child_index| {
1420+
let child_node = self.nodes.get(child_index)?;
1421+
if child_node.get_parent_payload_status() == node.payload_status {
1422+
Some((
1423+
IndexedForkChoiceNode {
1424+
root: child_node.root(),
1425+
proto_node_index: child_index,
1426+
payload_status: PayloadStatus::Pending,
1427+
},
1428+
child_node.clone(),
1429+
))
1430+
} else {
1431+
None
1432+
}
1433+
})
1434+
.collect()
14171435
})
1418-
.collect())
1436+
.unwrap_or_default())
14191437
}
14201438
}
14211439

0 commit comments

Comments
 (0)