Skip to main content

fuel_parallel/
pipeline_parallel.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Pipeline parallelism: stage assignment and micro-batch scheduling.
3//!
4//! Pipeline parallelism (PP) partitions a model into consecutive *stages*,
5//! each assigned to a different device. Data flows through stages sequentially,
6//! like an assembly line. To maximise device utilisation, a mini-batch is split
7//! into *micro-batches* that overlap: while stage 2 processes micro-batch 1,
8//! stage 1 is already working on micro-batch 2.
9//!
10//! ## Schedules
11//!
12//! - **GPipe**: all-forward then all-backward. Simple but high bubble time.
13//! - **1F1B** (One-Forward-One-Backward): interleaves forward and backward
14//!   passes to reduce the pipeline bubble.
15//!
16//! This module provides the *scheduler* — it computes which micro-batch each
17//! stage should process at each time step. Actual tensor transfer between
18//! stages is the caller's responsibility.
19//!
20//! # Example
21//!
22//! ```rust
23//! use fuel_parallel::pipeline_parallel::{
24//!     PipelineConfig, Schedule, ScheduleKind, StageOp,
25//! };
26//!
27//! let config = PipelineConfig::new(4, 8); // 4 stages, 8 micro-batches
28//! let schedule = config.build_schedule(ScheduleKind::OneForwardOneBackward);
29//!
30//! assert_eq!(schedule.num_stages(), 4);
31//! assert_eq!(schedule.num_microbatches(), 8);
32//!
33//! // Stage 0 starts with forward of micro-batch 0
34//! let first = &schedule.steps(0)[0];
35//! assert_eq!(first.op, StageOp::Forward);
36//! assert_eq!(first.microbatch, 0);
37//! ```
38
39use serde::{Deserialize, Serialize};
40
41/// An operation a stage performs at one time step.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum StageOp {
44    /// Forward pass on a micro-batch.
45    Forward,
46    /// Backward pass on a micro-batch.
47    Backward,
48    /// Idle (bubble).
49    Idle,
50}
51
52/// A single scheduled step: what a stage does at one time slot.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ScheduleStep {
55    /// Stage index.
56    pub stage: usize,
57    /// Time step (0-indexed).
58    pub time: usize,
59    /// Operation.
60    pub op: StageOp,
61    /// Micro-batch index (meaningless if `op == Idle`).
62    pub microbatch: usize,
63}
64
65/// Kind of pipeline schedule.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum ScheduleKind {
68    /// GPipe: all forwards, then all backwards.
69    GPipe,
70    /// 1F1B: interleaved forward-backward to reduce bubble.
71    OneForwardOneBackward,
72}
73
74/// Pipeline schedule: a per-stage sequence of operations over time.
75#[derive(Debug, Clone)]
76pub struct Schedule {
77    num_stages: usize,
78    num_microbatches: usize,
79    /// Steps indexed by `[stage][time]`.
80    per_stage: Vec<Vec<ScheduleStep>>,
81}
82
83impl Schedule {
84    /// Number of pipeline stages.
85    pub fn num_stages(&self) -> usize {
86        self.num_stages
87    }
88
89    /// Number of micro-batches.
90    pub fn num_microbatches(&self) -> usize {
91        self.num_microbatches
92    }
93
94    /// Steps for a given stage in time order.
95    pub fn steps(&self, stage: usize) -> &[ScheduleStep] {
96        &self.per_stage[stage]
97    }
98
99    /// Total number of time steps.
100    pub fn total_steps(&self) -> usize {
101        self.per_stage.first().map_or(0, |s| s.len())
102    }
103
104    /// Count the number of idle (bubble) steps across all stages.
105    pub fn bubble_steps(&self) -> usize {
106        self.per_stage
107            .iter()
108            .flat_map(|s| s.iter())
109            .filter(|s| s.op == StageOp::Idle)
110            .count()
111    }
112
113    /// Bubble ratio: fraction of all `(stage × time)` slots that are idle.
114    pub fn bubble_ratio(&self) -> f64 {
115        let total = self.per_stage.iter().map(|s| s.len()).sum::<usize>();
116        if total == 0 {
117            return 0.0;
118        }
119        self.bubble_steps() as f64 / total as f64
120    }
121
122    /// All steps at a given time across all stages.
123    pub fn at_time(&self, time: usize) -> Vec<&ScheduleStep> {
124        self.per_stage.iter().filter_map(|s| s.get(time)).collect()
125    }
126}
127
128/// Pipeline parallelism configuration.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PipelineConfig {
131    /// Number of stages (partitions of the model).
132    num_stages: usize,
133    /// Number of micro-batches per mini-batch.
134    num_microbatches: usize,
135}
136
137impl PipelineConfig {
138    /// Create a pipeline configuration.
139    ///
140    /// # Panics
141    ///
142    /// Panics if `num_stages == 0` or `num_microbatches == 0`.
143    pub fn new(num_stages: usize, num_microbatches: usize) -> Self {
144        assert!(num_stages > 0, "num_stages must be > 0");
145        assert!(num_microbatches > 0, "num_microbatches must be > 0");
146        Self {
147            num_stages,
148            num_microbatches,
149        }
150    }
151
152    /// Number of stages.
153    pub fn num_stages(&self) -> usize {
154        self.num_stages
155    }
156
157    /// Number of micro-batches.
158    pub fn num_microbatches(&self) -> usize {
159        self.num_microbatches
160    }
161
162    /// Build a schedule of the given kind.
163    pub fn build_schedule(&self, kind: ScheduleKind) -> Schedule {
164        match kind {
165            ScheduleKind::GPipe => self.build_gpipe(),
166            ScheduleKind::OneForwardOneBackward => self.build_1f1b(),
167        }
168    }
169
170    /// GPipe schedule: all forwards first, then all backwards.
171    ///
172    /// Time steps: `num_stages + num_microbatches - 1` for forward phase,
173    /// same for backward, total = 2 × (S + M - 1).
174    fn build_gpipe(&self) -> Schedule {
175        let s = self.num_stages;
176        let m = self.num_microbatches;
177        let phase_len = s + m - 1;
178        let total = 2 * phase_len;
179
180        let mut per_stage: Vec<Vec<ScheduleStep>> = Vec::with_capacity(s);
181        for stage in 0..s {
182            let mut steps = Vec::with_capacity(total);
183            for t in 0..total {
184                let step = if t < phase_len {
185                    // Forward phase
186                    let mb = t as isize - stage as isize;
187                    if mb >= 0 && (mb as usize) < m {
188                        ScheduleStep {
189                            stage,
190                            time: t,
191                            op: StageOp::Forward,
192                            microbatch: mb as usize,
193                        }
194                    } else {
195                        ScheduleStep {
196                            stage,
197                            time: t,
198                            op: StageOp::Idle,
199                            microbatch: 0,
200                        }
201                    }
202                } else {
203                    // Backward phase (reversed micro-batch order)
204                    let bt = t - phase_len;
205                    let mb = bt as isize - (s - 1 - stage) as isize;
206                    if mb >= 0 && (mb as usize) < m {
207                        let actual_mb = m - 1 - mb as usize;
208                        ScheduleStep {
209                            stage,
210                            time: t,
211                            op: StageOp::Backward,
212                            microbatch: actual_mb,
213                        }
214                    } else {
215                        ScheduleStep {
216                            stage,
217                            time: t,
218                            op: StageOp::Idle,
219                            microbatch: 0,
220                        }
221                    }
222                };
223                steps.push(step);
224            }
225            per_stage.push(steps);
226        }
227
228        Schedule {
229            num_stages: s,
230            num_microbatches: m,
231            per_stage,
232        }
233    }
234
235    /// 1F1B schedule: warmup forwards, then strictly interleaved 1-forward-1-backward,
236    /// then cooldown backwards.
237    fn build_1f1b(&self) -> Schedule {
238        let s = self.num_stages;
239        let m = self.num_microbatches;
240
241        // Phase 1: warmup — stage i does (s - 1 - i) leading forwards
242        // Phase 2: steady state — 1 forward then 1 backward per step
243        // Phase 3: cooldown — remaining backwards
244
245        let mut per_stage: Vec<Vec<ScheduleStep>> = vec![Vec::new(); s];
246
247        // `per_stage` is built with exactly `s` entries just above, so
248        // enumerating it is the same traversal as `0..s`.
249        for (stage, steps) in per_stage.iter_mut().enumerate() {
250            let warmup = s - 1 - stage;
251            let mut fwd_idx: usize = 0;
252            let mut bwd_idx: usize = 0;
253            let mut time: usize = stage; // stagger start
254
255            // Helper: pad stage with idle steps up to `time`.
256            fn pad_idle(steps: &mut Vec<ScheduleStep>, stage: usize, time: usize) {
257                while steps.len() < time {
258                    let t = steps.len();
259                    steps.push(ScheduleStep {
260                        stage,
261                        time: t,
262                        op: StageOp::Idle,
263                        microbatch: 0,
264                    });
265                }
266            }
267
268            // Warmup: forward-only
269            for _ in 0..warmup.min(m) {
270                pad_idle(steps, stage, time);
271                steps.push(ScheduleStep {
272                    stage,
273                    time,
274                    op: StageOp::Forward,
275                    microbatch: fwd_idx,
276                });
277                fwd_idx += 1;
278                time += 1;
279            }
280
281            // Steady state: 1F1B
282            while fwd_idx < m {
283                // Forward
284                pad_idle(steps, stage, time);
285                steps.push(ScheduleStep {
286                    stage,
287                    time,
288                    op: StageOp::Forward,
289                    microbatch: fwd_idx,
290                });
291                fwd_idx += 1;
292                time += 1;
293
294                // Backward
295                if bwd_idx < m {
296                    pad_idle(steps, stage, time);
297                    steps.push(ScheduleStep {
298                        stage,
299                        time,
300                        op: StageOp::Backward,
301                        microbatch: bwd_idx,
302                    });
303                    bwd_idx += 1;
304                    time += 1;
305                }
306            }
307
308            // Cooldown: remaining backwards
309            while bwd_idx < m {
310                pad_idle(steps, stage, time);
311                steps.push(ScheduleStep {
312                    stage,
313                    time,
314                    op: StageOp::Backward,
315                    microbatch: bwd_idx,
316                });
317                bwd_idx += 1;
318                time += 1;
319            }
320        }
321
322        // Pad all stages to the same length
323        let max_len = per_stage.iter().map(|s| s.len()).max().unwrap_or(0);
324        for (stage, steps) in per_stage.iter_mut().enumerate() {
325            while steps.len() < max_len {
326                let t = steps.len();
327                steps.push(ScheduleStep {
328                    stage,
329                    time: t,
330                    op: StageOp::Idle,
331                    microbatch: 0,
332                });
333            }
334        }
335
336        Schedule {
337            num_stages: s,
338            num_microbatches: m,
339            per_stage,
340        }
341    }
342}
343
344/// Describes which layers belong to which pipeline stage.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct StageAssignment {
347    /// Per-layer stage assignment: `assignments[layer_index] = stage_index`.
348    pub assignments: Vec<usize>,
349    /// Number of stages.
350    pub num_stages: usize,
351}
352
353impl StageAssignment {
354    /// Assign `num_layers` layers evenly across `num_stages` stages.
355    pub fn uniform(num_layers: usize, num_stages: usize) -> Self {
356        assert!(
357            num_stages > 0 && num_layers >= num_stages,
358            "need at least as many layers as stages"
359        );
360        let per_stage = num_layers / num_stages;
361        let remainder = num_layers % num_stages;
362        let mut assignments = Vec::with_capacity(num_layers);
363        let mut layer = 0;
364        for stage in 0..num_stages {
365            let count = per_stage + if stage < remainder { 1 } else { 0 };
366            for _ in 0..count {
367                assignments.push(stage);
368                layer += 1;
369            }
370        }
371        let _ = layer;
372        Self {
373            assignments,
374            num_stages,
375        }
376    }
377
378    /// Which stage a layer belongs to.
379    pub fn stage_of(&self, layer: usize) -> Option<usize> {
380        self.assignments.get(layer).copied()
381    }
382
383    /// Layers belonging to a given stage.
384    pub fn layers_in_stage(&self, stage: usize) -> Vec<usize> {
385        self.assignments
386            .iter()
387            .enumerate()
388            .filter(|(_, s)| **s == stage)
389            .map(|(i, _)| i)
390            .collect()
391    }
392
393    /// Number of layers per stage.
394    pub fn layers_per_stage(&self) -> Vec<usize> {
395        let mut counts = vec![0usize; self.num_stages];
396        for &s in &self.assignments {
397            counts[s] += 1;
398        }
399        counts
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn gpipe_schedule_basic() {
409        let config = PipelineConfig::new(2, 4);
410        let sched = config.build_schedule(ScheduleKind::GPipe);
411
412        assert_eq!(sched.num_stages(), 2);
413        assert_eq!(sched.num_microbatches(), 4);
414
415        // Stage 0 first step should be Forward micro-batch 0
416        let first = &sched.steps(0)[0];
417        assert_eq!(first.op, StageOp::Forward);
418        assert_eq!(first.microbatch, 0);
419    }
420
421    #[test]
422    fn gpipe_all_microbatches_processed() {
423        let config = PipelineConfig::new(3, 5);
424        let sched = config.build_schedule(ScheduleKind::GPipe);
425
426        for stage in 0..3 {
427            let fwd_mbs: Vec<usize> = sched
428                .steps(stage)
429                .iter()
430                .filter(|s| s.op == StageOp::Forward)
431                .map(|s| s.microbatch)
432                .collect();
433            assert_eq!(
434                fwd_mbs.len(),
435                5,
436                "stage {stage} should have 5 forward steps"
437            );
438            // Should process all microbatches 0..5
439            let mut sorted = fwd_mbs.clone();
440            sorted.sort();
441            assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
442        }
443    }
444
445    #[test]
446    fn one_f1b_schedule_basic() {
447        let config = PipelineConfig::new(4, 8);
448        let sched = config.build_schedule(ScheduleKind::OneForwardOneBackward);
449
450        assert_eq!(sched.num_stages(), 4);
451        assert_eq!(sched.num_microbatches(), 8);
452
453        // Stage 0 starts with forward
454        let first_non_idle = sched
455            .steps(0)
456            .iter()
457            .find(|s| s.op != StageOp::Idle)
458            .unwrap();
459        assert_eq!(first_non_idle.op, StageOp::Forward);
460        assert_eq!(first_non_idle.microbatch, 0);
461    }
462
463    #[test]
464    fn one_f1b_all_microbatches_processed() {
465        let config = PipelineConfig::new(3, 6);
466        let sched = config.build_schedule(ScheduleKind::OneForwardOneBackward);
467
468        for stage in 0..3 {
469            let fwd_mbs: Vec<usize> = sched
470                .steps(stage)
471                .iter()
472                .filter(|s| s.op == StageOp::Forward)
473                .map(|s| s.microbatch)
474                .collect();
475            let bwd_mbs: Vec<usize> = sched
476                .steps(stage)
477                .iter()
478                .filter(|s| s.op == StageOp::Backward)
479                .map(|s| s.microbatch)
480                .collect();
481            assert_eq!(fwd_mbs.len(), 6, "stage {stage} forward count");
482            assert_eq!(bwd_mbs.len(), 6, "stage {stage} backward count");
483        }
484    }
485
486    #[test]
487    fn one_f1b_less_bubble_than_gpipe() {
488        let config = PipelineConfig::new(4, 16);
489        let gpipe = config.build_schedule(ScheduleKind::GPipe);
490        let f1b = config.build_schedule(ScheduleKind::OneForwardOneBackward);
491
492        // 1F1B should have fewer or equal bubble steps
493        assert!(
494            f1b.bubble_ratio() <= gpipe.bubble_ratio(),
495            "1F1B bubble={:.2}% should be ≤ GPipe bubble={:.2}%",
496            f1b.bubble_ratio() * 100.0,
497            gpipe.bubble_ratio() * 100.0
498        );
499    }
500
501    #[test]
502    fn bubble_ratio_range() {
503        let config = PipelineConfig::new(2, 4);
504        let sched = config.build_schedule(ScheduleKind::GPipe);
505        let ratio = sched.bubble_ratio();
506        assert!((0.0..=1.0).contains(&ratio));
507    }
508
509    #[test]
510    fn at_time_query() {
511        let config = PipelineConfig::new(2, 3);
512        let sched = config.build_schedule(ScheduleKind::GPipe);
513        let steps = sched.at_time(0);
514        assert_eq!(steps.len(), 2); // both stages have a step at time 0
515    }
516
517    #[test]
518    fn uniform_stage_assignment() {
519        let assign = StageAssignment::uniform(12, 4);
520        assert_eq!(assign.layers_per_stage(), vec![3, 3, 3, 3]);
521        assert_eq!(assign.stage_of(0), Some(0));
522        assert_eq!(assign.stage_of(3), Some(1));
523        assert_eq!(assign.stage_of(11), Some(3));
524    }
525
526    #[test]
527    fn uniform_stage_with_remainder() {
528        let assign = StageAssignment::uniform(10, 3);
529        // 10 / 3 = 3r1 → stages get 4, 3, 3
530        assert_eq!(assign.layers_per_stage(), vec![4, 3, 3]);
531    }
532
533    #[test]
534    fn layers_in_stage() {
535        let assign = StageAssignment::uniform(6, 2);
536        assert_eq!(assign.layers_in_stage(0), vec![0, 1, 2]);
537        assert_eq!(assign.layers_in_stage(1), vec![3, 4, 5]);
538    }
539
540    #[test]
541    #[should_panic]
542    fn zero_stages_panics() {
543        PipelineConfig::new(0, 4);
544    }
545
546    #[test]
547    #[should_panic]
548    fn zero_microbatches_panics() {
549        PipelineConfig::new(4, 0);
550    }
551
552    #[test]
553    fn single_stage_no_bubble() {
554        let config = PipelineConfig::new(1, 4);
555        let sched = config.build_schedule(ScheduleKind::GPipe);
556        // With 1 stage, every step is productive
557        let bubble = sched.bubble_steps();
558        assert_eq!(bubble, 0);
559    }
560}