Skip to main content

optirs_core/distributed/
pipeline_parallel.rs

1// Micro-batch pipeline-parallel schedulers, partitioning and analysis.
2//
3// This module is a pure-Rust, CPU-only *simulation* of pipeline parallelism. No
4// real devices are involved: the "stages" are logical and timing is driven by a
5// pluggable per-stage compute cost. The module is therefore a faithful reference
6// for reasoning about pipeline efficiency (bubble, utilisation, activation
7// memory) that a real multi-device runtime could later be calibrated against.
8//
9// Two classic schedules are implemented:
10//
11// * **GPipe** (Huang et al., 2019) splits a minibatch into `M` micro-batches and
12//   runs *all* forwards through stages `0..P` and then *all* backwards through
13//   stages `P-1..=0`. It is simple and has bubble fraction `(P-1)/(M+P-1)`, but
14//   every stage must stash `M` micro-batch activations simultaneously.
15//
16// * **1F1B / PipeDream-Flush** (Narayanan et al., 2021) reaches the same steady
17//   state bubble fraction but, after a `P-1-stage` warm-up of forwards, alternates
18//   one forward and one backward. This bounds the in-flight (stashed) activations
19//   per stage to the pipeline depth (`min(P, M)`) instead of `M`, dramatically
20//   lowering peak activation memory for large `M`.
21//
22// # Timing model
23// Each stage is a single resource that runs its assigned operations sequentially
24// in the schedule-specified order. An operation's earliest start is the maximum
25// finish time of (a) the previous operation on the same stage (resource edge) and
26// (b) its data dependencies: a forward `F(m, s)` needs `F(m, s-1)`; a backward
27// `B(m, s)` needs `B(m, s+1)` (the downstream gradient) and `F(m, s)` (the
28// stashed activations). The resulting directed acyclic graph is scheduled by a
29// topological earliest-finish pass, giving exact start/end times for arbitrary,
30// non-uniform per-stage costs.
31//
32// # Stage partitioning
33// [`StagePartitioner`] solves the balanced contiguous partition problem: split
34// `L` layers, each with a compute cost, into exactly `P` contiguous stages that
35// minimise the maximum per-stage load. This is the classic "split an array into
36// `P` parts minimising the largest part sum" problem, solved here exactly with
37// dynamic programming and reconstructed split points.
38
39use crate::error::{OptimError, Result};
40use std::collections::VecDeque;
41
42/// Which pipeline schedule to generate.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum PipelineSchedule {
45    /// All-forward-then-all-backward (GPipe). Simple, but stashes `M`
46    /// activations per stage.
47    GPipe,
48    /// One-forward-one-backward steady state (PipeDream-Flush). Same bubble as
49    /// GPipe but bounds stashed activations to the pipeline depth.
50    OneForwardOneBackward,
51}
52
53impl PipelineSchedule {
54    /// Human-readable name of the schedule.
55    pub fn name(self) -> &'static str {
56        match self {
57            PipelineSchedule::GPipe => "GPipe",
58            PipelineSchedule::OneForwardOneBackward => "1F1B",
59        }
60    }
61}
62
63/// Whether a pipeline operation is a forward or a backward pass.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum OpKind {
66    /// Forward pass: stashes one micro-batch activation on the stage.
67    Forward,
68    /// Backward pass: releases one stashed micro-batch activation.
69    Backward,
70}
71
72/// Per-stage forward/backward compute cost in abstract time units.
73///
74/// Costs are pluggable: they need not correspond to any particular hardware. The
75/// only requirement is that both are finite and strictly positive.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct StageCost {
78    /// Cost of a single forward pass on this stage.
79    pub forward: f64,
80    /// Cost of a single backward pass on this stage.
81    pub backward: f64,
82}
83
84impl StageCost {
85    /// Create a stage cost from explicit forward and backward costs.
86    pub fn new(forward: f64, backward: f64) -> Self {
87        Self { forward, backward }
88    }
89
90    /// Create a stage cost with equal forward and backward cost.
91    pub fn uniform(value: f64) -> Self {
92        Self {
93            forward: value,
94            backward: value,
95        }
96    }
97}
98
99/// A single scheduled pipeline operation with its computed timing.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct PipelineOp {
102    /// Stage (device) index in `0..num_stages`.
103    pub stage: usize,
104    /// Micro-batch index in `0..num_micro_batches`.
105    pub micro_batch: usize,
106    /// Forward or backward.
107    pub kind: OpKind,
108    /// Earliest start time in cost units.
109    pub start: f64,
110    /// Finish time in cost units (`start + cost`).
111    pub end: f64,
112}
113
114/// Configuration of a pipeline: number of stages and micro-batches.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct PipelineConfig {
117    /// Number of pipeline stages `P` (pipeline depth).
118    pub num_stages: usize,
119    /// Number of micro-batches `M` the minibatch is split into.
120    pub num_micro_batches: usize,
121}
122
123impl PipelineConfig {
124    /// Create a configuration, validating that both counts are at least one.
125    pub fn new(num_stages: usize, num_micro_batches: usize) -> Result<Self> {
126        if num_stages == 0 {
127            return Err(OptimError::InvalidConfig(
128                "num_stages must be at least 1".to_string(),
129            ));
130        }
131        if num_micro_batches == 0 {
132            return Err(OptimError::InvalidConfig(
133                "num_micro_batches must be at least 1".to_string(),
134            ));
135        }
136        Ok(Self {
137            num_stages,
138            num_micro_batches,
139        })
140    }
141
142    /// Analytical GPipe bubble fraction `(P-1)/(M+P-1)`.
143    ///
144    /// This is the fraction of stage-time wasted to pipeline fill/drain under the
145    /// idealised uniform-cost model. The generated schedule's measured idle
146    /// fraction equals this value when all stage costs are uniform.
147    pub fn analytical_bubble_fraction(&self) -> f64 {
148        let p = self.num_stages as f64;
149        let m = self.num_micro_batches as f64;
150        (p - 1.0) / (m + p - 1.0)
151    }
152
153    /// Analytical pipeline utilisation `M/(M+P-1) = 1 - bubble_fraction`.
154    pub fn analytical_utilization(&self) -> f64 {
155        let p = self.num_stages as f64;
156        let m = self.num_micro_batches as f64;
157        m / (m + p - 1.0)
158    }
159}
160
161/// Efficiency metrics derived from a generated schedule.
162#[derive(Debug, Clone, PartialEq)]
163pub struct PipelineMetrics {
164    /// Fraction of total stage-time spent idle (the pipeline bubble),
165    /// `1 - utilization`. Measured directly from the generated schedule.
166    pub bubble_fraction: f64,
167    /// Fraction of total stage-time spent doing useful work,
168    /// `total_busy / (num_stages * makespan)`.
169    pub utilization: f64,
170    /// Peak number of in-flight (stashed) micro-batch activations across all
171    /// stages. GPipe peaks at `M`; 1F1B peaks at `min(P, M)`.
172    pub peak_activation_stash: usize,
173    /// Peak stashed activations for each stage individually.
174    pub per_stage_peak_stash: Vec<usize>,
175    /// Total wall-clock makespan of the schedule in cost units.
176    pub makespan: f64,
177    /// Throughput in micro-batches per cost unit (`M / makespan`).
178    pub throughput: f64,
179}
180
181/// A fully scheduled pipeline execution: ordered ops plus efficiency metrics.
182#[derive(Debug, Clone)]
183pub struct PipelineExecution {
184    /// Which schedule produced this execution.
185    pub schedule: PipelineSchedule,
186    /// The configuration that was scheduled.
187    pub config: PipelineConfig,
188    /// All `2 * P * M` operations, ordered by start time.
189    pub ops: Vec<PipelineOp>,
190    /// Computed efficiency metrics.
191    pub metrics: PipelineMetrics,
192}
193
194impl PipelineExecution {
195    /// Borrow the ordered operation list.
196    pub fn ops(&self) -> &[PipelineOp] {
197        &self.ops
198    }
199
200    /// Borrow the computed metrics.
201    pub fn metrics(&self) -> &PipelineMetrics {
202        &self.metrics
203    }
204
205    /// Total makespan of the schedule in cost units.
206    pub fn makespan(&self) -> f64 {
207        self.metrics.makespan
208    }
209
210    /// Total busy (non-idle) stage-time, i.e. the sum of all op durations.
211    pub fn total_busy_time(&self) -> f64 {
212        self.ops.iter().map(|op| op.end - op.start).sum()
213    }
214}
215
216/// Contiguous range of layers assigned to one pipeline stage.
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct StageRange {
219    /// Stage (device) index in `0..num_stages`.
220    pub stage: usize,
221    /// First layer index assigned to the stage (inclusive).
222    pub start_layer: usize,
223    /// One past the last layer index assigned to the stage (exclusive).
224    pub end_layer: usize,
225    /// Sum of the costs of the layers in this stage.
226    pub load: f64,
227}
228
229impl StageRange {
230    /// Number of layers assigned to this stage.
231    pub fn num_layers(&self) -> usize {
232        self.end_layer - self.start_layer
233    }
234}
235
236/// Balanced contiguous stage partitioner.
237///
238/// Splits `L` layers into exactly `P` contiguous stages minimising the maximum
239/// per-stage load, via exact dynamic programming.
240#[derive(Debug, Clone, Copy, Default)]
241pub struct StagePartitioner;
242
243impl StagePartitioner {
244    /// Create a new partitioner.
245    pub fn new() -> Self {
246        Self
247    }
248
249    /// Partition `layer_costs` into exactly `num_stages` contiguous stages so the
250    /// maximum per-stage load is minimised.
251    ///
252    /// Returns one [`StageRange`] per stage in order; the ranges are contiguous,
253    /// non-overlapping, non-empty and together cover every layer. The maximum
254    /// stage load equals the optimum (see [`StagePartitioner::optimal_max_load`]).
255    ///
256    /// # Errors
257    /// Returns an error when `num_stages == 0`, `layer_costs` is empty,
258    /// `num_stages` exceeds the number of layers, or any cost is negative or
259    /// non-finite.
260    pub fn partition(&self, layer_costs: &[f64], num_stages: usize) -> Result<Vec<StageRange>> {
261        let num_layers = layer_costs.len();
262        if num_stages == 0 {
263            return Err(OptimError::InvalidConfig(
264                "num_stages must be at least 1".to_string(),
265            ));
266        }
267        if num_layers == 0 {
268            return Err(OptimError::InvalidConfig(
269                "layer_costs must not be empty".to_string(),
270            ));
271        }
272        if num_stages > num_layers {
273            return Err(OptimError::InvalidConfig(format!(
274                "num_stages {num_stages} exceeds number of layers {num_layers}: \
275                 cannot form non-empty contiguous stages"
276            )));
277        }
278        for (i, &cost) in layer_costs.iter().enumerate() {
279            if !cost.is_finite() || cost < 0.0 {
280                return Err(OptimError::InvalidConfig(format!(
281                    "layer {i} cost {cost} must be finite and non-negative"
282                )));
283            }
284        }
285
286        // Prefix sums: prefix[i] is the total cost of layers 0..i.
287        let mut prefix = vec![0.0f64; num_layers + 1];
288        for i in 0..num_layers {
289            prefix[i + 1] = prefix[i] + layer_costs[i];
290        }
291
292        // dp[p][i] = minimal achievable maximum load when splitting the first `i`
293        // layers into exactly `p` contiguous stages. choice[p][i] records the
294        // start index of the `p`-th (last) stage for reconstruction.
295        let mut dp = vec![vec![f64::INFINITY; num_layers + 1]; num_stages + 1];
296        let mut choice = vec![vec![0usize; num_layers + 1]; num_stages + 1];
297
298        // Base case: a single stage covering the first `i` layers.
299        dp[1][1..=num_layers].copy_from_slice(&prefix[1..=num_layers]);
300
301        // Fill the table: the last stage covers [j, i); the first j layers are
302        // split into p-1 stages. Each stage must be non-empty, so j ranges over
303        // (p-1)..i (at least one layer per earlier stage, at least one here).
304        for stages in 2..=num_stages {
305            for i in stages..=num_layers {
306                let mut best = f64::INFINITY;
307                let mut best_j = stages - 1;
308                for j in (stages - 1)..i {
309                    let last_load = prefix[i] - prefix[j];
310                    let candidate = dp[stages - 1][j].max(last_load);
311                    if candidate < best {
312                        best = candidate;
313                        best_j = j;
314                    }
315                }
316                dp[stages][i] = best;
317                choice[stages][i] = best_j;
318            }
319        }
320
321        // Reconstruct the stage boundaries from the back.
322        let mut ranges: Vec<StageRange> = Vec::with_capacity(num_stages);
323        let mut end = num_layers;
324        let mut stages = num_stages;
325        while stages >= 1 {
326            let start = if stages == 1 { 0 } else { choice[stages][end] };
327            ranges.push(StageRange {
328                stage: stages - 1,
329                start_layer: start,
330                end_layer: end,
331                load: prefix[end] - prefix[start],
332            });
333            end = start;
334            stages -= 1;
335        }
336        ranges.reverse();
337        Ok(ranges)
338    }
339
340    /// Minimal achievable maximum per-stage load for the balanced partition.
341    ///
342    /// Equal to the largest stage load of [`StagePartitioner::partition`].
343    pub fn optimal_max_load(&self, layer_costs: &[f64], num_stages: usize) -> Result<f64> {
344        let ranges = self.partition(layer_costs, num_stages)?;
345        Ok(ranges.iter().map(|range| range.load).fold(0.0f64, f64::max))
346    }
347}
348
349/// Flat operation index for `(stage, micro_batch, kind)`.
350///
351/// The encoding is a perfect hash over `0..(2 * P * M)`, avoiding any map.
352#[inline]
353fn op_index(stage: usize, micro: usize, kind: OpKind, num_micro: usize) -> usize {
354    let kind_idx = match kind {
355        OpKind::Forward => 0,
356        OpKind::Backward => 1,
357    };
358    (stage * num_micro + micro) * 2 + kind_idx
359}
360
361/// Inverse of [`op_index`].
362#[inline]
363fn decode_index(index: usize, num_micro: usize) -> (usize, usize, OpKind) {
364    let kind = if index.is_multiple_of(2) {
365        OpKind::Forward
366    } else {
367        OpKind::Backward
368    };
369    let rest = index / 2;
370    let micro = rest % num_micro;
371    let stage = rest / num_micro;
372    (stage, micro, kind)
373}
374
375/// Per-stage execution order for GPipe: all forwards (`0..M`) then all backwards
376/// in reverse micro-batch order (`M-1..=0`), identical on every stage.
377fn gpipe_stage_orders(num_stages: usize, num_micro: usize) -> Vec<Vec<(usize, OpKind)>> {
378    let mut orders = Vec::with_capacity(num_stages);
379    for _ in 0..num_stages {
380        let mut order = Vec::with_capacity(2 * num_micro);
381        for micro in 0..num_micro {
382            order.push((micro, OpKind::Forward));
383        }
384        for micro in (0..num_micro).rev() {
385            order.push((micro, OpKind::Backward));
386        }
387        orders.push(order);
388    }
389    orders
390}
391
392/// Per-stage execution order for 1F1B / PipeDream-Flush.
393///
394/// Stage `s` first issues `warmup = min(P-1-s, M)` forwards, then `M - warmup`
395/// steady-state (forward, backward) pairs, then drains the remaining backwards.
396/// Forwards are issued in order `0..M` and backwards in order `0..M`.
397fn one_f_one_b_stage_orders(num_stages: usize, num_micro: usize) -> Vec<Vec<(usize, OpKind)>> {
398    let mut orders = Vec::with_capacity(num_stages);
399    for stage in 0..num_stages {
400        let warmup = (num_stages - 1 - stage).min(num_micro);
401        let steady = num_micro - warmup;
402        let mut order = Vec::with_capacity(2 * num_micro);
403
404        // Warm-up forwards.
405        for micro in 0..warmup {
406            order.push((micro, OpKind::Forward));
407        }
408        // Steady state: one forward then one backward.
409        for k in 0..steady {
410            order.push((warmup + k, OpKind::Forward));
411            order.push((k, OpKind::Backward));
412        }
413        // Cool-down: remaining backwards.
414        for micro in steady..num_micro {
415            order.push((micro, OpKind::Backward));
416        }
417        orders.push(order);
418    }
419    orders
420}
421
422/// Earliest-finish topological scheduling of the dependency DAG.
423///
424/// Returns the timed ops (ordered by start time) and the makespan.
425fn compute_timeline(
426    num_stages: usize,
427    num_micro: usize,
428    stage_orders: &[Vec<(usize, OpKind)>],
429    stage_costs: &[StageCost],
430) -> Result<(Vec<PipelineOp>, f64)> {
431    let num_ops = num_stages * num_micro * 2;
432    let mut preds: Vec<Vec<usize>> = vec![Vec::new(); num_ops];
433
434    // Resource edges: consecutive ops on the same stage.
435    for (stage, order) in stage_orders.iter().enumerate() {
436        for window in order.windows(2) {
437            let prev = op_index(stage, window[0].0, window[0].1, num_micro);
438            let cur = op_index(stage, window[1].0, window[1].1, num_micro);
439            preds[cur].push(prev);
440        }
441    }
442
443    // Data edges: forward chain, backward chain, and activation dependency.
444    for micro in 0..num_micro {
445        for stage in 0..num_stages {
446            let forward = op_index(stage, micro, OpKind::Forward, num_micro);
447            if stage > 0 {
448                preds[forward].push(op_index(stage - 1, micro, OpKind::Forward, num_micro));
449            }
450            let backward = op_index(stage, micro, OpKind::Backward, num_micro);
451            if stage + 1 < num_stages {
452                preds[backward].push(op_index(stage + 1, micro, OpKind::Backward, num_micro));
453            }
454            preds[backward].push(forward);
455        }
456    }
457
458    // Build successor lists and in-degrees for Kahn's algorithm.
459    let mut indeg = vec![0usize; num_ops];
460    let mut succ: Vec<Vec<usize>> = vec![Vec::new(); num_ops];
461    for (op, plist) in preds.iter().enumerate() {
462        indeg[op] = plist.len();
463        for &pred in plist {
464            succ[pred].push(op);
465        }
466    }
467
468    let mut start = vec![0.0f64; num_ops];
469    let mut end = vec![0.0f64; num_ops];
470    let mut queue: VecDeque<usize> = VecDeque::new();
471    for (op, &deg) in indeg.iter().enumerate() {
472        if deg == 0 {
473            queue.push_back(op);
474        }
475    }
476
477    let mut processed = 0usize;
478    while let Some(op) = queue.pop_front() {
479        // Earliest start is the latest finish among data + resource predecessors.
480        let mut earliest = 0.0f64;
481        for &pred in &preds[op] {
482            if end[pred] > earliest {
483                earliest = end[pred];
484            }
485        }
486        let (stage, _micro, kind) = decode_index(op, num_micro);
487        let cost = match kind {
488            OpKind::Forward => stage_costs[stage].forward,
489            OpKind::Backward => stage_costs[stage].backward,
490        };
491        start[op] = earliest;
492        end[op] = earliest + cost;
493        processed += 1;
494
495        for &next in &succ[op] {
496            indeg[next] -= 1;
497            if indeg[next] == 0 {
498                queue.push_back(next);
499            }
500        }
501    }
502
503    if processed != num_ops {
504        return Err(OptimError::InvalidState(
505            "pipeline dependency graph is cyclic; schedule is infeasible".to_string(),
506        ));
507    }
508
509    let mut makespan = 0.0f64;
510    let mut ops = Vec::with_capacity(num_ops);
511    for op in 0..num_ops {
512        let (stage, micro, kind) = decode_index(op, num_micro);
513        if end[op] > makespan {
514            makespan = end[op];
515        }
516        ops.push(PipelineOp {
517            stage,
518            micro_batch: micro,
519            kind,
520            start: start[op],
521            end: end[op],
522        });
523    }
524
525    ops.sort_by(|a, b| {
526        a.start
527            .partial_cmp(&b.start)
528            .unwrap_or(std::cmp::Ordering::Equal)
529            .then(a.stage.cmp(&b.stage))
530            .then((a.kind as usize).cmp(&(b.kind as usize)))
531            .then(a.micro_batch.cmp(&b.micro_batch))
532    });
533
534    Ok((ops, makespan))
535}
536
537/// Peak stashed (in-flight) activations per stage, read off the op order.
538///
539/// A forward stashes one activation; the matching backward releases it. The peak
540/// of the running count is the stage's activation memory pressure.
541fn compute_peak_stash(stage_orders: &[Vec<(usize, OpKind)>]) -> Vec<usize> {
542    let mut peaks = Vec::with_capacity(stage_orders.len());
543    for order in stage_orders {
544        let mut current = 0i64;
545        let mut peak = 0i64;
546        for &(_, kind) in order {
547            match kind {
548                OpKind::Forward => {
549                    current += 1;
550                    if current > peak {
551                        peak = current;
552                    }
553                }
554                OpKind::Backward => {
555                    current -= 1;
556                }
557            }
558        }
559        peaks.push(peak.max(0) as usize);
560    }
561    peaks
562}
563
564/// Driver that turns a [`PipelineConfig`] into timed schedules and metrics.
565#[derive(Debug, Clone, Copy)]
566pub struct PipelineScheduler {
567    config: PipelineConfig,
568}
569
570impl PipelineScheduler {
571    /// Create a scheduler for the given configuration.
572    pub fn new(config: PipelineConfig) -> Self {
573        Self { config }
574    }
575
576    /// The configuration this scheduler drives.
577    pub fn config(&self) -> &PipelineConfig {
578        &self.config
579    }
580
581    /// Generate a schedule for `schedule_kind` driven by per-stage `stage_costs`.
582    ///
583    /// `stage_costs` must contain exactly `num_stages` entries with finite,
584    /// strictly positive forward and backward costs.
585    ///
586    /// # Errors
587    /// Returns an error when the number of costs does not match the number of
588    /// stages, when any cost is non-finite or non-positive, or (defensively) when
589    /// the generated dependency graph is cyclic.
590    pub fn schedule(
591        &self,
592        schedule_kind: PipelineSchedule,
593        stage_costs: &[StageCost],
594    ) -> Result<PipelineExecution> {
595        let num_stages = self.config.num_stages;
596        let num_micro = self.config.num_micro_batches;
597
598        if stage_costs.len() != num_stages {
599            return Err(OptimError::DimensionMismatch(format!(
600                "expected {num_stages} stage costs (one per stage), got {}",
601                stage_costs.len()
602            )));
603        }
604        for (stage, cost) in stage_costs.iter().enumerate() {
605            if !cost.forward.is_finite() || cost.forward <= 0.0 {
606                return Err(OptimError::InvalidConfig(format!(
607                    "stage {stage} forward cost {} must be finite and positive",
608                    cost.forward
609                )));
610            }
611            if !cost.backward.is_finite() || cost.backward <= 0.0 {
612                return Err(OptimError::InvalidConfig(format!(
613                    "stage {stage} backward cost {} must be finite and positive",
614                    cost.backward
615                )));
616            }
617        }
618
619        let stage_orders = match schedule_kind {
620            PipelineSchedule::GPipe => gpipe_stage_orders(num_stages, num_micro),
621            PipelineSchedule::OneForwardOneBackward => {
622                one_f_one_b_stage_orders(num_stages, num_micro)
623            }
624        };
625
626        let (ops, makespan) = compute_timeline(num_stages, num_micro, &stage_orders, stage_costs)?;
627        let per_stage_peak_stash = compute_peak_stash(&stage_orders);
628        let peak_activation_stash = per_stage_peak_stash.iter().copied().max().unwrap_or(0);
629
630        let total_busy: f64 = stage_costs
631            .iter()
632            .map(|cost| (cost.forward + cost.backward) * num_micro as f64)
633            .sum();
634        let capacity = num_stages as f64 * makespan;
635        let utilization = if capacity > 0.0 {
636            (total_busy / capacity).min(1.0)
637        } else {
638            0.0
639        };
640        let bubble_fraction = (1.0 - utilization).max(0.0);
641        let throughput = if makespan > 0.0 {
642            num_micro as f64 / makespan
643        } else {
644            0.0
645        };
646
647        let metrics = PipelineMetrics {
648            bubble_fraction,
649            utilization,
650            peak_activation_stash,
651            per_stage_peak_stash,
652            makespan,
653            throughput,
654        };
655
656        Ok(PipelineExecution {
657            schedule: schedule_kind,
658            config: self.config,
659            ops,
660            metrics,
661        })
662    }
663
664    /// Convenience wrapper around [`PipelineScheduler::schedule`] with a single
665    /// forward/backward cost applied uniformly to every stage.
666    pub fn schedule_uniform(
667        &self,
668        schedule_kind: PipelineSchedule,
669        forward: f64,
670        backward: f64,
671    ) -> Result<PipelineExecution> {
672        let stage_costs = vec![StageCost::new(forward, backward); self.config.num_stages];
673        self.schedule(schedule_kind, &stage_costs)
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use approx::assert_relative_eq;
681
682    /// Independent brute-force optimum for the balanced contiguous partition,
683    /// used to validate the dynamic program.
684    fn brute_force_max_load(layer_costs: &[f64], num_stages: usize) -> f64 {
685        let num_layers = layer_costs.len();
686        let mut prefix = vec![0.0f64; num_layers + 1];
687        for i in 0..num_layers {
688            prefix[i + 1] = prefix[i] + layer_costs[i];
689        }
690
691        fn rec(prefix: &[f64], start: usize, stages: usize, num_layers: usize) -> f64 {
692            if stages == 1 {
693                return prefix[num_layers] - prefix[start];
694            }
695            let mut best = f64::INFINITY;
696            // Leave at least one layer for each of the remaining stages.
697            let last_end = num_layers - (stages - 1);
698            for end in (start + 1)..=last_end {
699                let first = prefix[end] - prefix[start];
700                let rest = rec(prefix, end, stages - 1, num_layers);
701                let candidate = first.max(rest);
702                if candidate < best {
703                    best = candidate;
704                }
705            }
706            best
707        }
708
709        rec(&prefix, 0, num_stages, num_layers)
710    }
711
712    fn assert_contiguous_cover(ranges: &[StageRange], num_layers: usize, num_stages: usize) {
713        assert_eq!(ranges.len(), num_stages, "wrong number of stages");
714        assert_eq!(
715            ranges[0].start_layer, 0,
716            "first stage must start at layer 0"
717        );
718        assert_eq!(
719            ranges[num_stages - 1].end_layer,
720            num_layers,
721            "last stage must end at the final layer"
722        );
723        for (i, range) in ranges.iter().enumerate() {
724            assert_eq!(range.stage, i, "stage index out of order");
725            assert!(range.num_layers() >= 1, "every stage must be non-empty");
726            if i + 1 < ranges.len() {
727                assert_eq!(
728                    range.end_layer,
729                    ranges[i + 1].start_layer,
730                    "stages must be contiguous"
731                );
732            }
733        }
734    }
735
736    #[test]
737    fn test_partition_balances_uniform_load() {
738        let partitioner = StagePartitioner::new();
739        let costs = vec![1.0f64; 8];
740        let ranges = partitioner.partition(&costs, 4).unwrap();
741
742        assert_contiguous_cover(&ranges, 8, 4);
743        for range in &ranges {
744            assert_eq!(range.num_layers(), 2);
745            assert_relative_eq!(range.load, 2.0, epsilon = 1e-12);
746        }
747        let max_load = ranges.iter().map(|r| r.load).fold(0.0, f64::max);
748        assert_relative_eq!(max_load, 2.0, epsilon = 1e-12);
749    }
750
751    #[test]
752    fn test_partition_matches_brute_force_optimum() {
753        let partitioner = StagePartitioner::new();
754        let cases: &[(Vec<f64>, usize)] = &[
755            (vec![3.0, 1.0, 1.0, 1.0, 3.0, 1.0], 3),
756            (vec![5.0, 2.0, 4.0, 1.0, 1.0, 9.0, 3.0, 2.0], 4),
757            (vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], 2),
758            (vec![10.0, 1.0, 1.0, 1.0, 1.0], 5),
759            (vec![2.0, 2.0, 2.0, 2.0, 2.0, 2.0], 3),
760        ];
761        for (costs, num_stages) in cases {
762            let ranges = partitioner.partition(costs, *num_stages).unwrap();
763            assert_contiguous_cover(&ranges, costs.len(), *num_stages);
764
765            let dp_max = ranges.iter().map(|r| r.load).fold(0.0, f64::max);
766            let optimum = brute_force_max_load(costs, *num_stages);
767            assert_relative_eq!(dp_max, optimum, epsilon = 1e-9);
768
769            let reported = partitioner.optimal_max_load(costs, *num_stages).unwrap();
770            assert_relative_eq!(reported, optimum, epsilon = 1e-9);
771
772            // The balanced max load must never exceed a naive equal-count split.
773            let naive = naive_equal_count_max_load(costs, *num_stages);
774            assert!(
775                dp_max <= naive + 1e-9,
776                "balanced split must beat naive split"
777            );
778        }
779    }
780
781    /// Reference: greedily slice into `num_stages` runs of (almost) equal layer
782    /// counts and report the largest run load.
783    fn naive_equal_count_max_load(costs: &[f64], num_stages: usize) -> f64 {
784        let num_layers = costs.len();
785        let base = num_layers / num_stages;
786        let rem = num_layers % num_stages;
787        let mut idx = 0usize;
788        let mut max_load = 0.0f64;
789        for stage in 0..num_stages {
790            let count = if stage < rem { base + 1 } else { base };
791            let load: f64 = costs[idx..idx + count].iter().sum();
792            if load > max_load {
793                max_load = load;
794            }
795            idx += count;
796        }
797        max_load
798    }
799
800    #[test]
801    fn test_partition_invalid_configs() {
802        let partitioner = StagePartitioner::new();
803        // num_stages == 0.
804        assert!(partitioner.partition(&[1.0, 2.0], 0).is_err());
805        // empty layers.
806        assert!(partitioner.partition(&[], 1).is_err());
807        // num_stages > num_layers.
808        assert!(partitioner.partition(&[1.0, 2.0], 3).is_err());
809        // negative cost.
810        assert!(partitioner.partition(&[1.0, -1.0, 2.0], 2).is_err());
811        // non-finite cost.
812        assert!(partitioner.partition(&[1.0, f64::NAN], 2).is_err());
813        // valid edge: one stage per layer.
814        assert!(partitioner.partition(&[1.0, 2.0, 3.0], 3).is_ok());
815    }
816
817    #[test]
818    fn test_pipeline_config_validation() {
819        assert!(PipelineConfig::new(0, 4).is_err());
820        assert!(PipelineConfig::new(4, 0).is_err());
821        assert!(PipelineConfig::new(1, 1).is_ok());
822        assert!(PipelineConfig::new(4, 8).is_ok());
823    }
824
825    #[test]
826    fn test_gpipe_bubble_fraction_matches_formula() {
827        let cases = [(2usize, 2usize), (4, 8), (4, 1), (8, 16), (3, 5), (1, 4)];
828        for (p, m) in cases {
829            let config = PipelineConfig::new(p, m).unwrap();
830            let scheduler = PipelineScheduler::new(config);
831            let exec = scheduler
832                .schedule_uniform(PipelineSchedule::GPipe, 1.0, 1.0)
833                .unwrap();
834
835            let analytical = config.analytical_bubble_fraction();
836            assert_relative_eq!(
837                analytical,
838                (p as f64 - 1.0) / (m as f64 + p as f64 - 1.0),
839                epsilon = 1e-12
840            );
841            assert_relative_eq!(exec.metrics.bubble_fraction, analytical, epsilon = 1e-9);
842            assert_relative_eq!(
843                exec.metrics.utilization,
844                config.analytical_utilization(),
845                epsilon = 1e-9
846            );
847            // bubble + utilization == 1.
848            assert_relative_eq!(
849                exec.metrics.bubble_fraction + exec.metrics.utilization,
850                1.0,
851                epsilon = 1e-9
852            );
853        }
854    }
855
856    #[test]
857    fn test_gpipe_generated_idle_matches_analytical_bubble() {
858        let cases = [(2usize, 4usize), (4, 8), (3, 6), (5, 10)];
859        for (p, m) in cases {
860            let config = PipelineConfig::new(p, m).unwrap();
861            let scheduler = PipelineScheduler::new(config);
862            let exec = scheduler
863                .schedule_uniform(PipelineSchedule::GPipe, 1.0, 1.0)
864                .unwrap();
865
866            // Recompute idle fraction directly from the generated ops, fully
867            // independent of the metrics struct.
868            let busy: f64 = exec.ops.iter().map(|op| op.end - op.start).sum();
869            let capacity = p as f64 * exec.metrics.makespan;
870            let idle_fraction = 1.0 - busy / capacity;
871
872            assert_relative_eq!(
873                idle_fraction,
874                config.analytical_bubble_fraction(),
875                epsilon = 1e-9
876            );
877            // Uniform GPipe makespan is exactly 2 * (M + P - 1).
878            assert_relative_eq!(
879                exec.metrics.makespan,
880                2.0 * (m as f64 + p as f64 - 1.0),
881                epsilon = 1e-9
882            );
883        }
884    }
885
886    #[test]
887    fn test_one_f_one_b_lower_activation_stash() {
888        let cases = [(4usize, 8usize), (8, 16), (4, 4), (3, 10), (6, 2)];
889        for (p, m) in cases {
890            let config = PipelineConfig::new(p, m).unwrap();
891            let scheduler = PipelineScheduler::new(config);
892
893            let gpipe = scheduler
894                .schedule_uniform(PipelineSchedule::GPipe, 1.0, 1.0)
895                .unwrap();
896            let one_f_one_b = scheduler
897                .schedule_uniform(PipelineSchedule::OneForwardOneBackward, 1.0, 1.0)
898                .unwrap();
899
900            // GPipe stashes exactly M activations per stage.
901            assert_eq!(gpipe.metrics.peak_activation_stash, m);
902
903            // 1F1B caps the stash at min(P, M) and never exceeds GPipe.
904            assert_eq!(
905                one_f_one_b.metrics.peak_activation_stash,
906                p.min(m),
907                "1F1B peak stash should equal min(P, M)"
908            );
909            assert!(
910                one_f_one_b.metrics.peak_activation_stash <= gpipe.metrics.peak_activation_stash,
911                "1F1B peak must not exceed GPipe peak"
912            );
913            assert!(
914                one_f_one_b.metrics.peak_activation_stash <= p,
915                "1F1B peak must not exceed pipeline depth P"
916            );
917            for &stage_peak in &one_f_one_b.metrics.per_stage_peak_stash {
918                assert!(stage_peak <= p, "per-stage 1F1B stash must be <= P");
919            }
920        }
921    }
922
923    #[test]
924    fn test_one_f_one_b_strictly_lower_stash_for_large_m() {
925        let config = PipelineConfig::new(4, 16).unwrap();
926        let scheduler = PipelineScheduler::new(config);
927        let gpipe = scheduler
928            .schedule_uniform(PipelineSchedule::GPipe, 1.0, 1.0)
929            .unwrap();
930        let one_f_one_b = scheduler
931            .schedule_uniform(PipelineSchedule::OneForwardOneBackward, 1.0, 1.0)
932            .unwrap();
933        assert_eq!(gpipe.metrics.peak_activation_stash, 16);
934        assert_eq!(one_f_one_b.metrics.peak_activation_stash, 4);
935        assert!(one_f_one_b.metrics.peak_activation_stash < gpipe.metrics.peak_activation_stash);
936    }
937
938    #[test]
939    fn test_gpipe_and_one_f_one_b_same_bubble_and_makespan_uniform() {
940        // 1F1B trades memory, not throughput: same bubble and makespan as GPipe
941        // under uniform costs.
942        let cases = [(2usize, 2usize), (4, 8), (3, 7), (5, 5)];
943        for (p, m) in cases {
944            let config = PipelineConfig::new(p, m).unwrap();
945            let scheduler = PipelineScheduler::new(config);
946            let gpipe = scheduler
947                .schedule_uniform(PipelineSchedule::GPipe, 1.0, 1.0)
948                .unwrap();
949            let one_f_one_b = scheduler
950                .schedule_uniform(PipelineSchedule::OneForwardOneBackward, 1.0, 1.0)
951                .unwrap();
952            assert_relative_eq!(
953                gpipe.metrics.makespan,
954                one_f_one_b.metrics.makespan,
955                epsilon = 1e-9
956            );
957            assert_relative_eq!(
958                gpipe.metrics.makespan,
959                2.0 * (m as f64 + p as f64 - 1.0),
960                epsilon = 1e-9
961            );
962            assert_relative_eq!(
963                gpipe.metrics.bubble_fraction,
964                one_f_one_b.metrics.bubble_fraction,
965                epsilon = 1e-9
966            );
967        }
968    }
969
970    #[test]
971    fn test_throughput_increases_with_micro_batches() {
972        for schedule in [
973            PipelineSchedule::GPipe,
974            PipelineSchedule::OneForwardOneBackward,
975        ] {
976            let micro_batches = [1usize, 2, 4, 8, 16];
977            let mut previous = 0.0f64;
978            for &m in &micro_batches {
979                let config = PipelineConfig::new(4, m).unwrap();
980                let scheduler = PipelineScheduler::new(config);
981                let exec = scheduler.schedule_uniform(schedule, 1.0, 1.0).unwrap();
982                assert!(
983                    exec.metrics.throughput > previous,
984                    "throughput must increase with M for {} (M={m})",
985                    schedule.name()
986                );
987                previous = exec.metrics.throughput;
988            }
989        }
990    }
991
992    #[test]
993    fn test_schedule_structure_is_valid() {
994        let config = PipelineConfig::new(4, 6).unwrap();
995        let scheduler = PipelineScheduler::new(config);
996        for schedule in [
997            PipelineSchedule::GPipe,
998            PipelineSchedule::OneForwardOneBackward,
999        ] {
1000            let exec = scheduler.schedule_uniform(schedule, 1.0, 2.0).unwrap();
1001
1002            // Exactly 2 * P * M ops.
1003            assert_eq!(exec.ops.len(), 4 * 6 * 2);
1004
1005            // Each (stage, micro) has exactly one forward and one backward, and
1006            // the forward finishes no later than the backward starts.
1007            for stage in 0..4 {
1008                for micro in 0..6 {
1009                    let forward = exec
1010                        .ops
1011                        .iter()
1012                        .find(|op| {
1013                            op.stage == stage
1014                                && op.micro_batch == micro
1015                                && op.kind == OpKind::Forward
1016                        })
1017                        .unwrap();
1018                    let backward = exec
1019                        .ops
1020                        .iter()
1021                        .find(|op| {
1022                            op.stage == stage
1023                                && op.micro_batch == micro
1024                                && op.kind == OpKind::Backward
1025                        })
1026                        .unwrap();
1027                    assert!(forward.end <= backward.start + 1e-9);
1028                    // Forward cost 1.0, backward cost 2.0.
1029                    assert_relative_eq!(forward.end - forward.start, 1.0, epsilon = 1e-9);
1030                    assert_relative_eq!(backward.end - backward.start, 2.0, epsilon = 1e-9);
1031                }
1032            }
1033
1034            // Forward data dependency: F(m, s) finishes no later than F(m, s+1)
1035            // starts.
1036            for micro in 0..6 {
1037                for stage in 0..3 {
1038                    let here = exec
1039                        .ops
1040                        .iter()
1041                        .find(|op| {
1042                            op.stage == stage
1043                                && op.micro_batch == micro
1044                                && op.kind == OpKind::Forward
1045                        })
1046                        .unwrap();
1047                    let next = exec
1048                        .ops
1049                        .iter()
1050                        .find(|op| {
1051                            op.stage == stage + 1
1052                                && op.micro_batch == micro
1053                                && op.kind == OpKind::Forward
1054                        })
1055                        .unwrap();
1056                    assert!(here.end <= next.start + 1e-9);
1057                }
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn test_schedule_invalid_costs() {
1064        let config = PipelineConfig::new(3, 4).unwrap();
1065        let scheduler = PipelineScheduler::new(config);
1066
1067        // Wrong number of stage costs.
1068        let too_few = vec![StageCost::uniform(1.0); 2];
1069        assert!(scheduler
1070            .schedule(PipelineSchedule::GPipe, &too_few)
1071            .is_err());
1072
1073        // Non-positive cost.
1074        let bad = vec![
1075            StageCost::new(1.0, 1.0),
1076            StageCost::new(0.0, 1.0),
1077            StageCost::new(1.0, 1.0),
1078        ];
1079        assert!(scheduler.schedule(PipelineSchedule::GPipe, &bad).is_err());
1080
1081        // Non-finite cost.
1082        let infinite = vec![
1083            StageCost::new(1.0, 1.0),
1084            StageCost::new(1.0, f64::INFINITY),
1085            StageCost::new(1.0, 1.0),
1086        ];
1087        assert!(scheduler
1088            .schedule(PipelineSchedule::GPipe, &infinite)
1089            .is_err());
1090    }
1091
1092    #[test]
1093    fn test_non_uniform_costs_bottleneck_dominates_makespan() {
1094        // A heavy middle stage should dominate the steady-state throughput.
1095        let config = PipelineConfig::new(3, 8).unwrap();
1096        let scheduler = PipelineScheduler::new(config);
1097        let stage_costs = [
1098            StageCost::new(1.0, 1.0),
1099            StageCost::new(4.0, 4.0),
1100            StageCost::new(1.0, 1.0),
1101        ];
1102        let exec = scheduler
1103            .schedule(PipelineSchedule::OneForwardOneBackward, &stage_costs)
1104            .unwrap();
1105
1106        // The bottleneck stage performs 8 forwards + 8 backwards at cost 4 each =
1107        // 64 cost units of unavoidable work, so the makespan is at least that.
1108        assert!(exec.metrics.makespan >= 64.0 - 1e-9);
1109        assert!(exec.metrics.utilization > 0.0 && exec.metrics.utilization <= 1.0);
1110        assert!(exec.metrics.bubble_fraction >= 0.0 && exec.metrics.bubble_fraction < 1.0);
1111    }
1112}