Skip to content

Commit ab7b5a2

Browse files
authored
feat(aggregation): cap aggregation at one job before our proposal (#544)
## Motivation At interval 2 the aggregation worker runs up to `MAX_AGGREGATION_JOBS` leanVM proofs. When interval 4 of the *same* slot builds the next slot's block, that build runs its own proofs — so the two compete for the prover, and the build is the one with a hard deadline (it has to publish at the next slot's interval-0 tick). ## Change `start_aggregation_session` drops the session to a single job whenever one of our validators proposes the next slot: ```rust let next_proposer = self .get_our_proposer(slot + 1) .filter(|_| self.sync_status.duties_allowed()); let max_jobs = if next_proposer.is_some() { 1 } else { MAX_AGGREGATION_JOBS }; ``` - **Condition mirrors the propose path** (`SlotInterval::EndOfSlot`): proposer *and* `duties_allowed()`. A slot where duties are sync-suppressed keeps the full job budget, since no build will happen. - **Covers both entry points.** The cap is computed inside `start_aggregation_session`, so it applies to the interval-2 tick *and* the early 2/3-threshold trigger. The early session *is* the slot's session, so exempting it would defeat the change. - **The retained job is the best-scoring candidate.** `snapshot_aggregation_inputs` gained a `max_jobs` parameter that bounds the greedy selection loop; the pool is unchanged (`groups_considered` still counts every candidate), so the one job we run is the same one the uncapped selection picks first. - **`MAX_AGGREGATION_JOBS` lowered 3 → 2**, trimming baseline prover work per session too. Unchanged: `AGGREGATION_DEADLINE`, the early-trigger threshold, and the worker loop. ## Tests - Existing `snapshot_caps_jobs_at_max_aggregation_jobs` refactored to share a store fixture with a new `snapshot_caps_jobs_at_one_for_proposer`, which asserts the proposer cap yields exactly one job and that it is the top-scoring candidate (not an arbitrary one). - `make lint` clean, `cargo test --workspace --release` green (122 fork-choice spec, 119 STF, all unit tests).
1 parent 97485de commit ab7b5a2

2 files changed

Lines changed: 81 additions & 26 deletions

File tree

crates/blockchain/src/aggregation.rs

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
//! aggregation material once (raw-first + trim, see [`resolve_job`]), then a
1515
//! pure in-memory loop scores and orders candidates by consensus value
1616
//! (current-slot before stale, then Finalize > Justify > Build), emitting at
17-
//! most [`MAX_AGGREGATION_JOBS`] jobs.
17+
//! most `max_jobs` jobs — [`MAX_AGGREGATION_JOBS`] normally, dropping to a
18+
//! single job in the slot before one of our validators proposes.
1819
1920
use std::collections::{HashMap, HashSet};
2021
use std::time::{Duration, Instant, SystemTime};
@@ -176,7 +177,7 @@ impl Message for EarlyAggregationCheck {
176177
/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in
177178
/// [`snapshot_aggregation_inputs`] stops after this many rounds even if
178179
/// scoring candidates remain.
179-
const MAX_AGGREGATION_JOBS: usize = 3;
180+
pub(crate) const MAX_AGGREGATION_JOBS: usize = 2;
180181

181182
/// Build a snapshot of everything needed to aggregate. Runs on the actor
182183
/// thread, touches the store, does no heavy cryptography. Returns `None` when
@@ -190,17 +191,22 @@ const MAX_AGGREGATION_JOBS: usize = 3;
190191
/// (`store.iter_gossip_signatures()`) and payload-only groups
191192
/// (`store.new_payload_keys()` not already a gossip candidate, requiring
192193
/// at least two existing proofs to merge).
193-
/// 2. **Greedy loop**, at most [`MAX_AGGREGATION_JOBS`] rounds: each round
194+
/// 2. **Greedy loop**, at most `max_jobs` rounds: each round
194195
/// scores every unselected candidate against the projected state and
195196
/// keeps the lowest ordering key (current-slot before stale, then
196197
/// Finalize > Justify > Build, mirroring the block builder). The winning
197198
/// [`AggregationJob`] is emitted as-is; the projection is updated with its
198199
/// realized coverage.
199200
///
200201
/// Stops early when no remaining candidate scores (converged).
202+
///
203+
/// `max_jobs` is [`MAX_AGGREGATION_JOBS`] for an ordinary session and `1` when
204+
/// the caller is about to build a block at interval 4 (see
205+
/// `BlockChainServer::start_aggregation_session`).
201206
pub fn snapshot_aggregation_inputs(
202207
store: &Store,
203208
current_slot: u64,
209+
max_jobs: usize,
204210
) -> Option<AggregationSnapshot> {
205211
let gossip_groups = store.iter_gossip_signatures();
206212
let new_payload_keys = store.new_payload_keys();
@@ -269,9 +275,8 @@ pub fn snapshot_aggregation_inputs(
269275

270276
let mut projected = block_builder::ProjectedState::from_head_state(&head_state);
271277

272-
let mut jobs: Vec<AggregationJob> =
273-
Vec::with_capacity(MAX_AGGREGATION_JOBS.min(groups_considered));
274-
for _round in 0..MAX_AGGREGATION_JOBS {
278+
let mut jobs: Vec<AggregationJob> = Vec::with_capacity(max_jobs.min(groups_considered));
279+
for _round in 0..max_jobs {
275280
let Some((data_root, score)) = pick_best_candidate(
276281
&candidates,
277282
&projected,
@@ -1192,7 +1197,7 @@ mod tests {
11921197
fn snapshot_returns_none_for_empty_store() {
11931198
let hashes = vec![H256([1u8; 32])];
11941199
let store = new_test_store(make_head_state(0, 4, &hashes));
1195-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1200+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
11961201
}
11971202

11981203
/// A single gossip signature with no other material to merge is dropped
@@ -1221,7 +1226,7 @@ mod tests {
12211226
let hashed = HashedAttestationData::new(att_data);
12221227
store.insert_gossip_signature(hashed, 0, dummy_sig());
12231228

1224-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1229+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
12251230
}
12261231

12271232
/// A group whose target is already justified (here: at or behind the
@@ -1264,7 +1269,7 @@ mod tests {
12641269
store.insert_gossip_signature(hashed, 1, dummy_sig());
12651270

12661271
assert!(
1267-
snapshot_aggregation_inputs(&store, 999).is_none(),
1272+
snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS).is_none(),
12681273
"a group targeting an already-justified slot must never become a job"
12691274
);
12701275
}
@@ -1320,7 +1325,7 @@ mod tests {
13201325
store.insert_gossip_signature(hashed.clone(), 0, dummy_sig());
13211326
store.insert_gossip_signature(hashed, 1, dummy_sig());
13221327

1323-
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT)
1328+
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT, MAX_AGGREGATION_JOBS)
13241329
.expect("a vote for the current head must produce a job (chain view covers the tip)");
13251330
assert_eq!(snapshot.jobs.len(), 1);
13261331
assert_eq!(
@@ -1330,23 +1335,26 @@ mod tests {
13301335
);
13311336
}
13321337

1333-
/// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1334-
/// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1335-
/// key. Five Build-tier candidates (2 raw sigs each, well under the 2/3
1336-
/// threshold) differ only by `target_slot`; Build-tier ordering prefers
1337-
/// larger `target_slot` on a new_voters tie, so the top three by slot win.
1338-
#[test]
1339-
fn snapshot_caps_jobs_at_max_aggregation_jobs() {
1338+
/// Number of competing candidates built by
1339+
/// [`store_with_competing_build_tier_groups`]; more than either job cap so
1340+
/// both cap tests actually bind.
1341+
const NUM_GROUPS: usize = 5;
1342+
1343+
/// Store holding `NUM_GROUPS` competing Build-tier candidates (2 raw sigs
1344+
/// each, well under the 2/3 threshold) that differ only by `target_slot`
1345+
/// (`1..=NUM_GROUPS`, all justifiable at delta <= 5). Build-tier ordering
1346+
/// prefers larger `target_slot` on a new_voters tie, so selection takes
1347+
/// them highest-slot-first.
1348+
fn store_with_competing_build_tier_groups() -> Store {
13401349
const NUM_VALIDATORS: usize = 10;
13411350
const HEAD_SLOT: u64 = 10;
1342-
const NUM_GROUPS: usize = 5;
13431351

13441352
let hashes: Vec<H256> = (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect();
13451353
let mut store = new_test_store(make_head_state(HEAD_SLOT, NUM_VALIDATORS, &hashes));
13461354
insert_test_block(&mut store, hashes[0], 0, H256::ZERO);
13471355

13481356
for i in 0..NUM_GROUPS {
1349-
let target_slot = i as u64 + 1; // 1..=5, all justifiable (delta <= 5)
1357+
let target_slot = i as u64 + 1;
13501358
let att_data = AttestationData {
13511359
slot: target_slot,
13521360
head: Checkpoint {
@@ -1368,7 +1376,18 @@ mod tests {
13681376
store.insert_gossip_signature(hashed, (2 * i + 1) as u64, dummy_sig());
13691377
}
13701378

1371-
let snapshot = snapshot_aggregation_inputs(&store, 999).expect("should produce jobs");
1379+
store
1380+
}
1381+
1382+
/// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1383+
/// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1384+
/// key, i.e. the top two by `target_slot`.
1385+
#[test]
1386+
fn snapshot_caps_jobs_at_max_aggregation_jobs() {
1387+
let store = store_with_competing_build_tier_groups();
1388+
1389+
let snapshot = snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS)
1390+
.expect("should produce jobs");
13721391
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
13731392
assert_eq!(snapshot.jobs.len(), MAX_AGGREGATION_JOBS);
13741393

@@ -1379,8 +1398,27 @@ mod tests {
13791398
.collect();
13801399
assert_eq!(
13811400
selected_targets,
1382-
HashSet::from([3, 4, 5]),
1383-
"the three highest target_slot groups win the new_voters tie"
1401+
HashSet::from([4, 5]),
1402+
"the two highest target_slot groups win the new_voters tie"
1403+
);
1404+
}
1405+
1406+
/// The proposer cap (`max_jobs = 1`) yields exactly one job from the same
1407+
/// pool, and it is the single best-scoring candidate — the one the uncapped
1408+
/// selection also picks first (highest `target_slot`). Every other candidate
1409+
/// is still counted in `groups_considered`, so the cap is visibly a
1410+
/// selection bound rather than a narrower candidate pool.
1411+
#[test]
1412+
fn snapshot_caps_jobs_at_one_for_proposer() {
1413+
let store = store_with_competing_build_tier_groups();
1414+
1415+
let snapshot = snapshot_aggregation_inputs(&store, 999, 1).expect("should produce a job");
1416+
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
1417+
assert_eq!(snapshot.jobs.len(), 1);
1418+
assert_eq!(
1419+
snapshot.jobs[0].hashed.data().target.slot,
1420+
NUM_GROUPS as u64,
1421+
"the single job is the best-scoring candidate, not an arbitrary one"
13841422
);
13851423
}
13861424
}

crates/blockchain/src/lib.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use ethlambda_types::{
1515

1616
use crate::aggregation::{
1717
AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone,
18-
AggregationSession, EARLY_AGGREGATION_WINDOW, EarlyAggregationCheck, PRIOR_WORKER_JOIN_TIMEOUT,
19-
run_aggregation_worker,
18+
AggregationSession, EARLY_AGGREGATION_WINDOW, EarlyAggregationCheck, MAX_AGGREGATION_JOBS,
19+
PRIOR_WORKER_JOIN_TIMEOUT, run_aggregation_worker,
2020
};
2121
use crate::key_manager::ValidatorKeyPair;
2222
use crate::sync_status::SyncStatusTracker;
@@ -460,9 +460,14 @@ impl BlockChainServer {
460460

461461
/// Kick off a committee-signature aggregation session:
462462
/// 1. If a prior session is still running (pathological), warn and join it.
463-
/// 2. Snapshot the aggregation inputs from the store.
463+
/// 2. Snapshot the aggregation inputs from the store, capped at a single job
464+
/// when we propose next slot.
464465
/// 3. Spawn a `spawn_blocking` worker that streams results back as messages.
465466
/// 4. Schedule the `AggregationDeadline` self-message at +`AGGREGATION_DEADLINE`.
467+
///
468+
/// Both entry points land here — the interval-2 tick and the early
469+
/// 2/3-threshold trigger — so the proposer cap applies to whichever one
470+
/// starts the slot's session.
466471
async fn start_aggregation_session(&mut self, slot: u64, ctx: &Context<Self>) {
467472
if let Some(prior) = self.current_aggregation.take() {
468473
prior.cancel.cancel();
@@ -485,7 +490,19 @@ impl BlockChainServer {
485490

486491
coverage::emit_agg_start_new_coverage(&self.store, self.attestation_committee_count);
487492

488-
let Some(snapshot) = aggregation::snapshot_aggregation_inputs(&self.store, slot) else {
493+
// Limit ourselves to a single round of aggregation if we propose next round.
494+
// This buys us time to build the block before the next slot's interval-0 tick.
495+
let next_proposer = self
496+
.get_our_proposer(slot + 1)
497+
.filter(|_| self.sync_status.duties_allowed());
498+
let max_jobs = if next_proposer.is_some() {
499+
1
500+
} else {
501+
MAX_AGGREGATION_JOBS
502+
};
503+
504+
let Some(snapshot) = aggregation::snapshot_aggregation_inputs(&self.store, slot, max_jobs)
505+
else {
489506
// No current-slot gossip sigs — nothing to aggregate this slot.
490507
return;
491508
};

0 commit comments

Comments
 (0)