Skip to main content

trustformers_optim/zero/
mod.rs

1//! ZeRO (Zero Redundancy Optimizer) Implementation for TrustformeRS
2//!
3//! ZeRO is a memory-efficient training technique that partitions optimizer states,
4//! gradients, and parameters across devices to reduce memory usage while maintaining
5//! training efficiency.
6//!
7//! Implements three stages:
8//! - Stage 1: Partition optimizer states
9//! - Stage 2: Partition optimizer states + gradients
10//! - Stage 3: Partition optimizer states + gradients + parameters
11
12pub mod zero_optimizer;
13pub mod zero_stage1;
14pub mod zero_stage2;
15pub mod zero_stage3;
16pub mod zero_stage3_overlap;
17pub mod zero_utils;
18
19pub use zero_optimizer::{ZeROConfig, ZeROOptimizer, ZeROStage};
20pub use zero_stage1::ZeROStage1;
21pub use zero_stage2::ZeROStage2;
22pub use zero_stage3::ZeROStage3;
23pub use zero_utils::{
24    all_gather_gradients, gather_parameters, partition_gradients, partition_parameters,
25    reduce_scatter_gradients, GradientBuffer, ParameterGroup, ParameterPartition, ZeROState,
26};
27
28/// ZeRO optimization stages
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ZeROImplementationStage {
31    /// Stage 1: Partition optimizer states only
32    Stage1,
33    /// Stage 2: Partition optimizer states + gradients
34    Stage2,
35    /// Stage 3: Partition optimizer states + gradients + parameters
36    Stage3,
37}
38
39/// Memory statistics for ZeRO optimization
40#[derive(Debug, Clone)]
41pub struct ZeROMemoryStats {
42    /// Memory saved by partitioning optimizer states
43    pub optimizer_memory_saved: usize,
44    /// Memory saved by partitioning gradients
45    pub gradient_memory_saved: usize,
46    /// Memory saved by partitioning parameters
47    pub parameter_memory_saved: usize,
48    /// Total memory saved
49    pub total_memory_saved: usize,
50    /// Memory overhead from communication buffers
51    pub communication_overhead: usize,
52}
53
54impl Default for ZeROMemoryStats {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl ZeROMemoryStats {
61    pub fn new() -> Self {
62        Self {
63            optimizer_memory_saved: 0,
64            gradient_memory_saved: 0,
65            parameter_memory_saved: 0,
66            total_memory_saved: 0,
67            communication_overhead: 0,
68        }
69    }
70
71    pub fn update_totals(&mut self) {
72        self.total_memory_saved =
73            self.optimizer_memory_saved + self.gradient_memory_saved + self.parameter_memory_saved;
74    }
75}
76
77// ─── Flat partition helpers (no Tensor dependency) ───────────────────────────
78
79/// Partition optimizer states across `world_size` ranks.
80///
81/// `state` is a slice of optimizer state vectors (e.g., Adam m, v per parameter).
82/// Returns `Vec<Vec<Vec<f32>>>` where `result[rank][param_idx]` is that rank's slice
83/// of the optimizer state for parameter `param_idx`.
84pub fn partition_optimizer_state(state: &[Vec<f32>], world_size: usize) -> Vec<Vec<Vec<f32>>> {
85    assert!(world_size > 0, "world_size must be > 0");
86    let mut result: Vec<Vec<Vec<f32>>> = vec![Vec::new(); world_size];
87    for param_state in state {
88        let total = param_state.len();
89        let chunk_size = total.div_ceil(world_size);
90        for rank in 0..world_size {
91            let start = rank * chunk_size;
92            let end = (start + chunk_size).min(total);
93            let shard = if start < total { param_state[start..end].to_vec() } else { Vec::new() };
94            result[rank].push(shard);
95        }
96    }
97    result
98}
99
100/// Partition gradients across `world_size` ranks.
101///
102/// Returns `result[rank][param_idx]` = that rank's slice of grad for param `param_idx`.
103pub fn partition_gradients_flat(grads: &[Vec<f32>], world_size: usize) -> Vec<Vec<Vec<f32>>> {
104    assert!(world_size > 0, "world_size must be > 0");
105    let mut result: Vec<Vec<Vec<f32>>> = vec![Vec::new(); world_size];
106    for grad in grads {
107        let total = grad.len();
108        let chunk_size = total.div_ceil(world_size);
109        for rank in 0..world_size {
110            let start = rank * chunk_size;
111            let end = (start + chunk_size).min(total);
112            let shard = if start < total { grad[start..end].to_vec() } else { Vec::new() };
113            result[rank].push(shard);
114        }
115    }
116    result
117}
118
119/// Partition parameters across `world_size` ranks.
120///
121/// Returns `result[rank][param_idx]` = that rank's slice of the parameter.
122pub fn partition_parameters_flat(params: &[Vec<f32>], world_size: usize) -> Vec<Vec<Vec<f32>>> {
123    assert!(world_size > 0, "world_size must be > 0");
124    let mut result: Vec<Vec<Vec<f32>>> = vec![Vec::new(); world_size];
125    for param in params {
126        let total = param.len();
127        let chunk_size = total.div_ceil(world_size);
128        for rank in 0..world_size {
129            let start = rank * chunk_size;
130            let end = (start + chunk_size).min(total);
131            let shard = if start < total { param[start..end].to_vec() } else { Vec::new() };
132            result[rank].push(shard);
133        }
134    }
135    result
136}
137
138/// Gather (reconstruct) parameters from their partitioned shards.
139///
140/// `partitioned[rank][param_idx]` = rank's shard for that param.
141/// Returns `Vec<Vec<f32>>` indexed by param_idx with full concatenated values.
142pub fn gather_parameters_flat(partitioned: &[Vec<Vec<f32>>]) -> Vec<Vec<f32>> {
143    if partitioned.is_empty() {
144        return Vec::new();
145    }
146    let num_params = partitioned[0].len();
147    let mut result: Vec<Vec<f32>> = vec![Vec::new(); num_params];
148    for rank_data in partitioned {
149        for (param_idx, shard) in rank_data.iter().enumerate() {
150            if param_idx < result.len() {
151                result[param_idx].extend_from_slice(shard);
152            }
153        }
154    }
155    result
156}
157
158/// Calculate memory reduction ratio for a given ZeRO stage.
159///
160/// Returns the fraction of total baseline memory that is saved (0.0 to 1.0).
161/// - Stage 1: saves optimizer_bytes * (world_size - 1) / world_size
162/// - Stage 2: saves (optimizer_bytes + grad_bytes) * (world_size - 1) / world_size
163/// - Stage 3: saves all bytes * (world_size - 1) / world_size
164pub fn zero_stage_memory_reduction(
165    stage: u8,
166    world_size: usize,
167    param_bytes: usize,
168    grad_bytes: usize,
169    opt_bytes: usize,
170) -> f32 {
171    if world_size <= 1 {
172        return 0.0;
173    }
174    let total_bytes = (param_bytes + grad_bytes + opt_bytes) as f32;
175    if total_bytes == 0.0 {
176        return 0.0;
177    }
178    let ws = world_size as f32;
179    let save_fraction = (ws - 1.0) / ws;
180    let saved_bytes = match stage {
181        1 => opt_bytes as f32 * save_fraction,
182        2 => (opt_bytes + grad_bytes) as f32 * save_fraction,
183        3 => (param_bytes + grad_bytes + opt_bytes) as f32 * save_fraction,
184        _ => 0.0,
185    };
186    saved_bytes / total_bytes
187}
188
189// ─── ZeroConfig ─────────────────────────────────────────────────────────────
190
191/// Simple configuration struct for ZeRO stage selection and validation.
192#[derive(Debug, Clone)]
193pub struct ZeroConfig {
194    /// ZeRO stage: 1, 2, or 3
195    pub stage: u8,
196    /// Number of distributed ranks
197    pub world_size: usize,
198    /// Overlap communication with computation
199    pub overlap_comm: bool,
200    /// Number of gradient elements per reduce bucket
201    pub reduce_bucket_size: usize,
202}
203
204impl Default for ZeroConfig {
205    fn default() -> Self {
206        Self {
207            stage: 1,
208            world_size: 1,
209            overlap_comm: true,
210            reduce_bucket_size: 500_000_000,
211        }
212    }
213}
214
215impl ZeroConfig {
216    /// Validate the configuration.
217    ///
218    /// Returns `Err` with a descriptive message if:
219    /// - `stage` is not in 1..=3
220    /// - `world_size` is 0
221    pub fn validate(&self) -> Result<(), String> {
222        if self.stage == 0 || self.stage > 3 {
223            return Err(format!("ZeRO stage must be 1, 2, or 3; got {}", self.stage));
224        }
225        if self.world_size == 0 {
226            return Err("world_size must be >= 1".to_string());
227        }
228        Ok(())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    // ── Helper ───────────────────────────────────────────────────────────────
237
238    fn make_params(n: usize) -> Vec<f32> {
239        (0..n).map(|i| i as f32).collect()
240    }
241
242    // ── partition_optimizer_state ─────────────────────────────────────────────
243
244    #[test]
245    fn test_partition_optimizer_state_basic() {
246        // 1 state vec of 8 floats, world_size=4 → each rank gets 2
247        let state = vec![make_params(8)];
248        let partitioned = partition_optimizer_state(&state, 4);
249        assert_eq!(partitioned.len(), 4);
250        for rank in 0..4 {
251            assert_eq!(
252                partitioned[rank][0].len(),
253                2,
254                "rank {rank} should have 2 elements"
255            );
256        }
257        // verify values: rank 0 = [0,1], rank 1 = [2,3], ...
258        assert_eq!(partitioned[0][0], vec![0.0, 1.0]);
259        assert_eq!(partitioned[1][0], vec![2.0, 3.0]);
260        assert_eq!(partitioned[2][0], vec![4.0, 5.0]);
261        assert_eq!(partitioned[3][0], vec![6.0, 7.0]);
262    }
263
264    #[test]
265    fn test_partition_optimizer_state_uneven() {
266        // 7 elements, world_size=3 → chunks of ceil(7/3)=3, ranks get [3, 3, 1]
267        let state = vec![make_params(7)];
268        let partitioned = partition_optimizer_state(&state, 3);
269        assert_eq!(partitioned.len(), 3);
270        assert_eq!(partitioned[0][0].len(), 3);
271        assert_eq!(partitioned[1][0].len(), 3);
272        assert_eq!(partitioned[2][0].len(), 1);
273        // total elements = 7
274        let total: usize = partitioned.iter().map(|r| r[0].len()).sum();
275        assert_eq!(total, 7);
276    }
277
278    #[test]
279    fn test_partition_optimizer_state_multiple_states() {
280        // world_size=2, 3 state vecs of different lengths
281        let state = vec![make_params(4), make_params(6), make_params(2)];
282        let partitioned = partition_optimizer_state(&state, 2);
283        assert_eq!(partitioned.len(), 2);
284        for rank_data in &partitioned {
285            assert_eq!(rank_data.len(), 3, "each rank should have 3 param states");
286        }
287    }
288
289    #[test]
290    fn test_partition_optimizer_state_rank_sizes_sum_to_original() {
291        let state = vec![make_params(10), make_params(7)];
292        let partitioned = partition_optimizer_state(&state, 4);
293        for param_idx in 0..2 {
294            let total: usize = partitioned.iter().map(|r| r[param_idx].len()).sum();
295            assert_eq!(total, state[param_idx].len());
296        }
297    }
298
299    // ── partition_gradients_flat ──────────────────────────────────────────────
300
301    #[test]
302    fn test_partition_gradients_basic() {
303        let grads = vec![make_params(16)];
304        let partitioned = partition_gradients_flat(&grads, 4);
305        assert_eq!(partitioned.len(), 4);
306        for rank in 0..4 {
307            assert_eq!(partitioned[rank][0].len(), 4);
308        }
309    }
310
311    #[test]
312    fn test_partition_gradients_multi() {
313        let grads = vec![make_params(8), make_params(4)];
314        let partitioned = partition_gradients_flat(&grads, 2);
315        // rank 0: first 4 of param0, first 2 of param1
316        assert_eq!(partitioned[0][0].len(), 4);
317        assert_eq!(partitioned[0][1].len(), 2);
318    }
319
320    #[test]
321    fn test_partition_gradients_size_check() {
322        let grads = vec![make_params(9), make_params(5)];
323        let partitioned = partition_gradients_flat(&grads, 3);
324        for (param_idx, original) in grads.iter().enumerate() {
325            let total: usize = partitioned.iter().map(|r| r[param_idx].len()).sum();
326            assert_eq!(total, original.len());
327        }
328    }
329
330    // ── partition_parameters_flat ─────────────────────────────────────────────
331
332    #[test]
333    fn test_partition_parameters_basic() {
334        let params = vec![make_params(12)];
335        let partitioned = partition_parameters_flat(&params, 4);
336        assert_eq!(partitioned.len(), 4);
337        for rank in 0..4 {
338            assert_eq!(partitioned[rank][0].len(), 3);
339        }
340    }
341
342    #[test]
343    fn test_partition_parameters_no_duplicate() {
344        // Total elements across all ranks must equal original count
345        let params = vec![make_params(20)];
346        let partitioned = partition_parameters_flat(&params, 4);
347        let total: usize = partitioned.iter().map(|r| r[0].len()).sum();
348        assert_eq!(total, 20);
349    }
350
351    #[test]
352    fn test_partition_parameters_world_size_1() {
353        let params = vec![make_params(10)];
354        let partitioned = partition_parameters_flat(&params, 1);
355        assert_eq!(partitioned.len(), 1);
356        assert_eq!(partitioned[0][0], make_params(10));
357    }
358
359    // ── gather_parameters_flat ────────────────────────────────────────────────
360
361    #[test]
362    fn test_gather_is_inverse_of_partition() {
363        let original = vec![make_params(12), make_params(8)];
364        let partitioned = partition_parameters_flat(&original, 4);
365        let gathered = gather_parameters_flat(&partitioned);
366        assert_eq!(gathered.len(), original.len());
367        for (idx, orig) in original.iter().enumerate() {
368            assert_eq!(&gathered[idx], orig, "param {idx} mismatch after gather");
369        }
370    }
371
372    #[test]
373    fn test_gather_inverse_uneven() {
374        let original = vec![make_params(7), make_params(11)];
375        let partitioned = partition_parameters_flat(&original, 3);
376        let gathered = gather_parameters_flat(&partitioned);
377        for (idx, orig) in original.iter().enumerate() {
378            assert_eq!(&gathered[idx], orig);
379        }
380    }
381
382    #[test]
383    fn test_gather_empty() {
384        let gathered = gather_parameters_flat(&[]);
385        assert!(gathered.is_empty());
386    }
387
388    // ── zero_stage_memory_reduction ───────────────────────────────────────────
389
390    #[test]
391    fn test_stage1_memory_reduction() {
392        // Stage 1 saves opt_bytes * (ws-1)/ws
393        // world_size=4: saves 3/4 of opt_bytes
394        let ratio = zero_stage_memory_reduction(1, 4, 1000, 1000, 1000);
395        // saved = 1000 * 0.75 = 750, total = 3000, ratio = 750/3000 = 0.25
396        let expected = (1000.0f32 * 0.75) / 3000.0;
397        assert!(
398            (ratio - expected).abs() < 1e-5,
399            "got {ratio}, expected {expected}"
400        );
401    }
402
403    #[test]
404    fn test_stage2_memory_reduction() {
405        let ratio = zero_stage_memory_reduction(2, 4, 1000, 1000, 1000);
406        // saved = (opt+grad) * 0.75 = 2000 * 0.75 = 1500, total=3000, ratio=0.5
407        let expected = (2000.0f32 * 0.75) / 3000.0;
408        assert!(
409            (ratio - expected).abs() < 1e-5,
410            "got {ratio}, expected {expected}"
411        );
412    }
413
414    #[test]
415    fn test_stage3_memory_reduction() {
416        let ratio = zero_stage_memory_reduction(3, 4, 1000, 1000, 1000);
417        // saved = 3000 * 0.75 = 2250, total=3000, ratio=0.75
418        let expected = 3000.0f32 * 0.75 / 3000.0;
419        assert!(
420            (ratio - expected).abs() < 1e-5,
421            "got {ratio}, expected {expected}"
422        );
423    }
424
425    #[test]
426    fn test_memory_reduction_world_size_1() {
427        let ratio = zero_stage_memory_reduction(3, 1, 1000, 1000, 1000);
428        assert_eq!(ratio, 0.0);
429    }
430
431    #[test]
432    fn test_memory_reduction_stage3_is_greater_than_stage1() {
433        let r1 = zero_stage_memory_reduction(1, 4, 1000, 1000, 1000);
434        let r3 = zero_stage_memory_reduction(3, 4, 1000, 1000, 1000);
435        assert!(r3 > r1, "stage3 should save more than stage1");
436    }
437
438    // ── ZeroConfig validation ─────────────────────────────────────────────────
439
440    #[test]
441    fn test_zero_config_valid() {
442        let cfg = ZeroConfig {
443            stage: 2,
444            world_size: 4,
445            ..Default::default()
446        };
447        assert!(cfg.validate().is_ok());
448    }
449
450    #[test]
451    fn test_zero_config_invalid_stage_zero() {
452        let cfg = ZeroConfig {
453            stage: 0,
454            world_size: 4,
455            ..Default::default()
456        };
457        assert!(cfg.validate().is_err());
458    }
459
460    #[test]
461    fn test_zero_config_invalid_stage_four() {
462        let cfg = ZeroConfig {
463            stage: 4,
464            world_size: 4,
465            ..Default::default()
466        };
467        assert!(cfg.validate().is_err());
468    }
469
470    #[test]
471    fn test_zero_config_invalid_world_size() {
472        let cfg = ZeroConfig {
473            stage: 1,
474            world_size: 0,
475            ..Default::default()
476        };
477        assert!(cfg.validate().is_err());
478    }
479
480    #[test]
481    fn test_zero_config_all_stages_valid() {
482        for stage in 1u8..=3 {
483            let cfg = ZeroConfig {
484                stage,
485                world_size: 8,
486                ..Default::default()
487            };
488            assert!(cfg.validate().is_ok(), "stage {stage} should be valid");
489        }
490    }
491
492    // ── ZeROMemoryStats ───────────────────────────────────────────────────────
493
494    #[test]
495    fn test_zero_memory_stats_new() {
496        let stats = ZeROMemoryStats::new();
497        assert_eq!(stats.optimizer_memory_saved, 0);
498        assert_eq!(stats.gradient_memory_saved, 0);
499        assert_eq!(stats.parameter_memory_saved, 0);
500        assert_eq!(stats.total_memory_saved, 0);
501        assert_eq!(stats.communication_overhead, 0);
502    }
503
504    #[test]
505    fn test_zero_memory_stats_update_totals() {
506        let mut stats = ZeROMemoryStats::new();
507        stats.optimizer_memory_saved = 100;
508        stats.gradient_memory_saved = 200;
509        stats.parameter_memory_saved = 300;
510        stats.update_totals();
511        assert_eq!(stats.total_memory_saved, 600);
512    }
513
514    #[test]
515    fn test_partition_large_vectors() {
516        let params: Vec<Vec<f32>> =
517            (0..5).map(|p| (0..1000).map(|i| (p * 1000 + i) as f32).collect()).collect();
518        let partitioned = partition_parameters_flat(&params, 8);
519        assert_eq!(partitioned.len(), 8);
520        // Each rank should hold 1000/8 = ceil(1000/8) = 125 elements per param
521        assert_eq!(partitioned[0][0].len(), 125);
522        // Gather should recover original
523        let gathered = gather_parameters_flat(&partitioned);
524        for (idx, orig) in params.iter().enumerate() {
525            assert_eq!(&gathered[idx], orig, "param {idx} mismatch");
526        }
527    }
528}