Skip to main content

optirs_core/distributed/
elastic.rs

1// Elastic distributed training with a dynamic world size (pure CPU simulation).
2//
3// This module is a pure-Rust, CPU-only *simulation* of elastic data-parallel
4// training. No real networking or devices are involved: workers, the rendezvous
5// barrier and the shard placement are all logical. It is nevertheless a faithful
6// reference for the three hard problems an elastic runtime (e.g. TorchElastic /
7// `torchrun --max-restarts`, Horovod Elastic) must solve when the set of workers
8// grows and shrinks during a run:
9//
10// * **Rendezvous with version epochs.** [`RendezvousState`] holds the agreed
11//   membership (a sorted set of worker ranks), the agreed `world_size` and a
12//   monotonically increasing `version` (the rendezvous *epoch*). Every accepted
13//   membership change bumps the version by exactly one, and every worker that
14//   observes a given version sees the *same* membership — this is what lets the
15//   data-parallel group re-form consistently after a join or a leave.
16//
17// * **Join / leave state machine.** [`ElasticCoordinator`] consumes a stream of
18//   [`MembershipEvent`]s and drives each worker through the lifecycle
19//   `Pending -> Active -> Leaving -> Removed` (a removed worker may rejoin,
20//   re-entering at `Pending`). Illegal transitions — joining an already-active
21//   worker, or leaving an unknown / already-removed worker — are rejected with an
22//   honest `Err` and leave the rendezvous untouched (no version bump).
23//
24// * **Consistent, balanced re-sharding.** Two stateless placement schemes map a
25//   dataset of `D` shards onto the current membership of size `N`:
26//     - [`block_partition`] (the default, returned by
27//       [`ElasticCoordinator::shard_assignment`]) splits `0..D` into `N`
28//       contiguous [`ShardRange`]s whose sizes differ by at most one. It is a pure
29//       function of the *sorted* membership and `D`, so every worker computes the
30//       identical assignment for a given `(version, world_size, D)`.
31//     - [`hashed_assignment`] (Highest-Random-Weight / rendezvous hashing, exposed
32//       via [`ElasticCoordinator::hashed_shard_assignment`]) is the same kind of
33//       deterministic, exactly-balanced map but, because each shard independently
34//       prefers the worker that maximises a mixing hash, a single join or leave
35//       relocates far fewer shards than the contiguous block scheme. This is the
36//       classic minimal-movement property of rendezvous hashing.
37//
38// * **Linear scaling rule.** Following Goyal et al. (2017, "Accurate, Large
39//   Minibatch SGD"), the effective learning rate is scaled linearly with the
40//   world size, `lr = base_lr * world_size / reference_world_size`, and the
41//   gradient-averaging divisor tracks the live world size. An optional gradual
42//   warmup ramps the learning rate from `base_lr` up to the scaled target over a
43//   configurable number of steps after a resize.
44//
45// Everything here is deterministic and uses only the standard library plus `f64`;
46// no randomness is drawn (the hash is a fixed integer mix), so results are fully
47// reproducible.
48
49use crate::error::{OptimError, Result};
50use std::collections::BTreeMap;
51
52/// A membership change requested against the elastic group.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum MembershipEvent {
55    /// A worker with the given rank requests to join the group.
56    Join(usize),
57    /// A worker with the given rank requests to leave the group.
58    Leave(usize),
59}
60
61impl MembershipEvent {
62    /// The worker rank this event refers to.
63    pub fn worker_id(self) -> usize {
64        match self {
65            MembershipEvent::Join(worker_id) | MembershipEvent::Leave(worker_id) => worker_id,
66        }
67    }
68
69    /// Whether this is a join event.
70    pub fn is_join(self) -> bool {
71        matches!(self, MembershipEvent::Join(_))
72    }
73}
74
75/// Lifecycle state of a single worker in the elastic group.
76///
77/// The legal transitions form the chain `Pending -> Active -> Leaving -> Removed`,
78/// with one extra edge `Removed -> Pending` so a worker may rejoin after leaving.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum WorkerState {
81    /// The worker has requested to join and is waiting at the rendezvous barrier.
82    Pending,
83    /// The worker is an active member of the current rendezvous.
84    Active,
85    /// The worker has requested to leave and is draining out of the group.
86    Leaving,
87    /// The worker has fully left the group (and may later rejoin).
88    Removed,
89}
90
91impl WorkerState {
92    /// Human-readable name of the lifecycle state.
93    pub fn name(self) -> &'static str {
94        match self {
95            WorkerState::Pending => "Pending",
96            WorkerState::Active => "Active",
97            WorkerState::Leaving => "Leaving",
98            WorkerState::Removed => "Removed",
99        }
100    }
101
102    /// Whether `from -> to` is a legal worker-lifecycle transition.
103    ///
104    /// `from == None` denotes a worker the coordinator has never seen.
105    pub fn is_legal_transition(from: Option<WorkerState>, to: WorkerState) -> bool {
106        matches!(
107            (from, to),
108            (None, WorkerState::Pending)
109                | (Some(WorkerState::Removed), WorkerState::Pending)
110                | (Some(WorkerState::Pending), WorkerState::Active)
111                | (Some(WorkerState::Active), WorkerState::Leaving)
112                | (Some(WorkerState::Leaving), WorkerState::Removed)
113        )
114    }
115}
116
117/// Human-readable description of an optional worker state, for error messages.
118fn describe_state(state: Option<WorkerState>) -> &'static str {
119    match state {
120        Some(state) => state.name(),
121        None => "absent",
122    }
123}
124
125/// A contiguous half-open range `[start, end)` of shard indices owned by a worker.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct ShardRange {
128    /// Rank of the worker that owns this range.
129    pub worker_id: usize,
130    /// First shard index in the range (inclusive).
131    pub start: usize,
132    /// One past the last shard index in the range (exclusive).
133    pub end: usize,
134}
135
136impl ShardRange {
137    /// Number of shards in this range.
138    pub fn len(&self) -> usize {
139        self.end - self.start
140    }
141
142    /// Whether this range contains no shards.
143    pub fn is_empty(&self) -> bool {
144        self.start >= self.end
145    }
146
147    /// Whether the given shard index falls inside this range.
148    pub fn contains(&self, shard: usize) -> bool {
149        shard >= self.start && shard < self.end
150    }
151}
152
153/// The (generally non-contiguous) set of shards a worker owns under a hashed
154/// (rendezvous / HRW) assignment.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct WorkerShards {
157    /// Rank of the worker that owns these shards.
158    pub worker_id: usize,
159    /// Sorted, de-duplicated shard indices owned by the worker.
160    pub shards: Vec<usize>,
161}
162
163impl WorkerShards {
164    /// Number of shards owned by the worker.
165    pub fn len(&self) -> usize {
166        self.shards.len()
167    }
168
169    /// Whether the worker owns no shards.
170    pub fn is_empty(&self) -> bool {
171        self.shards.is_empty()
172    }
173}
174
175/// The agreed rendezvous view shared by every worker at a given epoch.
176///
177/// Holds the canonical, sorted membership, the agreed world size and a
178/// monotonically increasing version (epoch) that bumps on every membership
179/// change. Two coordinators that have accepted the same multiset of events end up
180/// with identical membership and world size (the version reflects how many
181/// changes were accepted).
182#[derive(Debug, Clone, PartialEq, Eq, Default)]
183pub struct RendezvousState {
184    version: u64,
185    members: Vec<usize>,
186    world_size: usize,
187}
188
189impl RendezvousState {
190    /// Create the initial, empty rendezvous (version 0, no members).
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// The current rendezvous version (epoch).
196    pub fn version(&self) -> u64 {
197        self.version
198    }
199
200    /// The current sorted membership (worker ranks).
201    pub fn members(&self) -> &[usize] {
202        &self.members
203    }
204
205    /// The current world size (`== members().len()`).
206    pub fn world_size(&self) -> usize {
207        self.world_size
208    }
209
210    /// Whether the given worker rank is currently a member.
211    pub fn contains(&self, worker_id: usize) -> bool {
212        self.members.binary_search(&worker_id).is_ok()
213    }
214}
215
216/// Configuration of an elastic training run.
217#[derive(Debug, Clone, PartialEq)]
218pub struct ElasticConfig {
219    /// Base (reference) learning rate, valid at `reference_world_size` workers.
220    pub base_learning_rate: f64,
221    /// World size the `base_learning_rate` was tuned for (the linear-scaling
222    /// reference). Must be at least one.
223    pub reference_world_size: usize,
224    /// Number of dataset shards `D` to distribute across the workers.
225    pub dataset_shards: usize,
226    /// Minimum allowed world size: a leave that would drop below this is rejected.
227    pub min_world_size: usize,
228    /// Maximum allowed world size: a join that would rise above this is rejected.
229    pub max_world_size: usize,
230    /// Number of gradual-warmup steps applied to the learning rate after a resize
231    /// (`0` disables warmup).
232    pub warmup_steps: usize,
233}
234
235impl ElasticConfig {
236    /// Create and validate an elastic configuration.
237    ///
238    /// # Errors
239    /// Returns an error when `base_learning_rate` is non-finite or non-positive,
240    /// `reference_world_size`, `dataset_shards` or `min_world_size` are zero, or
241    /// `max_world_size < min_world_size`.
242    pub fn new(
243        base_learning_rate: f64,
244        reference_world_size: usize,
245        dataset_shards: usize,
246        min_world_size: usize,
247        max_world_size: usize,
248        warmup_steps: usize,
249    ) -> Result<Self> {
250        let config = Self {
251            base_learning_rate,
252            reference_world_size,
253            dataset_shards,
254            min_world_size,
255            max_world_size,
256            warmup_steps,
257        };
258        config.validate()?;
259        Ok(config)
260    }
261
262    /// Validate the configuration invariants.
263    ///
264    /// # Errors
265    /// See [`ElasticConfig::new`].
266    pub fn validate(&self) -> Result<()> {
267        if !self.base_learning_rate.is_finite() || self.base_learning_rate <= 0.0 {
268            return Err(OptimError::InvalidConfig(format!(
269                "base_learning_rate {} must be finite and positive",
270                self.base_learning_rate
271            )));
272        }
273        if self.reference_world_size == 0 {
274            return Err(OptimError::InvalidConfig(
275                "reference_world_size must be at least 1".to_string(),
276            ));
277        }
278        if self.dataset_shards == 0 {
279            return Err(OptimError::InvalidConfig(
280                "dataset_shards must be at least 1".to_string(),
281            ));
282        }
283        if self.min_world_size == 0 {
284            return Err(OptimError::InvalidConfig(
285                "min_world_size must be at least 1".to_string(),
286            ));
287        }
288        if self.max_world_size < self.min_world_size {
289            return Err(OptimError::InvalidConfig(format!(
290                "max_world_size {} must be >= min_world_size {}",
291                self.max_world_size, self.min_world_size
292            )));
293        }
294        Ok(())
295    }
296}
297
298impl Default for ElasticConfig {
299    fn default() -> Self {
300        Self {
301            base_learning_rate: 0.1,
302            reference_world_size: 1,
303            dataset_shards: 1,
304            min_world_size: 1,
305            max_world_size: usize::MAX,
306            warmup_steps: 0,
307        }
308    }
309}
310
311/// A point-in-time snapshot of the elastic group after an accepted event.
312#[derive(Debug, Clone, PartialEq)]
313pub struct EpochSnapshot {
314    /// Rendezvous version (epoch) after the event.
315    pub version: u64,
316    /// World size after the event.
317    pub world_size: usize,
318    /// Sorted membership after the event.
319    pub members: Vec<usize>,
320    /// Balanced contiguous shard assignment for this membership.
321    pub shard_assignment: Vec<ShardRange>,
322    /// Linear-scaling-rule learning rate for this world size.
323    pub scaled_lr: f64,
324    /// Gradient-averaging factor (`1 / world_size`) for this world size.
325    pub averaging_factor: f64,
326}
327
328/// Split the shard index space `0..dataset_shards` into one contiguous
329/// [`ShardRange`] per member, balanced so the sizes differ by at most one.
330///
331/// `members` must be sorted ascending (the coordinator maintains this invariant).
332/// The first `dataset_shards % members.len()` members each receive one extra
333/// shard. The result is a pure function of `(members, dataset_shards)`, so it is
334/// identical on every worker that shares the same rendezvous view. When there are
335/// more workers than shards, the surplus workers receive empty ranges.
336pub fn block_partition(members: &[usize], dataset_shards: usize) -> Vec<ShardRange> {
337    let num_workers = members.len();
338    if num_workers == 0 {
339        return Vec::new();
340    }
341    let base = dataset_shards / num_workers;
342    let remainder = dataset_shards % num_workers;
343
344    let mut ranges = Vec::with_capacity(num_workers);
345    let mut start = 0usize;
346    for (index, &worker_id) in members.iter().enumerate() {
347        let size = base + usize::from(index < remainder);
348        let end = start + size;
349        ranges.push(ShardRange {
350            worker_id,
351            start,
352            end,
353        });
354        start = end;
355    }
356    ranges
357}
358
359/// SplitMix64 finalizer: a fast, well-distributed, fully deterministic 64-bit
360/// integer mix. Used to derive rendezvous-hash weights without any randomness.
361#[inline]
362fn splitmix64(seed: u64) -> u64 {
363    let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
364    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
365    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
366    z ^ (z >> 31)
367}
368
369/// Highest-Random-Weight (rendezvous) hash of a `(worker, shard)` pair.
370///
371/// A shard prefers the worker that maximises this weight; ties (astronomically
372/// rare) are broken by smaller worker rank in the caller, keeping the map a pure
373/// function of `(members, dataset_shards)`.
374#[inline]
375fn hrw_weight(worker_id: usize, shard: usize) -> u64 {
376    let worker_hash = splitmix64(worker_id as u64);
377    let shard_hash = splitmix64(shard as u64);
378    splitmix64(worker_hash ^ shard_hash.rotate_left(32))
379}
380
381/// Per-member shard capacities for a balanced (`<= 1` spread) assignment.
382///
383/// The first `dataset_shards % num_workers` members receive `base + 1`, the rest
384/// receive `base`; the capacities therefore sum to exactly `dataset_shards`.
385fn balanced_capacities(num_workers: usize, dataset_shards: usize) -> Vec<usize> {
386    let base = dataset_shards / num_workers;
387    let remainder = dataset_shards % num_workers;
388    (0..num_workers)
389        .map(|index| base + usize::from(index < remainder))
390        .collect()
391}
392
393/// Assign `0..dataset_shards` to `members` by capacity-bounded rendezvous (HRW)
394/// hashing: each shard is placed on the highest-weight member that still has
395/// spare capacity, with capacities chosen so worker loads differ by at most one.
396///
397/// `members` must be sorted ascending. The result is deterministic and exactly
398/// balanced, and — because shard ownership is decided independently per shard by
399/// a fixed hash — a single membership change relocates far fewer shards than the
400/// contiguous [`block_partition`] scheme (the minimal-movement property of
401/// rendezvous hashing). Returns one [`WorkerShards`] per member, in membership
402/// order, each with its owned shard indices in ascending order.
403pub fn hashed_assignment(members: &[usize], dataset_shards: usize) -> Vec<WorkerShards> {
404    let num_workers = members.len();
405    if num_workers == 0 {
406        return Vec::new();
407    }
408
409    let mut remaining = balanced_capacities(num_workers, dataset_shards);
410    let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); num_workers];
411
412    for shard in 0..dataset_shards {
413        // Seed the search with the first member that still has spare capacity.
414        // At least one always exists because the remaining capacities sum to
415        // `dataset_shards - shard > 0`.
416        let mut best_index = 0usize;
417        while remaining[best_index] == 0 {
418            best_index += 1;
419        }
420        let mut best_weight = hrw_weight(members[best_index], shard);
421
422        for index in (best_index + 1)..num_workers {
423            if remaining[index] == 0 {
424                continue;
425            }
426            let weight = hrw_weight(members[index], shard);
427            let prefer = match weight.cmp(&best_weight) {
428                std::cmp::Ordering::Greater => true,
429                std::cmp::Ordering::Less => false,
430                std::cmp::Ordering::Equal => members[index] < members[best_index],
431            };
432            if prefer {
433                best_weight = weight;
434                best_index = index;
435            }
436        }
437
438        remaining[best_index] -= 1;
439        buckets[best_index].push(shard);
440    }
441
442    members
443        .iter()
444        .zip(buckets)
445        .map(|(&worker_id, shards)| WorkerShards { worker_id, shards })
446        .collect()
447}
448
449/// Drives an elastic training group: a join/leave state machine over a versioned
450/// rendezvous, with balanced re-sharding and linear learning-rate scaling.
451#[derive(Debug, Clone)]
452pub struct ElasticCoordinator {
453    config: ElasticConfig,
454    rendezvous: RendezvousState,
455    worker_states: BTreeMap<usize, WorkerState>,
456}
457
458impl ElasticCoordinator {
459    /// Create a coordinator from a configuration (which is validated).
460    ///
461    /// The initial rendezvous is empty: version 0, no members, world size 0.
462    ///
463    /// # Errors
464    /// Propagates [`ElasticConfig::validate`] errors.
465    pub fn new(config: ElasticConfig) -> Result<Self> {
466        config.validate()?;
467        Ok(Self {
468            config,
469            rendezvous: RendezvousState::new(),
470            worker_states: BTreeMap::new(),
471        })
472    }
473
474    /// Borrow the configuration.
475    pub fn config(&self) -> &ElasticConfig {
476        &self.config
477    }
478
479    /// Borrow the current rendezvous view.
480    pub fn rendezvous(&self) -> &RendezvousState {
481        &self.rendezvous
482    }
483
484    /// Current rendezvous version (epoch).
485    pub fn current_version(&self) -> u64 {
486        self.rendezvous.version
487    }
488
489    /// Current world size.
490    pub fn world_size(&self) -> usize {
491        self.rendezvous.world_size
492    }
493
494    /// Current sorted membership.
495    pub fn members(&self) -> &[usize] {
496        self.rendezvous.members()
497    }
498
499    /// Lifecycle state of a worker, or `None` if never seen.
500    pub fn worker_state(&self, worker_id: usize) -> Option<WorkerState> {
501        self.worker_states.get(&worker_id).copied()
502    }
503
504    /// Balanced contiguous shard assignment for the current membership.
505    pub fn shard_assignment(&self) -> Vec<ShardRange> {
506        block_partition(&self.rendezvous.members, self.config.dataset_shards)
507    }
508
509    /// Minimal-movement rendezvous-hash shard assignment for the current
510    /// membership.
511    pub fn hashed_shard_assignment(&self) -> Vec<WorkerShards> {
512        hashed_assignment(&self.rendezvous.members, self.config.dataset_shards)
513    }
514
515    /// Effective learning rate under the linear scaling rule,
516    /// `base_lr * world_size / reference_world_size`.
517    ///
518    /// Returns `0.0` before any worker has joined (world size 0).
519    pub fn scaled_learning_rate(&self) -> f64 {
520        if self.rendezvous.world_size == 0 {
521            return 0.0;
522        }
523        self.config.base_learning_rate * self.rendezvous.world_size as f64
524            / self.config.reference_world_size as f64
525    }
526
527    /// Gradient-averaging divisor that tracks the live world size (i.e. the number
528    /// of workers whose gradients are summed before averaging).
529    pub fn gradient_averaging_divisor(&self) -> f64 {
530        self.rendezvous.world_size as f64
531    }
532
533    /// Gradient-averaging factor `1 / world_size` (`0.0` before any join).
534    pub fn averaging_factor(&self) -> f64 {
535        if self.rendezvous.world_size == 0 {
536            0.0
537        } else {
538            1.0 / self.rendezvous.world_size as f64
539        }
540    }
541
542    /// Learning rate during gradual warmup after a resize.
543    ///
544    /// Linearly ramps from `base_learning_rate` at `step == 0` up to the full
545    /// [`ElasticCoordinator::scaled_learning_rate`] once `step >= warmup_steps`
546    /// (and is flat at the scaled value when `warmup_steps == 0`). At the
547    /// reference world size the scaled target equals the base rate, so warmup is a
548    /// no-op there.
549    pub fn warmup_learning_rate(&self, step: usize) -> f64 {
550        let target = self.scaled_learning_rate();
551        let warmup_steps = self.config.warmup_steps;
552        if warmup_steps == 0 || step >= warmup_steps {
553            return target;
554        }
555        let base = self.config.base_learning_rate;
556        let fraction = (step + 1) as f64 / warmup_steps as f64;
557        base + (target - base) * fraction
558    }
559
560    /// Apply one membership event, returning the new [`EpochSnapshot`].
561    ///
562    /// On success the rendezvous version bumps by exactly one and the membership,
563    /// shard assignment and scaled learning rate are recomputed. On failure the
564    /// rendezvous is left completely untouched (no version bump, no state change).
565    ///
566    /// # Errors
567    /// Returns an error for an illegal lifecycle transition (joining an
568    /// already-active worker, or leaving an unknown / already-removed worker), or
569    /// when the change would violate the configured world-size bounds.
570    pub fn apply_event(&mut self, event: MembershipEvent) -> Result<EpochSnapshot> {
571        match event {
572            MembershipEvent::Join(worker_id) => self.apply_join(worker_id),
573            MembershipEvent::Leave(worker_id) => self.apply_leave(worker_id),
574        }
575    }
576
577    /// Apply a sequence of events strictly: the first rejected event aborts the
578    /// whole simulation with its error.
579    ///
580    /// # Errors
581    /// Propagates the first [`ElasticCoordinator::apply_event`] error.
582    pub fn simulate(&mut self, events: &[MembershipEvent]) -> Result<Vec<EpochSnapshot>> {
583        let mut snapshots = Vec::with_capacity(events.len());
584        for &event in events {
585            snapshots.push(self.apply_event(event)?);
586        }
587        Ok(snapshots)
588    }
589
590    /// Apply a sequence of events resiliently: rejected events are skipped, and a
591    /// snapshot is produced only for each *accepted* event.
592    pub fn simulate_resilient(&mut self, events: &[MembershipEvent]) -> Vec<EpochSnapshot> {
593        let mut snapshots = Vec::new();
594        for &event in events {
595            if let Ok(snapshot) = self.apply_event(event) {
596                snapshots.push(snapshot);
597            }
598        }
599        snapshots
600    }
601
602    /// Validate and commit a join.
603    fn apply_join(&mut self, worker_id: usize) -> Result<EpochSnapshot> {
604        let current = self.worker_state(worker_id);
605        if !WorkerState::is_legal_transition(current, WorkerState::Pending) {
606            return Err(OptimError::InvalidState(format!(
607                "cannot join worker {worker_id}: it is currently {} (a join requires an \
608                 absent or removed worker)",
609                describe_state(current)
610            )));
611        }
612        let new_world_size = self.rendezvous.world_size + 1;
613        if new_world_size > self.config.max_world_size {
614            return Err(OptimError::InvalidConfig(format!(
615                "join of worker {worker_id} would raise the world size to {new_world_size}, \
616                 exceeding max_world_size {}",
617                self.config.max_world_size
618            )));
619        }
620
621        // Commit: walk the worker through Pending then Active, splice it into the
622        // sorted membership and bump the rendezvous version.
623        self.worker_states.insert(worker_id, WorkerState::Pending);
624        self.worker_states.insert(worker_id, WorkerState::Active);
625        let position = self
626            .rendezvous
627            .members
628            .partition_point(|&member| member < worker_id);
629        self.rendezvous.members.insert(position, worker_id);
630        self.rendezvous.world_size = self.rendezvous.members.len();
631        self.rendezvous.version += 1;
632
633        Ok(self.snapshot())
634    }
635
636    /// Validate and commit a leave.
637    fn apply_leave(&mut self, worker_id: usize) -> Result<EpochSnapshot> {
638        let current = self.worker_state(worker_id);
639        if current != Some(WorkerState::Active) {
640            return Err(OptimError::InvalidState(format!(
641                "cannot remove worker {worker_id}: it is currently {} (a leave requires an \
642                 active worker)",
643                describe_state(current)
644            )));
645        }
646        if self.rendezvous.world_size <= self.config.min_world_size {
647            return Err(OptimError::InvalidConfig(format!(
648                "leave of worker {worker_id} would drop the world size below min_world_size {}",
649                self.config.min_world_size
650            )));
651        }
652
653        // Commit: walk the worker through Leaving then Removed, splice it out of
654        // the sorted membership and bump the rendezvous version.
655        self.worker_states.insert(worker_id, WorkerState::Leaving);
656        self.worker_states.insert(worker_id, WorkerState::Removed);
657        if let Ok(position) = self.rendezvous.members.binary_search(&worker_id) {
658            self.rendezvous.members.remove(position);
659        }
660        self.rendezvous.world_size = self.rendezvous.members.len();
661        self.rendezvous.version += 1;
662
663        Ok(self.snapshot())
664    }
665
666    /// Build a snapshot of the current rendezvous.
667    fn snapshot(&self) -> EpochSnapshot {
668        EpochSnapshot {
669            version: self.rendezvous.version,
670            world_size: self.rendezvous.world_size,
671            members: self.rendezvous.members.clone(),
672            shard_assignment: self.shard_assignment(),
673            scaled_lr: self.scaled_learning_rate(),
674            averaging_factor: self.averaging_factor(),
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use approx::assert_relative_eq;
683
684    fn base_config() -> ElasticConfig {
685        ElasticConfig::new(0.1, 4, 12, 1, 16, 0).expect("valid config")
686    }
687
688    /// Map each shard index to its owning worker rank under a block partition.
689    fn block_owners(ranges: &[ShardRange], dataset_shards: usize) -> Vec<usize> {
690        let mut owners = vec![usize::MAX; dataset_shards];
691        for range in ranges {
692            owners[range.start..range.end].fill(range.worker_id);
693        }
694        owners
695    }
696
697    /// Map each shard index to its owning worker rank under a hashed assignment.
698    fn hashed_owners(assignment: &[WorkerShards], dataset_shards: usize) -> Vec<usize> {
699        let mut owners = vec![usize::MAX; dataset_shards];
700        for worker in assignment {
701            for &shard in &worker.shards {
702                owners[shard] = worker.worker_id;
703            }
704        }
705        owners
706    }
707
708    fn assert_balanced_cover(ranges: &[ShardRange], num_workers: usize, dataset_shards: usize) {
709        assert_eq!(ranges.len(), num_workers, "one range per worker");
710        // Contiguous, non-overlapping cover of 0..dataset_shards.
711        let mut expected_start = 0usize;
712        for range in ranges {
713            assert_eq!(range.start, expected_start, "ranges must be contiguous");
714            assert!(range.end >= range.start, "range must be well-formed");
715            expected_start = range.end;
716        }
717        assert_eq!(
718            expected_start, dataset_shards,
719            "ranges must cover every shard exactly once"
720        );
721        // Balanced: sizes differ by at most one.
722        let min_size = ranges.iter().map(ShardRange::len).min().unwrap_or(0);
723        let max_size = ranges.iter().map(ShardRange::len).max().unwrap_or(0);
724        assert!(
725            max_size - min_size <= 1,
726            "shard sizes must differ by at most one (min={min_size}, max={max_size})"
727        );
728    }
729
730    #[test]
731    fn test_version_bumps_by_one_per_accepted_event() {
732        let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
733        assert_eq!(coordinator.current_version(), 0);
734        for (expected_version, worker_id) in (1u64..=4).zip(0usize..4) {
735            let snapshot = coordinator
736                .apply_event(MembershipEvent::Join(worker_id))
737                .unwrap();
738            assert_eq!(snapshot.version, expected_version);
739            assert_eq!(coordinator.current_version(), expected_version);
740            assert_eq!(coordinator.world_size(), expected_version as usize);
741        }
742        // A leave also bumps by exactly one.
743        let snapshot = coordinator.apply_event(MembershipEvent::Leave(1)).unwrap();
744        assert_eq!(snapshot.version, 5);
745        assert_eq!(coordinator.world_size(), 3);
746    }
747
748    #[test]
749    fn test_invalid_events_return_err_and_do_not_bump_version() {
750        let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
751        coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
752        coordinator.apply_event(MembershipEvent::Join(1)).unwrap();
753        let version_before = coordinator.current_version();
754        let world_before = coordinator.world_size();
755
756        // Joining an already-active worker is rejected.
757        assert!(coordinator.apply_event(MembershipEvent::Join(0)).is_err());
758        // Leaving an unknown worker is rejected.
759        assert!(coordinator.apply_event(MembershipEvent::Leave(7)).is_err());
760
761        // Leaving then re-leaving the same worker: second leave is rejected.
762        coordinator.apply_event(MembershipEvent::Leave(1)).unwrap();
763        assert!(coordinator.apply_event(MembershipEvent::Leave(1)).is_err());
764        assert_eq!(coordinator.worker_state(1), Some(WorkerState::Removed));
765
766        // The two pure rejections above must not have changed version/size; the
767        // accepted leave bumped version once and dropped size once.
768        assert_eq!(coordinator.current_version(), version_before + 1);
769        assert_eq!(coordinator.world_size(), world_before - 1);
770    }
771
772    #[test]
773    fn test_shard_assignment_balanced_and_covers_all_shards() {
774        for dataset_shards in [1usize, 7, 10, 12, 13, 100] {
775            let config = ElasticConfig::new(0.05, 2, dataset_shards, 1, 64, 0).unwrap();
776            for num_workers in 1usize..=8 {
777                let mut coordinator = ElasticCoordinator::new(config.clone()).unwrap();
778                for worker_id in 0..num_workers {
779                    coordinator
780                        .apply_event(MembershipEvent::Join(worker_id))
781                        .unwrap();
782                }
783                let ranges = coordinator.shard_assignment();
784                assert_balanced_cover(&ranges, num_workers, dataset_shards);
785
786                // No shard index belongs to two ranges, and every shard has an owner.
787                let owners = block_owners(&ranges, dataset_shards);
788                assert!(
789                    owners.iter().all(|&owner| owner != usize::MAX),
790                    "every shard must be owned"
791                );
792            }
793        }
794    }
795
796    #[test]
797    fn test_shard_assignment_is_deterministic_and_path_independent() {
798        let config = base_config();
799        // Two coordinators that reach the same membership via different event
800        // orders must agree on version, world size and shard assignment.
801        let mut first = ElasticCoordinator::new(config.clone()).unwrap();
802        first
803            .simulate(&[
804                MembershipEvent::Join(0),
805                MembershipEvent::Join(1),
806                MembershipEvent::Join(2),
807            ])
808            .unwrap();
809
810        let mut second = ElasticCoordinator::new(config).unwrap();
811        second
812            .simulate(&[
813                MembershipEvent::Join(2),
814                MembershipEvent::Join(0),
815                MembershipEvent::Join(1),
816            ])
817            .unwrap();
818
819        assert_eq!(first.current_version(), second.current_version());
820        assert_eq!(first.world_size(), second.world_size());
821        assert_eq!(first.members(), second.members());
822        assert_eq!(first.shard_assignment(), second.shard_assignment());
823        assert_eq!(
824            first.hashed_shard_assignment(),
825            second.hashed_shard_assignment()
826        );
827
828        // Recomputing the pure assignment for (members, D) reproduces it exactly.
829        let recomputed = block_partition(first.members(), first.config().dataset_shards);
830        assert_eq!(first.shard_assignment(), recomputed);
831    }
832
833    #[test]
834    fn test_linear_scaling_rule_exact() {
835        let base_lr = 0.1;
836        let reference = 4usize;
837        let config = ElasticConfig::new(base_lr, reference, 12, 1, 32, 0).unwrap();
838        let mut coordinator = ElasticCoordinator::new(config).unwrap();
839        for worker_id in 0..8usize {
840            let snapshot = coordinator
841                .apply_event(MembershipEvent::Join(worker_id))
842                .unwrap();
843            let world_size = snapshot.world_size;
844            let expected = base_lr * world_size as f64 / reference as f64;
845            assert_eq!(coordinator.scaled_learning_rate(), expected);
846            assert_eq!(snapshot.scaled_lr, expected);
847            assert_eq!(coordinator.gradient_averaging_divisor(), world_size as f64);
848            assert_relative_eq!(
849                coordinator.averaging_factor(),
850                1.0 / world_size as f64,
851                epsilon = 1e-12
852            );
853        }
854        // At exactly the reference world size, the scaled LR equals the base LR.
855        let mut at_reference =
856            ElasticCoordinator::new(ElasticConfig::new(base_lr, reference, 12, 1, 32, 0).unwrap())
857                .unwrap();
858        for worker_id in 0..reference {
859            at_reference
860                .apply_event(MembershipEvent::Join(worker_id))
861                .unwrap();
862        }
863        assert_eq!(at_reference.scaled_learning_rate(), base_lr);
864    }
865
866    #[test]
867    fn test_warmup_ramps_from_base_to_scaled() {
868        let base_lr = 0.1;
869        let config = ElasticConfig::new(base_lr, 2, 12, 1, 32, 5).unwrap();
870        let mut coordinator = ElasticCoordinator::new(config).unwrap();
871        for worker_id in 0..8usize {
872            coordinator
873                .apply_event(MembershipEvent::Join(worker_id))
874                .unwrap();
875        }
876        let target = coordinator.scaled_learning_rate();
877        assert!(
878            target > base_lr,
879            "scaled target must exceed base for 8 > ref 2"
880        );
881
882        // Warmup starts above the base (step 0 already adds one fifth of the gap)
883        // and is monotonically increasing, reaching the scaled target at the end.
884        let mut previous = base_lr;
885        for step in 0..5 {
886            let lr = coordinator.warmup_learning_rate(step);
887            assert!(lr > previous, "warmup must be strictly increasing");
888            assert!(lr <= target + 1e-12, "warmup must not overshoot the target");
889            previous = lr;
890        }
891        assert_relative_eq!(coordinator.warmup_learning_rate(4), target, epsilon = 1e-12);
892        assert_relative_eq!(
893            coordinator.warmup_learning_rate(100),
894            target,
895            epsilon = 1e-12
896        );
897    }
898
899    #[test]
900    fn test_join_leave_round_trip_restores_membership() {
901        let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
902        coordinator
903            .simulate(&[
904                MembershipEvent::Join(0),
905                MembershipEvent::Join(1),
906                MembershipEvent::Join(2),
907            ])
908            .unwrap();
909        let members_before: Vec<usize> = coordinator.members().to_vec();
910        let world_before = coordinator.world_size();
911        let assignment_before = coordinator.shard_assignment();
912        let lr_before = coordinator.scaled_learning_rate();
913
914        // A worker joins and then leaves again.
915        coordinator.apply_event(MembershipEvent::Join(3)).unwrap();
916        assert_eq!(coordinator.world_size(), world_before + 1);
917        coordinator.apply_event(MembershipEvent::Leave(3)).unwrap();
918
919        assert_eq!(coordinator.members(), members_before.as_slice());
920        assert_eq!(coordinator.world_size(), world_before);
921        assert_eq!(coordinator.shard_assignment(), assignment_before);
922        assert_eq!(coordinator.scaled_learning_rate(), lr_before);
923        // Version is monotonic: two extra accepted events occurred.
924        assert_eq!(coordinator.current_version(), 5);
925    }
926
927    #[test]
928    fn test_min_and_max_world_size_bounds_enforced() {
929        let config = ElasticConfig::new(0.1, 2, 12, 2, 4, 0).unwrap();
930        let mut coordinator = ElasticCoordinator::new(config).unwrap();
931
932        // Bootstrapping below the minimum is permitted (the floor only gates leaves).
933        coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
934        assert_eq!(coordinator.world_size(), 1);
935
936        // Fill up to the maximum world size of 4.
937        for worker_id in 1..4usize {
938            coordinator
939                .apply_event(MembershipEvent::Join(worker_id))
940                .unwrap();
941        }
942        assert_eq!(coordinator.world_size(), 4);
943        let version_at_max = coordinator.current_version();
944
945        // A further join is rejected and does not bump the version.
946        assert!(coordinator.apply_event(MembershipEvent::Join(4)).is_err());
947        assert_eq!(coordinator.current_version(), version_at_max);
948        assert_eq!(coordinator.world_size(), 4);
949
950        // Leave down to the minimum world size of 2.
951        coordinator.apply_event(MembershipEvent::Leave(3)).unwrap();
952        coordinator.apply_event(MembershipEvent::Leave(2)).unwrap();
953        assert_eq!(coordinator.world_size(), 2);
954        let version_at_min = coordinator.current_version();
955
956        // A further leave is rejected and does not bump the version.
957        assert!(coordinator.apply_event(MembershipEvent::Leave(1)).is_err());
958        assert_eq!(coordinator.current_version(), version_at_min);
959        assert_eq!(coordinator.world_size(), 2);
960    }
961
962    #[test]
963    fn test_worker_lifecycle_states() {
964        let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
965        assert_eq!(coordinator.worker_state(0), None);
966        coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
967        assert_eq!(coordinator.worker_state(0), Some(WorkerState::Active));
968        // Need a second worker so the leave does not breach the min bound.
969        coordinator.apply_event(MembershipEvent::Join(1)).unwrap();
970        coordinator.apply_event(MembershipEvent::Leave(0)).unwrap();
971        assert_eq!(coordinator.worker_state(0), Some(WorkerState::Removed));
972        // A removed worker may rejoin.
973        coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
974        assert_eq!(coordinator.worker_state(0), Some(WorkerState::Active));
975
976        // Transition legality table.
977        assert!(WorkerState::is_legal_transition(None, WorkerState::Pending));
978        assert!(WorkerState::is_legal_transition(
979            Some(WorkerState::Removed),
980            WorkerState::Pending
981        ));
982        assert!(WorkerState::is_legal_transition(
983            Some(WorkerState::Active),
984            WorkerState::Leaving
985        ));
986        assert!(!WorkerState::is_legal_transition(
987            Some(WorkerState::Active),
988            WorkerState::Pending
989        ));
990        assert!(!WorkerState::is_legal_transition(None, WorkerState::Active));
991    }
992
993    #[test]
994    fn test_hashed_assignment_balanced_deterministic_and_full_cover() {
995        for dataset_shards in [1usize, 9, 64, 100] {
996            for num_workers in 1usize..=8 {
997                let members: Vec<usize> = (0..num_workers).collect();
998                let assignment = hashed_assignment(&members, dataset_shards);
999                assert_eq!(assignment.len(), num_workers);
1000
1001                let sizes: Vec<usize> = assignment.iter().map(WorkerShards::len).collect();
1002                let total: usize = sizes.iter().sum();
1003                assert_eq!(total, dataset_shards, "must cover every shard");
1004                let min_size = sizes.iter().copied().min().unwrap_or(0);
1005                let max_size = sizes.iter().copied().max().unwrap_or(0);
1006                assert!(max_size - min_size <= 1, "hashed loads must be balanced");
1007
1008                // Every shard owned exactly once.
1009                let owners = hashed_owners(&assignment, dataset_shards);
1010                assert!(owners.iter().all(|&owner| owner != usize::MAX));
1011
1012                // Deterministic: recomputation is identical.
1013                assert_eq!(assignment, hashed_assignment(&members, dataset_shards));
1014                // Shard lists are sorted ascending.
1015                for worker in &assignment {
1016                    assert!(worker.shards.windows(2).all(|pair| pair[0] < pair[1]));
1017                }
1018            }
1019        }
1020    }
1021
1022    #[test]
1023    fn test_hashed_assignment_moves_fewer_shards_than_block() {
1024        // Over a representative resize sequence, rendezvous hashing relocates
1025        // strictly fewer shards in aggregate than the contiguous block scheme.
1026        let dataset_shards = 600usize;
1027        let memberships: Vec<Vec<usize>> = vec![
1028            (0..6).collect(),
1029            (0..5).collect(), // worker 5 leaves
1030            (0..7).collect(), // workers 5, 6 join
1031            (1..7).collect(), // worker 0 leaves
1032            (0..8).collect(), // worker 0 joins, worker 7 joins
1033        ];
1034
1035        let mut block_moves = 0usize;
1036        let mut hashed_moves = 0usize;
1037        for window in memberships.windows(2) {
1038            let before = &window[0];
1039            let after = &window[1];
1040
1041            let block_before =
1042                block_owners(&block_partition(before, dataset_shards), dataset_shards);
1043            let block_after = block_owners(&block_partition(after, dataset_shards), dataset_shards);
1044            block_moves += (0..dataset_shards)
1045                .filter(|&shard| block_before[shard] != block_after[shard])
1046                .count();
1047
1048            let hashed_before =
1049                hashed_owners(&hashed_assignment(before, dataset_shards), dataset_shards);
1050            let hashed_after =
1051                hashed_owners(&hashed_assignment(after, dataset_shards), dataset_shards);
1052            hashed_moves += (0..dataset_shards)
1053                .filter(|&shard| hashed_before[shard] != hashed_after[shard])
1054                .count();
1055        }
1056
1057        assert!(
1058            hashed_moves < block_moves,
1059            "rendezvous hashing should move fewer shards (hashed={hashed_moves}, block={block_moves})"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_simulate_strict_and_resilient() {
1065        let config = base_config();
1066        // Strict simulation: an invalid event aborts with an error.
1067        let mut strict = ElasticCoordinator::new(config.clone()).unwrap();
1068        let result = strict.simulate(&[
1069            MembershipEvent::Join(0),
1070            MembershipEvent::Join(0), // invalid: already active
1071        ]);
1072        assert!(result.is_err());
1073
1074        // Resilient simulation: invalid events are skipped, one snapshot per
1075        // accepted event, and the version equals the number of accepted events.
1076        let mut resilient = ElasticCoordinator::new(config).unwrap();
1077        let snapshots = resilient.simulate_resilient(&[
1078            MembershipEvent::Join(0),
1079            MembershipEvent::Join(0), // skipped
1080            MembershipEvent::Join(1),
1081            MembershipEvent::Leave(5), // skipped
1082            MembershipEvent::Leave(0),
1083        ]);
1084        assert_eq!(snapshots.len(), 3, "three events accepted");
1085        assert_eq!(resilient.current_version(), 3);
1086        for (index, snapshot) in snapshots.iter().enumerate() {
1087            assert_eq!(snapshot.version, index as u64 + 1);
1088        }
1089    }
1090
1091    #[test]
1092    fn test_config_validation_errors() {
1093        assert!(ElasticConfig::new(0.0, 4, 12, 1, 16, 0).is_err());
1094        assert!(ElasticConfig::new(-1.0, 4, 12, 1, 16, 0).is_err());
1095        assert!(ElasticConfig::new(f64::NAN, 4, 12, 1, 16, 0).is_err());
1096        assert!(ElasticConfig::new(0.1, 0, 12, 1, 16, 0).is_err());
1097        assert!(ElasticConfig::new(0.1, 4, 0, 1, 16, 0).is_err());
1098        assert!(ElasticConfig::new(0.1, 4, 12, 0, 16, 0).is_err());
1099        assert!(ElasticConfig::new(0.1, 4, 12, 8, 4, 0).is_err());
1100        assert!(ElasticConfig::new(0.1, 4, 12, 1, 16, 0).is_ok());
1101        // The coordinator rejects an invalid config too.
1102        let bad = ElasticConfig {
1103            base_learning_rate: 0.1,
1104            reference_world_size: 0,
1105            dataset_shards: 1,
1106            min_world_size: 1,
1107            max_world_size: 1,
1108            warmup_steps: 0,
1109        };
1110        assert!(ElasticCoordinator::new(bad).is_err());
1111    }
1112}