Skip to content

Commit afe5e67

Browse files
authored
Merge branch 'main' into build/leanvm-track-main
2 parents e2a66a8 + 91eb0a1 commit afe5e67

29 files changed

Lines changed: 6920 additions & 30 deletions

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323

2424
- [ ] Ran `make fmt` — clean
2525
- [ ] Ran `make lint` (clippy with `-D warnings`) — clean
26-
- [ ] Ran `cargo test --workspace --release` — all passing
26+
- [ ] Ran `make test` (`cargo test --workspace --profile release-fast`) — all passing

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ jobs:
3030
components: rustfmt, clippy
3131

3232
- name: Setup cache
33+
# Tools under tooling/ are separate Cargo workspaces with their own
34+
# target dir and Cargo.lock, so they need listing explicitly or their
35+
# builds are neither cached nor reflected in the cache key.
3336
uses: Swatinem/rust-cache@v2
37+
with:
38+
workspaces: |
39+
.
40+
tooling/event-monitor
3441
3542
- name: Check formatting
3643
run: cargo fmt --all -- --check
@@ -41,6 +48,24 @@ jobs:
4148
- name: Clippy
4249
run: cargo clippy --workspace --all-targets -- -D warnings
4350

51+
# tooling/event-monitor declares its own [workspace] table, so every step
52+
# above stops at the root workspace members and never reaches it. Its
53+
# tests run in this job rather than in `test` because clippy has already
54+
# compiled the test targets, and because they need none of that job's
55+
# leanSpec fixtures.
56+
# `--locked` so the committed Cargo.lock is actually enforced: without it
57+
# cargo silently resolves and rewrites the lockfile in CI, and a stale or
58+
# missing entry never fails the build.
59+
- name: Lint tooling
60+
working-directory: tooling/event-monitor
61+
run: |
62+
cargo fmt --all -- --check
63+
cargo clippy --locked --all-targets -- -D warnings
64+
65+
- name: Test tooling
66+
working-directory: tooling/event-monitor
67+
run: cargo test --locked
68+
4469
test:
4570
name: Test
4671
runs-on: ubuntu-latest

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,11 +316,17 @@ GENESIS_VALIDATORS:
316316

317317
### Running Tests
318318
```bash
319-
cargo test --workspace --release # All workspace tests
319+
cargo test --workspace --profile release-fast # All workspace tests
320320
cargo test -p ethlambda-blockchain --test forkchoice_spectests
321321
cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads=1 # Sequential
322322
```
323323

324+
Tests run under `release-fast`: release-grade opt-level (needed to avoid stack
325+
overflows in signature verification/aggregation) but no LTO, parallel codegen,
326+
incremental, and line-tables-only debuginfo, so rebuilds are much faster than
327+
`--release`. Artifacts land in `target/release-fast/`, separate from
328+
`cargo build --release`.
329+
324330
## Common Gotchas
325331

326332
### Aggregator Flag Required for Finalization

Cargo.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,32 @@ repository = "https://github.com/lambdaclass/ethlambda"
2626
rust-version = "1.97.1"
2727
version = "0.1.0"
2828

29+
# Cross-crate inlining matters here: the hot paths (SSZ encode/decode, hashing,
30+
# fork choice traversal) are split across workspace crates and their
31+
# dependencies, so keeping every crate in its own codegen unit leaves inlining
32+
# opportunities on the table. Fat LTO plus a single codegen unit trades build
33+
# time for that.
34+
#
35+
# Fat rather than thin: on an 8-node devnet, fat cut mean state transition time
36+
# a further 2.1% over thin (every fat node beat every thin node) at no measurable
37+
# build-time or image-size cost. ethrex uses thin here; we diverge deliberately.
38+
[profile.release]
39+
opt-level = 3
40+
lto = "fat"
41+
codegen-units = 1
42+
43+
# Test profile (see the `test` target in the Makefile). Tests need release-grade
44+
# opt-level: signature verification/aggregation stack-overflows without it. They
45+
# do not need a whole-program-optimized binary, so this drops LTO, restores
46+
# parallel codegen, and keeps line tables for backtraces, which makes test
47+
# rebuilds finish in a fraction of the time of a `release` build.
48+
[profile.release-fast]
49+
inherits = "release"
50+
lto = false
51+
codegen-units = 16
52+
debug = "line-tables-only"
53+
incremental = true
54+
2955
[workspace.dependencies]
3056
ethlambda-blockchain = { path = "crates/blockchain" }
3157
ethlambda-fork-choice = { path = "crates/blockchain/fork_choice" }

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ lint: ## 🔍 Run clippy on all workspace crates
1010
cargo clippy --workspace --all-targets -- -D warnings
1111

1212
test: leanSpec/fixtures ## 🧪 Run all tests
13-
# Tests need to be run on release to avoid stack overflows during signature verification/aggregation
14-
cargo test --workspace --release
13+
# release-fast: release-grade opt-level to avoid stack overflows during
14+
# signature verification/aggregation, without paying for LTO on every rebuild
15+
cargo test --workspace --profile release-fast
1516

1617
GIT_COMMIT=$(shell git rev-parse HEAD)
1718
GIT_BRANCH=$(shell git rev-parse --abbrev-ref HEAD)

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,
@@ -1183,7 +1188,7 @@ mod tests {
11831188
fn snapshot_returns_none_for_empty_store() {
11841189
let hashes = vec![H256([1u8; 32])];
11851190
let store = new_test_store(make_head_state(0, 4, &hashes));
1186-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1191+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
11871192
}
11881193

11891194
/// A single gossip signature with no other material to merge is dropped
@@ -1212,7 +1217,7 @@ mod tests {
12121217
let hashed = HashedAttestationData::new(att_data);
12131218
store.insert_gossip_signature(hashed, 0, dummy_sig());
12141219

1215-
assert!(snapshot_aggregation_inputs(&store, 0).is_none());
1220+
assert!(snapshot_aggregation_inputs(&store, 0, MAX_AGGREGATION_JOBS).is_none());
12161221
}
12171222

12181223
/// A group whose target is already justified (here: at or behind the
@@ -1255,7 +1260,7 @@ mod tests {
12551260
store.insert_gossip_signature(hashed, 1, dummy_sig());
12561261

12571262
assert!(
1258-
snapshot_aggregation_inputs(&store, 999).is_none(),
1263+
snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS).is_none(),
12591264
"a group targeting an already-justified slot must never become a job"
12601265
);
12611266
}
@@ -1311,7 +1316,7 @@ mod tests {
13111316
store.insert_gossip_signature(hashed.clone(), 0, dummy_sig());
13121317
store.insert_gossip_signature(hashed, 1, dummy_sig());
13131318

1314-
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT)
1319+
let snapshot = snapshot_aggregation_inputs(&store, HEAD_SLOT, MAX_AGGREGATION_JOBS)
13151320
.expect("a vote for the current head must produce a job (chain view covers the tip)");
13161321
assert_eq!(snapshot.jobs.len(), 1);
13171322
assert_eq!(
@@ -1321,23 +1326,26 @@ mod tests {
13211326
);
13221327
}
13231328

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

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

13391347
for i in 0..NUM_GROUPS {
1340-
let target_slot = i as u64 + 1; // 1..=5, all justifiable (delta <= 5)
1348+
let target_slot = i as u64 + 1;
13411349
let att_data = AttestationData {
13421350
slot: target_slot,
13431351
head: Checkpoint {
@@ -1359,7 +1367,18 @@ mod tests {
13591367
store.insert_gossip_signature(hashed, (2 * i + 1) as u64, dummy_sig());
13601368
}
13611369

1362-
let snapshot = snapshot_aggregation_inputs(&store, 999).expect("should produce jobs");
1370+
store
1371+
}
1372+
1373+
/// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1374+
/// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1375+
/// key, i.e. the top two by `target_slot`.
1376+
#[test]
1377+
fn snapshot_caps_jobs_at_max_aggregation_jobs() {
1378+
let store = store_with_competing_build_tier_groups();
1379+
1380+
let snapshot = snapshot_aggregation_inputs(&store, 999, MAX_AGGREGATION_JOBS)
1381+
.expect("should produce jobs");
13631382
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
13641383
assert_eq!(snapshot.jobs.len(), MAX_AGGREGATION_JOBS);
13651384

@@ -1370,8 +1389,27 @@ mod tests {
13701389
.collect();
13711390
assert_eq!(
13721391
selected_targets,
1373-
HashSet::from([3, 4, 5]),
1374-
"the three highest target_slot groups win the new_voters tie"
1392+
HashSet::from([4, 5]),
1393+
"the two highest target_slot groups win the new_voters tie"
1394+
);
1395+
}
1396+
1397+
/// The proposer cap (`max_jobs = 1`) yields exactly one job from the same
1398+
/// pool, and it is the single best-scoring candidate — the one the uncapped
1399+
/// selection also picks first (highest `target_slot`). Every other candidate
1400+
/// is still counted in `groups_considered`, so the cap is visibly a
1401+
/// selection bound rather than a narrower candidate pool.
1402+
#[test]
1403+
fn snapshot_caps_jobs_at_one_for_proposer() {
1404+
let store = store_with_competing_build_tier_groups();
1405+
1406+
let snapshot = snapshot_aggregation_inputs(&store, 999, 1).expect("should produce a job");
1407+
assert_eq!(snapshot.groups_considered, NUM_GROUPS);
1408+
assert_eq!(snapshot.jobs.len(), 1);
1409+
assert_eq!(
1410+
snapshot.jobs[0].hashed.data().target.slot,
1411+
NUM_GROUPS as u64,
1412+
"the single job is the best-scoring candidate, not an arbitrary one"
13751413
);
13761414
}
13771415
}

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
};

tooling/event-monitor/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
/config.toml

0 commit comments

Comments
 (0)