Skip to main content

trustformers_optim/fsdp/
mod.rs

1//! Fully Sharded Data Parallel (FSDP) Simulation
2//!
3//! FSDP shards parameters, gradients, and optimizer state across all processes,
4//! providing maximum memory efficiency for large model training.
5//!
6//! This module simulates FSDP behavior for testing and algorithm development
7//! without requiring actual distributed infrastructure.
8
9use std::collections::HashMap;
10
11/// FSDP wrapping strategy
12#[derive(Debug, Clone, PartialEq)]
13pub enum WrappingPolicy {
14    /// Wrap entire model as one FSDP unit
15    WholeProgramWrap,
16    /// Wrap each transformer layer separately (recommended)
17    TransformerLayerWrap {
18        /// Minimum number of parameters to wrap a layer
19        min_params: usize,
20    },
21    /// Wrap by module size threshold
22    SizeBasedWrap {
23        /// Minimum number of parameters to trigger wrapping
24        min_num_params: usize,
25    },
26}
27
28/// Configuration for FSDP training
29#[derive(Debug, Clone)]
30pub struct FsdpConfig {
31    /// Total number of processes
32    pub world_size: usize,
33    /// This process's rank
34    pub local_rank: usize,
35    /// Strategy for deciding which modules to wrap
36    pub wrapping_policy: WrappingPolicy,
37    /// Offload parameter shards to CPU between forward and backward passes
38    pub cpu_offload: bool,
39    /// Keep full-precision master weights, use fp16 for forward pass
40    pub mixed_precision: bool,
41    /// Prefetch the next layer's params during the backward pass
42    pub backward_prefetch: bool,
43    /// Prefetch params ahead of forward pass
44    pub forward_prefetch: bool,
45}
46
47impl Default for FsdpConfig {
48    fn default() -> Self {
49        Self {
50            world_size: 1,
51            local_rank: 0,
52            wrapping_policy: WrappingPolicy::WholeProgramWrap,
53            cpu_offload: false,
54            mixed_precision: false,
55            backward_prefetch: true,
56            forward_prefetch: false,
57        }
58    }
59}
60
61/// Sharding strategy for FSDP — analogous to ZeRO stages.
62#[derive(Debug, Clone, PartialEq)]
63pub enum ShardingStrategy {
64    /// ZeRO-3: shard params + grads + opt state (maximum memory savings).
65    FullShard,
66    /// ZeRO-2: shard grads + opt state only; parameters are replicated.
67    ShardGradOp,
68    /// DDP: replicate everything — no sharding.
69    NoShard,
70    /// ZeRO-3 within a node, replicate model across nodes.
71    HybridShard { num_model_replicas: usize },
72}
73
74/// Memory analyzer for FSDP configurations.
75///
76/// Provides estimates of peak per-rank memory and ratio vs DDP baseline.
77pub struct FsdpMemoryAnalyzer;
78
79impl FsdpMemoryAnalyzer {
80    /// Estimate peak memory per rank in bytes.
81    ///
82    /// Assumes f32 (4 bytes/param) and Adam optimizer (2× param bytes for m and v).
83    /// Uses FullShard accounting by default:
84    ///   - param_bytes / world_size + grad_bytes / world_size + opt_bytes / world_size
85    pub fn peak_memory(config: &FsdpConfig, total_params: usize) -> usize {
86        let param_bytes = total_params * 4; // f32
87        let grad_bytes = total_params * 4;
88        let opt_bytes = total_params * 8; // Adam: m + v = 2× params
89        let ws = config.world_size.max(1);
90        if ws == 1 {
91            param_bytes + grad_bytes + opt_bytes
92        } else {
93            // FullShard: all three components are divided by world_size
94            param_bytes / ws + grad_bytes / ws + opt_bytes / ws
95        }
96    }
97
98    /// Ratio of per-rank memory vs DDP (no sharding) baseline.
99    ///
100    /// Returns a value in `(0.0, 1.0]` where `1.0` means same as DDP and
101    /// `1/world_size` means maximum reduction from FullShard.
102    pub fn memory_vs_ddp_ratio(world_size: usize) -> f32 {
103        if world_size <= 1 {
104            1.0
105        } else {
106            1.0 / world_size as f32
107        }
108    }
109}
110
111/// One FSDP unit — wraps a group of parameters that are collectively sharded.
112#[derive(Debug, Clone)]
113pub struct FsdpUnit {
114    /// Unique identifier for this unit
115    pub unit_id: usize,
116    /// Names of parameters in this unit
117    pub param_names: Vec<String>,
118    /// Total number of parameters across all ranks
119    pub total_params: usize,
120    /// Number of parameters this rank holds (shard_size = ceil(total/world_size))
121    pub shard_size: usize,
122    /// Whether the shard is currently offloaded to CPU
123    pub is_offloaded: bool,
124}
125
126impl FsdpUnit {
127    /// Create a new FSDP unit.
128    pub fn new(
129        unit_id: usize,
130        param_names: Vec<String>,
131        total_params: usize,
132        world_size: usize,
133    ) -> Self {
134        let shard_size =
135            if world_size == 0 { total_params } else { total_params.div_ceil(world_size) };
136
137        Self {
138            unit_id,
139            param_names,
140            total_params,
141            shard_size,
142            is_offloaded: false,
143        }
144    }
145
146    /// Memory in bytes used by this rank's shard.
147    ///
148    /// Uses 8 bytes per parameter (f64).
149    pub fn memory_bytes_per_rank(&self) -> usize {
150        self.shard_size * std::mem::size_of::<f64>()
151    }
152
153    /// Number of parameters held locally by this rank (= `shard_size`).
154    pub fn local_params(&self) -> usize {
155        self.shard_size
156    }
157
158    /// Size of the AllGather output buffer (= `total_params` — full reconstruction needed for forward pass).
159    pub fn all_gather_buffer_size(&self) -> usize {
160        self.total_params
161    }
162
163    /// Size of the ReduceScatter input/output buffer (= `total_params`).
164    pub fn reduce_scatter_buffer_size(&self) -> usize {
165        self.total_params
166    }
167}
168
169/// FSDP state tracking for one training step.
170pub struct FsdpState {
171    config: FsdpConfig,
172    units: Vec<FsdpUnit>,
173    /// param_name -> unit_id
174    param_registry: HashMap<String, usize>,
175    /// unit_id -> gathered params (during forward pass)
176    gather_cache: HashMap<usize, Vec<f64>>,
177    /// unit_id -> shard values
178    shard_storage: HashMap<usize, Vec<f64>>,
179}
180
181impl FsdpState {
182    /// Create a new FSDP state with the given configuration.
183    pub fn new(config: FsdpConfig) -> Self {
184        Self {
185            config,
186            units: Vec::new(),
187            param_registry: HashMap::new(),
188            gather_cache: HashMap::new(),
189            shard_storage: HashMap::new(),
190        }
191    }
192
193    /// Register a group of parameters as one FSDP unit.
194    ///
195    /// Returns the `unit_id` of the newly created unit.
196    pub fn wrap_unit(
197        &mut self,
198        param_names: Vec<String>,
199        param_values: HashMap<String, Vec<f64>>,
200    ) -> Result<usize, FsdpError> {
201        if param_names.is_empty() {
202            return Err(FsdpError::EmptyUnit);
203        }
204
205        // Check for duplicate registrations
206        for name in &param_names {
207            if self.param_registry.contains_key(name) {
208                return Err(FsdpError::AlreadyRegistered(name.clone()));
209            }
210        }
211
212        let unit_id = self.units.len();
213        let total_params: usize =
214            param_names.iter().filter_map(|n| param_values.get(n)).map(|v| v.len()).sum();
215
216        let unit = FsdpUnit::new(
217            unit_id,
218            param_names.clone(),
219            total_params,
220            self.config.world_size,
221        );
222
223        // Build the flat parameter vector (ordered by param_names)
224        let mut flat_params: Vec<f64> = Vec::with_capacity(total_params);
225        for name in &param_names {
226            if let Some(vals) = param_values.get(name) {
227                flat_params.extend_from_slice(vals);
228            }
229        }
230
231        // Store local shard
232        let shard_start = self.config.local_rank * unit.shard_size;
233        let shard_end = (shard_start + unit.shard_size).min(flat_params.len());
234        let shard: Vec<f64> = if shard_start < flat_params.len() {
235            flat_params[shard_start..shard_end].to_vec()
236        } else {
237            Vec::new()
238        };
239
240        self.shard_storage.insert(unit_id, shard);
241
242        // Register param names
243        for name in &param_names {
244            self.param_registry.insert(name.clone(), unit_id);
245        }
246
247        self.units.push(unit);
248        Ok(unit_id)
249    }
250
251    /// AllGather parameters for a unit (before forward pass).
252    ///
253    /// Simulates reconstruction by tiling the shard `world_size` times,
254    /// then truncating to total_params length.
255    pub fn allgather_unit(&mut self, unit_id: usize) -> Result<Vec<f64>, FsdpError> {
256        let unit = self.units.get(unit_id).ok_or(FsdpError::UnitNotFound(unit_id))?;
257
258        let total_params = unit.total_params;
259        let world_size = self.config.world_size;
260
261        let shard = self.shard_storage.get(&unit_id).cloned().unwrap_or_default();
262
263        // Simulate AllGather: repeat shard world_size times and truncate
264        let mut gathered: Vec<f64> = Vec::with_capacity(total_params);
265        if world_size == 0 || shard.is_empty() {
266            // fallback: empty
267        } else {
268            for _ in 0..world_size {
269                gathered.extend_from_slice(&shard);
270                if gathered.len() >= total_params {
271                    break;
272                }
273            }
274            gathered.truncate(total_params);
275        }
276
277        self.gather_cache.insert(unit_id, gathered.clone());
278        Ok(gathered)
279    }
280
281    /// Discard gathered parameters for a unit (after forward pass, to save memory).
282    pub fn discard_unit_params(&mut self, unit_id: usize) -> Result<(), FsdpError> {
283        if unit_id >= self.units.len() {
284            return Err(FsdpError::UnitNotFound(unit_id));
285        }
286        self.gather_cache.remove(&unit_id);
287        Ok(())
288    }
289
290    /// ReduceScatter gradients for a unit (after backward pass).
291    ///
292    /// Simulates by averaging `grads / world_size` and returning the local shard.
293    pub fn reduce_scatter_grads(
294        &mut self,
295        unit_id: usize,
296        grads: Vec<f64>,
297    ) -> Result<Vec<f64>, FsdpError> {
298        let unit = self.units.get(unit_id).ok_or(FsdpError::UnitNotFound(unit_id))?;
299
300        let shard_size = unit.shard_size;
301        let world_size = self.config.world_size.max(1) as f64;
302
303        // Average gradient over all ranks (simulate reduce)
304        let averaged: Vec<f64> = grads.iter().map(|&g| g / world_size).collect();
305
306        // Return the local shard portion
307        let rank = self.config.local_rank;
308        let start = rank * shard_size;
309        let end = (start + shard_size).min(averaged.len());
310
311        let local_grads: Vec<f64> =
312            if start < averaged.len() { averaged[start..end].to_vec() } else { Vec::new() };
313
314        Ok(local_grads)
315    }
316
317    /// Total memory saved vs DDP (replicated params) as a ratio.
318    ///
319    /// Returns `world_size` since each rank holds `1/world_size` of total params.
320    pub fn memory_saving_ratio(&self) -> f64 {
321        self.config.world_size as f64
322    }
323
324    /// Per-rank memory footprint in bytes across all units.
325    pub fn per_rank_memory_bytes(&self) -> usize {
326        self.units.iter().map(|u| u.memory_bytes_per_rank()).sum()
327    }
328
329    /// Number of FSDP units registered.
330    pub fn unit_count(&self) -> usize {
331        self.units.len()
332    }
333
334    /// Total parameter count across all units.
335    pub fn total_params(&self) -> usize {
336        self.units.iter().map(|u| u.total_params).sum()
337    }
338}
339
340/// Errors that can occur in FSDP operations.
341#[derive(Debug, thiserror::Error)]
342pub enum FsdpError {
343    #[error("Unit not found: {0}")]
344    UnitNotFound(usize),
345    #[error("Param already registered: {0}")]
346    AlreadyRegistered(String),
347    #[error("Empty unit")]
348    EmptyUnit,
349    #[error("Not gathered: unit {0}")]
350    NotGathered(usize),
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn make_param_values(names: &[&str], size: usize) -> HashMap<String, Vec<f64>> {
358        names
359            .iter()
360            .enumerate()
361            .map(|(i, &name)| {
362                let vals: Vec<f64> = (0..size).map(|j| (i * size + j) as f64).collect();
363                (name.to_string(), vals)
364            })
365            .collect()
366    }
367
368    // Test 1: wrap_unit basic functionality
369    #[test]
370    fn test_wrap_unit_basic() {
371        let config = FsdpConfig {
372            world_size: 4,
373            local_rank: 0,
374            ..Default::default()
375        };
376        let mut state = FsdpState::new(config);
377        let names = vec!["w1".to_string(), "b1".to_string()];
378        let values = make_param_values(&["w1", "b1"], 16);
379        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
380        assert_eq!(unit_id, 0);
381        assert_eq!(state.unit_count(), 1);
382    }
383
384    // Test 2: unit shard_size = ceil(total/world_size)
385    #[test]
386    fn test_unit_shard_size() {
387        let world_size = 4;
388        let config = FsdpConfig {
389            world_size,
390            local_rank: 0,
391            ..Default::default()
392        };
393        let mut state = FsdpState::new(config);
394        let names = vec!["weight".to_string()];
395        let total = 100usize;
396        let values: HashMap<String, Vec<f64>> = {
397            let mut m = HashMap::new();
398            m.insert("weight".to_string(), vec![1.0f64; total]);
399            m
400        };
401        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
402        let unit = &state.units[unit_id];
403        let expected_shard = total.div_ceil(world_size); // ceil(100/4) = 25
404        assert_eq!(unit.shard_size, expected_shard);
405        assert_eq!(unit.total_params, total);
406    }
407
408    // Test 3: allgather_unit reconstruction (returns non-empty vec)
409    #[test]
410    fn test_allgather_unit_reconstruction() {
411        let config = FsdpConfig {
412            world_size: 2,
413            local_rank: 0,
414            ..Default::default()
415        };
416        let mut state = FsdpState::new(config);
417        let names = vec!["p".to_string()];
418        let values: HashMap<String, Vec<f64>> = {
419            let mut m = HashMap::new();
420            m.insert("p".to_string(), vec![1.0, 2.0, 3.0, 4.0]);
421            m
422        };
423        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
424        let gathered = state.allgather_unit(unit_id).expect("gather failed");
425        // Should have total_params = 4 elements
426        assert_eq!(gathered.len(), 4);
427    }
428
429    // Test 4: discard clears gather_cache
430    #[test]
431    fn test_discard_clears_cache() {
432        let config = FsdpConfig {
433            world_size: 2,
434            local_rank: 0,
435            ..Default::default()
436        };
437        let mut state = FsdpState::new(config);
438        let names = vec!["q".to_string()];
439        let values: HashMap<String, Vec<f64>> = {
440            let mut m = HashMap::new();
441            m.insert("q".to_string(), vec![1.0, 2.0, 3.0, 4.0]);
442            m
443        };
444        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
445        state.allgather_unit(unit_id).expect("gather failed");
446        assert!(state.gather_cache.contains_key(&unit_id));
447        state.discard_unit_params(unit_id).expect("discard failed");
448        assert!(!state.gather_cache.contains_key(&unit_id));
449    }
450
451    // Test 5: reduce_scatter averages grads / world_size
452    #[test]
453    fn test_reduce_scatter_averages() {
454        let world_size = 4;
455        let config = FsdpConfig {
456            world_size,
457            local_rank: 0,
458            ..Default::default()
459        };
460        let mut state = FsdpState::new(config);
461        let names = vec!["r".to_string()];
462        let total = 8usize;
463        let values: HashMap<String, Vec<f64>> = {
464            let mut m = HashMap::new();
465            m.insert("r".to_string(), vec![1.0f64; total]);
466            m
467        };
468        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
469        // All grads = 4.0 → after avg = 1.0
470        let grads = vec![4.0f64; total];
471        let local_grads = state.reduce_scatter_grads(unit_id, grads).expect("scatter failed");
472        // Each element should be 4.0 / 4.0 = 1.0
473        for &g in &local_grads {
474            assert!((g - 1.0).abs() < 1e-9, "Expected 1.0, got {}", g);
475        }
476    }
477
478    // Test 6: memory_saving_ratio ≈ world_size
479    #[test]
480    fn test_memory_saving_ratio() {
481        let world_size = 8;
482        let config = FsdpConfig {
483            world_size,
484            local_rank: 2,
485            ..Default::default()
486        };
487        let state = FsdpState::new(config);
488        let ratio = state.memory_saving_ratio();
489        assert!((ratio - world_size as f64).abs() < 1e-9);
490    }
491
492    // Test 7: per_rank_memory_bytes calculation
493    #[test]
494    fn test_per_rank_memory_bytes() {
495        let world_size = 4;
496        let config = FsdpConfig {
497            world_size,
498            local_rank: 0,
499            ..Default::default()
500        };
501        let mut state = FsdpState::new(config);
502        let names = vec!["w".to_string()];
503        let total = 100usize;
504        let values: HashMap<String, Vec<f64>> = {
505            let mut m = HashMap::new();
506            m.insert("w".to_string(), vec![1.0f64; total]);
507            m
508        };
509        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
510        let unit = &state.units[unit_id];
511        let expected_bytes = unit.shard_size * 8; // f64 = 8 bytes
512        assert_eq!(state.per_rank_memory_bytes(), expected_bytes);
513    }
514
515    // Test 8: unit_count and total_params accessors
516    #[test]
517    fn test_unit_count_and_total_params() {
518        let config = FsdpConfig {
519            world_size: 2,
520            local_rank: 0,
521            ..Default::default()
522        };
523        let mut state = FsdpState::new(config);
524        assert_eq!(state.unit_count(), 0);
525        assert_eq!(state.total_params(), 0);
526
527        let names = vec!["a".to_string()];
528        let values: HashMap<String, Vec<f64>> = {
529            let mut m = HashMap::new();
530            m.insert("a".to_string(), vec![0.0f64; 50]);
531            m
532        };
533        state.wrap_unit(names, values).expect("wrap failed");
534        assert_eq!(state.unit_count(), 1);
535        assert_eq!(state.total_params(), 50);
536    }
537
538    // Test 9: TransformerLayerWrap policy
539    #[test]
540    fn test_transformer_layer_wrap_policy() {
541        let policy = WrappingPolicy::TransformerLayerWrap { min_params: 1024 };
542        let config = FsdpConfig {
543            world_size: 4,
544            local_rank: 1,
545            wrapping_policy: policy.clone(),
546            ..Default::default()
547        };
548        let state = FsdpState::new(config);
549        assert_eq!(
550            state.config.wrapping_policy,
551            WrappingPolicy::TransformerLayerWrap { min_params: 1024 }
552        );
553    }
554
555    // Test 10: FsdpUnit memory_bytes_per_rank
556    #[test]
557    fn test_fsdp_unit_memory_bytes_per_rank() {
558        let unit = FsdpUnit::new(0, vec!["w".to_string()], 1000, 4);
559        // shard_size = ceil(1000/4) = 250
560        assert_eq!(unit.shard_size, 250);
561        // memory = 250 * 8 = 2000 bytes
562        assert_eq!(unit.memory_bytes_per_rank(), 250 * 8);
563    }
564
565    // Test 11: double-register error (AlreadyRegistered)
566    #[test]
567    fn test_double_register_error() {
568        let config = FsdpConfig {
569            world_size: 2,
570            local_rank: 0,
571            ..Default::default()
572        };
573        let mut state = FsdpState::new(config);
574
575        let names1 = vec!["shared_param".to_string()];
576        let values1: HashMap<String, Vec<f64>> = {
577            let mut m = HashMap::new();
578            m.insert("shared_param".to_string(), vec![1.0f64; 10]);
579            m
580        };
581        state.wrap_unit(names1, values1).expect("first wrap should succeed");
582
583        let names2 = vec!["shared_param".to_string()];
584        let values2: HashMap<String, Vec<f64>> = {
585            let mut m = HashMap::new();
586            m.insert("shared_param".to_string(), vec![2.0f64; 10]);
587            m
588        };
589        let result = state.wrap_unit(names2, values2);
590        assert!(matches!(result, Err(FsdpError::AlreadyRegistered(_))));
591    }
592
593    // Test 12: unit_not_found error
594    #[test]
595    fn test_unit_not_found_error() {
596        let config = FsdpConfig {
597            world_size: 2,
598            local_rank: 0,
599            ..Default::default()
600        };
601        let mut state = FsdpState::new(config);
602        let result = state.allgather_unit(99);
603        assert!(matches!(result, Err(FsdpError::UnitNotFound(99))));
604    }
605
606    // Test 13: multi-unit model (multiple wrap_unit calls)
607    #[test]
608    fn test_multi_unit_model() {
609        let config = FsdpConfig {
610            world_size: 4,
611            local_rank: 0,
612            ..Default::default()
613        };
614        let mut state = FsdpState::new(config);
615
616        let layers = [
617            (vec!["l0.w".to_string(), "l0.b".to_string()], 128, 4),
618            (vec!["l1.w".to_string(), "l1.b".to_string()], 256, 8),
619            (vec!["l2.w".to_string(), "l2.b".to_string()], 512, 16),
620        ];
621
622        let mut total_expected = 0usize;
623        for (names, w_size, b_size) in &layers {
624            let mut values: HashMap<String, Vec<f64>> = HashMap::new();
625            values.insert(names[0].clone(), vec![1.0f64; *w_size]);
626            values.insert(names[1].clone(), vec![0.0f64; *b_size]);
627            total_expected += w_size + b_size;
628            state.wrap_unit(names.clone(), values).expect("wrap failed");
629        }
630
631        assert_eq!(state.unit_count(), 3);
632        assert_eq!(state.total_params(), total_expected);
633    }
634
635    // Test 14: cpu_offload flag in config
636    #[test]
637    fn test_cpu_offload_flag() {
638        let config = FsdpConfig {
639            world_size: 4,
640            local_rank: 1,
641            cpu_offload: true,
642            mixed_precision: true,
643            ..Default::default()
644        };
645        assert!(config.cpu_offload);
646        assert!(config.mixed_precision);
647
648        let state = FsdpState::new(config);
649        assert!(state.config.cpu_offload);
650        assert!(state.config.mixed_precision);
651
652        // Check default has cpu_offload false
653        let default_config = FsdpConfig::default();
654        assert!(!default_config.cpu_offload);
655    }
656
657    // ── ShardingStrategy tests ────────────────────────────────────────────────
658
659    // Test 15: FullShard variant equality
660    #[test]
661    fn test_sharding_strategy_full_shard() {
662        let s = ShardingStrategy::FullShard;
663        assert_eq!(s, ShardingStrategy::FullShard);
664    }
665
666    // Test 16: NoShard != FullShard
667    #[test]
668    fn test_sharding_strategy_no_shard() {
669        assert_ne!(ShardingStrategy::NoShard, ShardingStrategy::FullShard);
670    }
671
672    // Test 17: ShardGradOp variant
673    #[test]
674    fn test_sharding_strategy_shard_grad_op() {
675        let s = ShardingStrategy::ShardGradOp;
676        assert_eq!(s, ShardingStrategy::ShardGradOp);
677        assert_ne!(s, ShardingStrategy::FullShard);
678    }
679
680    // Test 18: HybridShard with num_model_replicas
681    #[test]
682    fn test_hybrid_shard_replicas() {
683        let s = ShardingStrategy::HybridShard {
684            num_model_replicas: 4,
685        };
686        assert_eq!(
687            s,
688            ShardingStrategy::HybridShard {
689                num_model_replicas: 4
690            }
691        );
692        assert_ne!(
693            s,
694            ShardingStrategy::HybridShard {
695                num_model_replicas: 2
696            }
697        );
698    }
699
700    // ── FsdpUnit extra methods ────────────────────────────────────────────────
701
702    // Test 19: local_params == shard_size
703    #[test]
704    fn test_fsdp_unit_local_params() {
705        let unit = FsdpUnit::new(0, vec!["w".to_string()], 100, 4);
706        assert_eq!(unit.local_params(), unit.shard_size);
707    }
708
709    // Test 20: all_gather_buffer_size == total_params
710    #[test]
711    fn test_fsdp_unit_all_gather_buffer() {
712        let unit = FsdpUnit::new(0, vec!["w".to_string()], 100, 4);
713        assert_eq!(unit.all_gather_buffer_size(), 100);
714    }
715
716    // Test 21: reduce_scatter_buffer_size == total_params
717    #[test]
718    fn test_fsdp_unit_reduce_scatter_buffer() {
719        let unit = FsdpUnit::new(0, vec!["w".to_string()], 64, 8);
720        assert_eq!(unit.reduce_scatter_buffer_size(), 64);
721    }
722
723    // ── FsdpMemoryAnalyzer tests ──────────────────────────────────────────────
724
725    // Test 22: peak_memory world_size=1 = 16 bytes/param (4+4+8)
726    #[test]
727    fn test_memory_analyzer_peak_memory_world_size_1() {
728        let config = FsdpConfig {
729            world_size: 1,
730            local_rank: 0,
731            ..Default::default()
732        };
733        let peak = FsdpMemoryAnalyzer::peak_memory(&config, 1000);
734        // 4+4+8 = 16 bytes per param * 1000 = 16000
735        assert_eq!(peak, 16_000);
736    }
737
738    // Test 23: peak_memory world_size=4 = 16/4 = 4 bytes/param (integer division)
739    #[test]
740    fn test_memory_analyzer_peak_memory_world_size_4() {
741        let config = FsdpConfig {
742            world_size: 4,
743            local_rank: 0,
744            ..Default::default()
745        };
746        let peak = FsdpMemoryAnalyzer::peak_memory(&config, 1000);
747        // 1000*4/4 + 1000*4/4 + 1000*8/4 = 250 + 250 + 500 = 1000 ... wait:
748        // (4+4+8)*1000 / 4 = 16000/4 = 4000
749        // but integer: 4000/4 + 4000/4 + 8000/4 = 1000+1000+2000 = 4000
750        assert_eq!(peak, 4_000);
751    }
752
753    // Test 24: memory_vs_ddp_ratio world_size=1 => 1.0
754    #[test]
755    fn test_memory_vs_ddp_ratio_world_size_1() {
756        let ratio = FsdpMemoryAnalyzer::memory_vs_ddp_ratio(1);
757        assert!((ratio - 1.0).abs() < 1e-6);
758    }
759
760    // Test 25: memory_vs_ddp_ratio world_size=8 => 0.125
761    #[test]
762    fn test_memory_vs_ddp_ratio_world_size_8() {
763        let ratio = FsdpMemoryAnalyzer::memory_vs_ddp_ratio(8);
764        assert!((ratio - 0.125).abs() < 1e-6);
765    }
766
767    // Test 26: memory_vs_ddp_ratio world_size=0 treated as 1 => 1.0
768    #[test]
769    fn test_memory_vs_ddp_ratio_world_size_0() {
770        let ratio = FsdpMemoryAnalyzer::memory_vs_ddp_ratio(0);
771        assert!((ratio - 1.0).abs() < 1e-6);
772    }
773
774    // ── Shard storage tests ───────────────────────────────────────────────────
775
776    // Test 27: rank 2 of 4 wrapping 100 params — shard_size=25, start=50
777    #[test]
778    fn test_fsdp_state_rank_gets_correct_shard() {
779        let config = FsdpConfig {
780            world_size: 4,
781            local_rank: 2,
782            ..Default::default()
783        };
784        let mut state = FsdpState::new(config);
785        let names = vec!["params".to_string()];
786        let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
787        let mut values = HashMap::new();
788        values.insert("params".to_string(), vals.clone());
789        let unit_id = state.wrap_unit(names, values).expect("wrap failed");
790
791        let unit = &state.units[unit_id];
792        assert_eq!(unit.shard_size, 25);
793
794        // rank 2: should hold elements [50..75]
795        let shard = state.shard_storage.get(&unit_id).expect("shard missing");
796        assert_eq!(shard.len(), 25);
797        assert!(
798            (shard[0] - 50.0).abs() < 1e-9,
799            "first element should be 50.0, got {}",
800            shard[0]
801        );
802    }
803
804    // Test 28: forward_prefetch flag in config
805    #[test]
806    fn test_forward_prefetch_flag() {
807        let config = FsdpConfig {
808            world_size: 4,
809            local_rank: 0,
810            forward_prefetch: true,
811            ..Default::default()
812        };
813        assert!(config.forward_prefetch);
814        let state = FsdpState::new(config);
815        assert!(state.config.forward_prefetch);
816        assert!(!FsdpConfig::default().forward_prefetch);
817    }
818
819    // Test 29: reduce_scatter rank=1 of world_size=4, 8 params, grads=[4.0;8], averaged=1.0
820    #[test]
821    fn test_reduce_scatter_rank1_of_4() {
822        let config = FsdpConfig {
823            world_size: 4,
824            local_rank: 1,
825            ..Default::default()
826        };
827        let mut state = FsdpState::new(config);
828        let names = vec!["r".to_string()];
829        let mut values = HashMap::new();
830        values.insert("r".to_string(), vec![1.0f64; 8]);
831        let unit_id = state.wrap_unit(names, values).expect("wrap");
832        // shard_size = ceil(8/4) = 2
833        let grads = vec![4.0f64; 8]; // all 4.0, averaged → 1.0
834        let local = state.reduce_scatter_grads(unit_id, grads).expect("scatter");
835        assert_eq!(local.len(), 2, "rank 1 should hold 2 elements");
836        for &g in &local {
837            assert!((g - 1.0).abs() < 1e-9, "Expected 1.0, got {g}");
838        }
839    }
840
841    // Test 30: wrapping empty param_names returns EmptyUnit error
842    #[test]
843    fn test_wrap_empty_unit_error() {
844        let config = FsdpConfig {
845            world_size: 2,
846            local_rank: 0,
847            ..Default::default()
848        };
849        let mut state = FsdpState::new(config);
850        let result = state.wrap_unit(vec![], HashMap::new());
851        assert!(matches!(result, Err(FsdpError::EmptyUnit)));
852    }
853}