Skip to content

Commit 82abf6b

Browse files
committed
feat(aggregation): cap aggregation at one job before our proposal
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 contend for the prover — and the build is the one with a hard slot-boundary deadline. Drop the session to a single job whenever one of our validators proposes the next slot, mirroring the propose path's condition (proposer + duties allowed) so a sync-suppressed slot keeps the full budget. The retained job is the best-scoring candidate, so the highest-value coverage survives the cap. Applies to both entry points into start_aggregation_session: the interval-2 tick and the early 2/3-threshold trigger.
1 parent b990e3c commit 82abf6b

2 files changed

Lines changed: 98 additions & 24 deletions

File tree

crates/blockchain/src/aggregation.rs

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
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
18+
//! [`PROPOSER_MAX_AGGREGATION_JOBS`] in the slot before one of our validators
19+
//! proposes.
1820
1921
use std::collections::{HashMap, HashSet};
2022
use std::time::{Duration, Instant, SystemTime};
@@ -176,7 +178,14 @@ impl Message for EarlyAggregationCheck {
176178
/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in
177179
/// [`snapshot_aggregation_inputs`] stops after this many rounds even if
178180
/// scoring candidates remain.
179-
const MAX_AGGREGATION_JOBS: usize = 3;
181+
pub(crate) const MAX_AGGREGATION_JOBS: usize = 3;
182+
183+
/// Job cap for a session running in the slot before one of our validators
184+
/// proposes. The interval-4 build runs its own leanVM proofs for the block it
185+
/// is about to publish; keeping this slot's aggregation to a single job leaves
186+
/// the prover to that build instead of racing it. The one job we do run is the
187+
/// best-scoring candidate, so the highest-value coverage is retained.
188+
pub(crate) const PROPOSER_MAX_AGGREGATION_JOBS: usize = 1;
180189

181190
/// Build a snapshot of everything needed to aggregate. Runs on the actor
182191
/// thread, touches the store, does no heavy cryptography. Returns `None` when
@@ -190,17 +199,22 @@ const MAX_AGGREGATION_JOBS: usize = 3;
190199
/// (`store.iter_gossip_signatures()`) and payload-only groups
191200
/// (`store.new_payload_keys()` not already a gossip candidate, requiring
192201
/// at least two existing proofs to merge).
193-
/// 2. **Greedy loop**, at most [`MAX_AGGREGATION_JOBS`] rounds: each round
202+
/// 2. **Greedy loop**, at most `max_jobs` rounds: each round
194203
/// scores every unselected candidate against the projected state and
195204
/// keeps the lowest ordering key (current-slot before stale, then
196205
/// Finalize > Justify > Build, mirroring the block builder). The winning
197206
/// [`AggregationJob`] is emitted as-is; the projection is updated with its
198207
/// realized coverage.
199208
///
200209
/// Stops early when no remaining candidate scores (converged).
210+
///
211+
/// `max_jobs` is [`MAX_AGGREGATION_JOBS`] for an ordinary session and
212+
/// [`PROPOSER_MAX_AGGREGATION_JOBS`] when the caller is about to build a block
213+
/// at interval 4 (see `BlockChainServer::start_aggregation_session`).
201214
pub fn snapshot_aggregation_inputs(
202215
store: &Store,
203216
current_slot: u64,
217+
max_jobs: usize,
204218
) -> Option<AggregationSnapshot> {
205219
let gossip_groups = store.iter_gossip_signatures();
206220
let new_payload_keys = store.new_payload_keys();
@@ -269,9 +283,8 @@ pub fn snapshot_aggregation_inputs(
269283

270284
let mut projected = block_builder::ProjectedState::from_head_state(&head_state);
271285

272-
let mut jobs: Vec<AggregationJob> =
273-
Vec::with_capacity(MAX_AGGREGATION_JOBS.min(groups_considered));
274-
for _round in 0..MAX_AGGREGATION_JOBS {
286+
let mut jobs: Vec<AggregationJob> = Vec::with_capacity(max_jobs.min(groups_considered));
287+
for _round in 0..max_jobs {
275288
let Some((data_root, score)) = pick_best_candidate(
276289
&candidates,
277290
&projected,
@@ -1189,7 +1202,7 @@ mod tests {
11891202
fn snapshot_returns_none_for_empty_store() {
11901203
let hashes = vec![H256([1u8; 32])];
11911204
let store = new_test_store(make_head_state(0, 4, &hashes));
1192-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1205+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
11931206
}
11941207

11951208
/// A single gossip signature with no other material to merge is dropped
@@ -1218,7 +1231,7 @@ mod tests {
12181231
let hashed = HashedAttestationData::new(att_data);
12191232
store.insert_gossip_signature(hashed, 0, dummy_sig());
12201233

1221-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1234+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
12221235
}
12231236

12241237
/// A group whose target is already justified (here: at or behind the
@@ -1261,7 +1274,7 @@ mod tests {
12611274
store.insert_gossip_signature(hashed, 1, dummy_sig());
12621275

12631276
assert!(
1264-
snapshot_aggregation_inputs(&store, 999).is_none(),
1277+
snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS).is_none(),
12651278
"a group targeting an already-justified slot must never become a job"
12661279
);
12671280
}
@@ -1317,7 +1330,7 @@ mod tests {
13171330
store.insert_gossip_signature(hashed.clone(), 0, dummy_sig());
13181331
store.insert_gossip_signature(hashed, 1, dummy_sig());
13191332

1320-
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT)
1333+
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT, MAX_AGGREGATION_JOBS)
13211334
.expect("a vote for the current head must produce a job (chain view covers the tip)");
13221335
assert_eq!(snapshot.jobs.len(), 1);
13231336
assert_eq!(
@@ -1327,23 +1340,26 @@ mod tests {
13271340
);
13281341
}
13291342

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

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

13451361
for i in 0..NUM_GROUPS {
1346-
let target_slot = i as u64 + 1; // 1..=5, all justifiable (delta <= 5)
1362+
let target_slot = i as u64 + 1;
13471363
let att_data = AttestationData {
13481364
slot: target_slot,
13491365
head: Checkpoint {
@@ -1365,7 +1381,18 @@ mod tests {
13651381
store.insert_gossip_signature(hashed, (2 * i + 1) as u64, dummy_sig());
13661382
}
13671383

1368-
let snapshot = snapshot_aggregation_inputs(&store, 999).expect("should produce jobs");
1384+
store
1385+
}
1386+
1387+
/// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1388+
/// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1389+
/// key, i.e. the top three by `target_slot`.
1390+
#[test]
1391+
fn snapshot_caps_jobs_at_max_aggregation_jobs() {
1392+
let store = store_with_competing_build_tier_groups();
1393+
1394+
let snapshot = snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS)
1395+
.expect("should produce jobs");
13691396
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
13701397
assert_eq!(snapshot.jobs.len(), MAX_AGGREGATION_JOBS);
13711398

@@ -1380,4 +1407,24 @@ mod tests {
13801407
"the three highest target_slot groups win the new_voters tie"
13811408
);
13821409
}
1410+
1411+
/// The proposer cap yields exactly one job from the same pool, and it is
1412+
/// the single best-scoring candidate — the one the uncapped selection also
1413+
/// picks first (highest `target_slot`). Every other candidate is still
1414+
/// counted in `groups_considered`, so the cap is visibly a selection bound
1415+
/// rather than a narrower candidate pool.
1416+
#[test]
1417+
fn snapshot_caps_jobs_at_one_for_proposer() {
1418+
let store = store_with_competing_build_tier_groups();
1419+
1420+
let snapshot = snapshot_aggregation_inputs(&store, 999, PROPOSER_MAX_AGGREGATION_JOBS)
1421+
.expect("should produce a job");
1422+
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
1423+
assert_eq!(snapshot.jobs.len(), PROPOSER_MAX_AGGREGATION_JOBS);
1424+
assert_eq!(
1425+
snapshot.jobs[0].hashed.data().target.slot,
1426+
NUM_GROUPS as u64,
1427+
"the single job is the best-scoring candidate, not an arbitrary one"
1428+
);
1429+
}
13831430
}

crates/blockchain/src/lib.rs

Lines changed: 31 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, PROPOSER_MAX_AGGREGATION_JOBS, 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 (see [`PROPOSER_MAX_AGGREGATION_JOBS`]).
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,29 @@ 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+
// Throttle to a single job when interval 4 of this slot will build the
494+
// next slot's block: that build runs its own leanVM proofs, and the two
495+
// otherwise contend for the prover. Mirror the propose path's condition
496+
// (`SlotInterval::EndOfSlot`) so a slot where duties are suppressed
497+
// keeps the full job budget.
498+
let next_proposer = self
499+
.get_our_proposer(slot + 1)
500+
.filter(|_| self.sync_status.duties_allowed());
501+
let max_jobs = match next_proposer {
502+
Some(validator_id) => {
503+
info!(
504+
%slot,
505+
next_slot_proposer = validator_id,
506+
max_jobs = PROPOSER_MAX_AGGREGATION_JOBS,
507+
"Throttling aggregation ahead of our block proposal"
508+
);
509+
PROPOSER_MAX_AGGREGATION_JOBS
510+
}
511+
None => MAX_AGGREGATION_JOBS,
512+
};
513+
514+
let Some(snapshot) = aggregation::snapshot_aggregation_inputs(&self.store, slot, max_jobs)
515+
else {
489516
// No current-slot gossip sigs — nothing to aggregate this slot.
490517
return;
491518
};

0 commit comments

Comments
 (0)