1use crate::error::{OptimError, Result};
40use std::collections::VecDeque;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum PipelineSchedule {
45 GPipe,
48 OneForwardOneBackward,
51}
52
53impl PipelineSchedule {
54 pub fn name(self) -> &'static str {
56 match self {
57 PipelineSchedule::GPipe => "GPipe",
58 PipelineSchedule::OneForwardOneBackward => "1F1B",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum OpKind {
66 Forward,
68 Backward,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct StageCost {
78 pub forward: f64,
80 pub backward: f64,
82}
83
84impl StageCost {
85 pub fn new(forward: f64, backward: f64) -> Self {
87 Self { forward, backward }
88 }
89
90 pub fn uniform(value: f64) -> Self {
92 Self {
93 forward: value,
94 backward: value,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct PipelineOp {
102 pub stage: usize,
104 pub micro_batch: usize,
106 pub kind: OpKind,
108 pub start: f64,
110 pub end: f64,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct PipelineConfig {
117 pub num_stages: usize,
119 pub num_micro_batches: usize,
121}
122
123impl PipelineConfig {
124 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 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 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#[derive(Debug, Clone, PartialEq)]
163pub struct PipelineMetrics {
164 pub bubble_fraction: f64,
167 pub utilization: f64,
170 pub peak_activation_stash: usize,
173 pub per_stage_peak_stash: Vec<usize>,
175 pub makespan: f64,
177 pub throughput: f64,
179}
180
181#[derive(Debug, Clone)]
183pub struct PipelineExecution {
184 pub schedule: PipelineSchedule,
186 pub config: PipelineConfig,
188 pub ops: Vec<PipelineOp>,
190 pub metrics: PipelineMetrics,
192}
193
194impl PipelineExecution {
195 pub fn ops(&self) -> &[PipelineOp] {
197 &self.ops
198 }
199
200 pub fn metrics(&self) -> &PipelineMetrics {
202 &self.metrics
203 }
204
205 pub fn makespan(&self) -> f64 {
207 self.metrics.makespan
208 }
209
210 pub fn total_busy_time(&self) -> f64 {
212 self.ops.iter().map(|op| op.end - op.start).sum()
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct StageRange {
219 pub stage: usize,
221 pub start_layer: usize,
223 pub end_layer: usize,
225 pub load: f64,
227}
228
229impl StageRange {
230 pub fn num_layers(&self) -> usize {
232 self.end_layer - self.start_layer
233 }
234}
235
236#[derive(Debug, Clone, Copy, Default)]
241pub struct StagePartitioner;
242
243impl StagePartitioner {
244 pub fn new() -> Self {
246 Self
247 }
248
249 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 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 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 dp[1][1..=num_layers].copy_from_slice(&prefix[1..=num_layers]);
300
301 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 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 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#[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#[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
375fn 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
392fn 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 for micro in 0..warmup {
406 order.push((micro, OpKind::Forward));
407 }
408 for k in 0..steady {
410 order.push((warmup + k, OpKind::Forward));
411 order.push((k, OpKind::Backward));
412 }
413 for micro in steady..num_micro {
415 order.push((micro, OpKind::Backward));
416 }
417 orders.push(order);
418 }
419 orders
420}
421
422fn 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 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 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 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, °) 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 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
537fn 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#[derive(Debug, Clone, Copy)]
566pub struct PipelineScheduler {
567 config: PipelineConfig,
568}
569
570impl PipelineScheduler {
571 pub fn new(config: PipelineConfig) -> Self {
573 Self { config }
574 }
575
576 pub fn config(&self) -> &PipelineConfig {
578 &self.config
579 }
580
581 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 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 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 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 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 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 assert!(partitioner.partition(&[1.0, 2.0], 0).is_err());
805 assert!(partitioner.partition(&[], 1).is_err());
807 assert!(partitioner.partition(&[1.0, 2.0], 3).is_err());
809 assert!(partitioner.partition(&[1.0, -1.0, 2.0], 2).is_err());
811 assert!(partitioner.partition(&[1.0, f64::NAN], 2).is_err());
813 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 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 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 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 assert_eq!(gpipe.metrics.peak_activation_stash, m);
902
903 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 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 µ_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 assert_eq!(exec.ops.len(), 4 * 6 * 2);
1004
1005 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 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 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 let too_few = vec![StageCost::uniform(1.0); 2];
1069 assert!(scheduler
1070 .schedule(PipelineSchedule::GPipe, &too_few)
1071 .is_err());
1072
1073 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 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 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 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}