Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bd76661
feat(aggregation): add subnet reach and window primitives
MegaRedHand Sep 9, 2026
a7c75fb
refactor(aggregation): tighten the subnet window primitives
MegaRedHand Sep 9, 2026
c0ca3e2
feat(aggregation): score child selection through a subnet window
MegaRedHand Sep 9, 2026
c49f5e0
docs(aggregation): correct why child selection covers out-of-window ids
MegaRedHand Sep 9, 2026
234a549
feat(aggregation): add the redundancy-skipping width rotation
MegaRedHand Sep 9, 2026
9ed9ab4
refactor(aggregation): floor the rotation width and document its edges
MegaRedHand Sep 9, 2026
0018e49
feat(aggregation): derive a per-candidate subnet window
MegaRedHand Sep 9, 2026
02a738d
fix(aggregation): derive the duty subnet from the subscription set
MegaRedHand Sep 9, 2026
68b8f4b
feat(blockchain): carry the aggregation duty subnet on the actor
MegaRedHand Sep 9, 2026
f1a9254
fix(aggregation): reduce the duty subnet before the width rotation
MegaRedHand Sep 9, 2026
05ab79b
feat(cli): add --skip-redundant-aggregation and duty-subnet resolution
MegaRedHand Sep 9, 2026
0641ddb
docs(cli): document the duty subnet and log how it resolved
MegaRedHand Sep 9, 2026
57b3233
docs: document the aggregation window metrics
MegaRedHand Sep 9, 2026
6041332
test(aggregation): pin the four-aggregator reduction tree
MegaRedHand Sep 9, 2026
e5825f0
docs: shorten the aggregation metric rows and correct the pinned reading
MegaRedHand Sep 9, 2026
5653ae7
test(aggregation): make the duty-subnet reduction test discriminate
MegaRedHand Sep 9, 2026
0f41273
fix(aggregation): fall back to the full window when a window declines…
MegaRedHand Sep 9, 2026
ee8ea8d
feat(cli): warn when the redundancy-skipping rotation cannot rotate
MegaRedHand Sep 9, 2026
b4ef8fa
docs(aggregation): describe the window's real cadence
MegaRedHand Sep 9, 2026
1782908
docs: describe subnet-windowed aggregation in the architecture guide
MegaRedHand Sep 9, 2026
e58eb20
feat(aggregation): anchor the subnet window on the duty subnet's best…
MegaRedHand Sep 10, 2026
aab43b1
Merge branch 'main' into feat/subnet-windowed-aggregation
MegaRedHand Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions bin/ethlambda/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,35 @@ pub(crate) struct NodeOptions {
pub(crate) attestation_committee_count: Option<u64>,
/// Subnet IDs this aggregator should subscribe to (comma-separated).
/// Requires --is-aggregator. Defaults to the subnets of the node's validators.
///
/// The first ID is also this node's aggregation duty subnet: where its
/// aggregation window starts, and what --skip-redundant-aggregation
/// rotates ownership over. Order matters, so give co-located aggregators
/// different first IDs. Unset, the duty subnet falls back to the lowest
/// subscribed subnet, which is the same value on every node whose
/// validators span all subnets.
#[arg(long, value_delimiter = ',', requires = "is_aggregator")]
pub(crate) aggregate_subnet_ids: Option<Vec<u64>>,
/// Sit out aggregation candidates whose level another duty subnet owns
/// this slot. Requires --is-aggregator.
///
/// By default every aggregator merges proofs for a window of subnets
/// starting at its duty subnet, and windows belonging to neighbouring duty
/// subnets overlap, so some prover work is duplicated. With this flag an
/// aggregator skips a candidate whose width it does not own in the current
/// slot and spends that job on the next-best attestation data instead. The
/// owner rotates with the slot, so no node is permanently the one sitting
/// out, and the narrowest width is owned by everyone, so a candidate whose
/// pool holds nothing on this node's subnet, which is the raw-signature
/// case, is never skipped.
///
/// Worth enabling when leanVM prover CPU is the bottleneck on co-located
/// aggregators. The cost is that a level in a given slot has few
/// producers, so a node that is down or late forfeits that slot's merge at
/// its level; the narrower levels still run and the next slot rotates to a
/// different owner.
#[arg(long, default_value = "false", requires = "is_aggregator")]
pub(crate) skip_redundant_aggregation: bool,
/// Directory for RocksDB storage
#[arg(long, default_value = "./data")]
pub(crate) data_dir: PathBuf,
Expand Down
78 changes: 77 additions & 1 deletion bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0";

use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
sync::Arc,
Expand Down Expand Up @@ -279,12 +279,34 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {
// receiver-count guard in `emit` makes every emission a no-op.
let events = EventBus::default();

let aggregation_duty_subnet = resolve_aggregation_duty_subnet(
options.aggregate_subnet_ids.as_deref(),
&subscribed_subnets,
);
info!(
aggregation_duty_subnet,
assigned = options.aggregate_subnet_ids.is_some(),
"Resolved aggregation duty subnet"
);
if options.skip_redundant_aggregation && options.aggregate_subnet_ids.is_none() {
warn!(
aggregation_duty_subnet,
"--skip-redundant-aggregation is set but the duty subnet was derived, not assigned: \
every co-located aggregator whose validators span all subnets derives the same duty \
subnet, so they will sit out in lockstep in the same slot instead of taking turns, \
and the widest level gets no producer at all in most slots. Give each aggregator a \
distinct first --aggregate-subnet-ids value to fix this."
);
}

let blockchain_config = BlockChainConfig {
aggregator: aggregator.clone(),
sync_status_controller: sync_status.clone(),
attestation_committee_count,
gate_duties: !options.disable_duty_sync_gate,
subscribed_subnets: subscribed_subnets.clone(),
aggregation_duty_subnet,
skip_redundant_aggregation: options.skip_redundant_aggregation,
proposer_config: ProposerConfig {
enable_proposer_aggregation: options.enable_proposer_aggregation,
max_attestations_per_block: options.max_attestations_per_block,
Expand Down Expand Up @@ -818,13 +840,67 @@ async fn fetch_initial_state(
Ok(store)
}

/// The subnet this node is responsible for when scoring recursive aggregation.
///
/// Operators assign it explicitly via --aggregate-subnet-ids so co-located
/// aggregators land on different subnets and merge different proofs. Without
/// an assignment, fall back to the lowest subnet this node already listens
/// on: `min` rather than an arbitrary pick because `HashSet` iteration order
/// is not stable and the duty subnet must be.
fn resolve_aggregation_duty_subnet(
assigned_subnet_ids: Option<&[u64]>,
subscribed_subnets: &HashSet<u64>,
) -> u64 {
assigned_subnet_ids
.and_then(|ids| ids.first().copied())
.or_else(|| subscribed_subnets.iter().copied().min())
.unwrap_or(0)
}

#[cfg(test)]
mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT;
use ethlambda_types::genesis::GenesisValidatorEntry;

/// The duty subnet is the first explicitly assigned subnet, so an operator
/// can place co-located aggregators on different subnets deliberately.
#[test]
fn duty_subnet_prefers_the_first_assigned_id() {
let subscribed = HashSet::from([0u64, 1, 2, 3]);
assert_eq!(
resolve_aggregation_duty_subnet(Some(&[3, 1]), &subscribed),
3,
"the first assigned id wins, not the lowest"
);
}

/// With no assignment, the lowest subscribed subnet is used, which is
/// stable across restarts unlike an arbitrary pick from the set.
#[test]
fn duty_subnet_falls_back_to_the_lowest_subscribed() {
let subscribed = HashSet::from([5u64, 2]);
assert_eq!(resolve_aggregation_duty_subnet(None, &subscribed), 2);
}

/// A node with nothing assigned and nothing subscribed still needs an
/// answer; subnet 0 always exists.
#[test]
fn duty_subnet_defaults_to_zero_with_nothing_to_go_on() {
assert_eq!(resolve_aggregation_duty_subnet(None, &HashSet::new()), 0);
}

/// An empty list is no assignment at all, so the subscription fallback
/// still applies rather than the last-resort zero.
#[test]
fn duty_subnet_treats_an_empty_assignment_as_no_assignment() {
assert_eq!(
resolve_aggregation_duty_subnet(Some(&[]), &HashSet::from([4u64])),
4
);
}

/// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
/// where networks share a non-default committee count.
const VC_WITH_COMMITTEE_COUNT: &str = r#"
Expand Down
Loading
Loading