Skip to main content

trustformers_optim/zero/
zero_utils.rs

1//! Utility functions and data structures for ZeRO optimization
2
3use std::collections::HashMap;
4use trustformers_core::errors::{Result, TrustformersError};
5use trustformers_core::parallel::ModelParallelContext;
6use trustformers_core::tensor::Tensor;
7
8/// ZeRO optimizer state management
9#[derive(Debug, Clone)]
10pub struct ZeROState {
11    /// Current step number
12    pub step: usize,
13    /// Partitioned optimizer states per parameter group
14    pub optimizer_states: HashMap<String, HashMap<String, Tensor>>,
15    /// Partitioned gradients (for Stage 2+)
16    pub gradient_partitions: HashMap<String, GradientBuffer>,
17    /// Partitioned parameters (for Stage 3)
18    pub parameter_partitions: HashMap<String, ParameterPartition>,
19    /// Communication buffers for all-gather operations
20    pub communication_buffers: HashMap<String, Tensor>,
21}
22
23impl Default for ZeROState {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl ZeROState {
30    pub fn new() -> Self {
31        Self {
32            step: 0,
33            optimizer_states: HashMap::new(),
34            gradient_partitions: HashMap::new(),
35            parameter_partitions: HashMap::new(),
36            communication_buffers: HashMap::new(),
37        }
38    }
39
40    /// Reset gradients for next iteration
41    pub fn zero_grad(&mut self) {
42        for buffer in self.gradient_partitions.values_mut() {
43            buffer.zero();
44        }
45    }
46
47    /// Increment step counter
48    pub fn step(&mut self) {
49        self.step += 1;
50    }
51
52    /// Get memory usage statistics
53    pub fn memory_usage(&self) -> HashMap<String, usize> {
54        let mut stats = HashMap::new();
55
56        // Calculate optimizer state memory
57        let mut optimizer_memory = 0;
58        for states in self.optimizer_states.values() {
59            for tensor in states.values() {
60                optimizer_memory += tensor.memory_usage();
61            }
62        }
63        stats.insert("optimizer_states".to_string(), optimizer_memory);
64
65        // Calculate gradient memory
66        let mut gradient_memory = 0;
67        for buffer in self.gradient_partitions.values() {
68            gradient_memory += buffer.memory_usage();
69        }
70        stats.insert("gradient_partitions".to_string(), gradient_memory);
71
72        // Calculate parameter memory
73        let mut parameter_memory = 0;
74        for partition in self.parameter_partitions.values() {
75            parameter_memory += partition.memory_usage();
76        }
77        stats.insert("parameter_partitions".to_string(), parameter_memory);
78
79        // Calculate communication buffer memory
80        let mut comm_memory = 0;
81        for tensor in self.communication_buffers.values() {
82            comm_memory += tensor.memory_usage();
83        }
84        stats.insert("communication_buffers".to_string(), comm_memory);
85
86        stats
87    }
88}
89
90/// Parameter group for ZeRO optimization
91#[derive(Debug, Clone)]
92pub struct ParameterGroup {
93    /// Group name/identifier
94    pub name: String,
95    /// Parameter names in this group
96    pub parameter_names: Vec<String>,
97    /// Local partition of parameters
98    pub local_parameters: HashMap<String, Tensor>,
99    /// Metadata for parameter partitioning
100    pub partition_info: PartitionInfo,
101}
102
103impl ParameterGroup {
104    pub fn new(name: String, parameter_names: Vec<String>) -> Self {
105        Self {
106            name,
107            parameter_names,
108            local_parameters: HashMap::new(),
109            partition_info: PartitionInfo::default(),
110        }
111    }
112
113    /// Add a parameter to this group
114    pub fn add_parameter(&mut self, name: String, tensor: Tensor) {
115        self.local_parameters.insert(name.clone(), tensor);
116        if !self.parameter_names.contains(&name) {
117            self.parameter_names.push(name);
118        }
119    }
120
121    /// Get total memory usage of this group
122    pub fn memory_usage(&self) -> usize {
123        self.local_parameters.values().map(|t| t.memory_usage()).sum()
124    }
125}
126
127/// Partition information for distributed parameters
128#[derive(Debug, Clone)]
129pub struct PartitionInfo {
130    /// Rank of this partition
131    pub rank: usize,
132    /// Total number of partitions
133    pub world_size: usize,
134    /// Start index in global parameter
135    pub start_idx: usize,
136    /// End index in global parameter
137    pub end_idx: usize,
138    /// Global shape of full parameter
139    pub global_shape: Vec<usize>,
140    /// Local shape of this partition
141    pub local_shape: Vec<usize>,
142}
143
144impl Default for PartitionInfo {
145    fn default() -> Self {
146        Self {
147            rank: 0,
148            world_size: 1,
149            start_idx: 0,
150            end_idx: 0,
151            global_shape: vec![],
152            local_shape: vec![],
153        }
154    }
155}
156
157/// Parameter partition for ZeRO Stage 3
158#[derive(Debug, Clone)]
159pub struct ParameterPartition {
160    /// Parameter name
161    pub name: String,
162    /// Local shard of the parameter
163    pub local_shard: Tensor,
164    /// Partition metadata
165    pub partition_info: PartitionInfo,
166    /// Whether this parameter is currently gathered
167    pub is_gathered: bool,
168    /// Full parameter (only valid when is_gathered = true)
169    pub full_parameter: Option<Tensor>,
170}
171
172impl ParameterPartition {
173    pub fn new(name: String, local_shard: Tensor, partition_info: PartitionInfo) -> Self {
174        Self {
175            name,
176            local_shard,
177            partition_info,
178            is_gathered: false,
179            full_parameter: None,
180        }
181    }
182
183    /// Get memory usage of this partition
184    pub fn memory_usage(&self) -> usize {
185        let mut usage = self.local_shard.memory_usage();
186        if let Some(full_param) = &self.full_parameter {
187            usage += full_param.memory_usage();
188        }
189        usage
190    }
191
192    /// Gather full parameter from all partitions
193    pub fn gather(&mut self, mp_context: &ModelParallelContext) -> Result<()> {
194        if self.is_gathered {
195            return Ok(());
196        }
197
198        // Use model parallel context to gather the parameter
199        let full_param =
200            mp_context.all_gather(&trustformers_core::parallel::DistributedTensor::new(
201                self.local_shard.clone(),
202                self.partition_info.global_shape.clone(),
203                trustformers_core::parallel::TensorPartition {
204                    split_dim: 0, // Assume partitioning along first dimension
205                    start_idx: self.partition_info.start_idx,
206                    end_idx: self.partition_info.end_idx,
207                    num_partitions: self.partition_info.world_size,
208                    partition_rank: self.partition_info.rank,
209                },
210                self.partition_info.rank,
211            ))?;
212
213        self.full_parameter = Some(full_param);
214        self.is_gathered = true;
215        Ok(())
216    }
217
218    /// Release gathered parameter to save memory
219    pub fn release(&mut self) {
220        self.full_parameter = None;
221        self.is_gathered = false;
222    }
223}
224
225/// Gradient buffer for ZeRO Stage 2+
226#[derive(Debug, Clone)]
227pub struct GradientBuffer {
228    /// Buffer name
229    pub name: String,
230    /// Local gradient shard
231    pub local_gradient: Tensor,
232    /// Accumulated gradients
233    pub accumulated_gradient: Option<Tensor>,
234    /// Number of accumulated steps
235    pub accumulation_steps: usize,
236    /// Partition metadata
237    pub partition_info: PartitionInfo,
238}
239
240impl GradientBuffer {
241    pub fn new(name: String, local_gradient: Tensor, partition_info: PartitionInfo) -> Self {
242        Self {
243            name,
244            local_gradient,
245            accumulated_gradient: None,
246            accumulation_steps: 0,
247            partition_info,
248        }
249    }
250
251    /// Zero the gradient buffer
252    pub fn zero(&mut self) {
253        // The shape is taken from an existing valid tensor, so `zeros` cannot
254        // fail in practice; on the theoretical error path we leave the buffer
255        // unchanged rather than panicking.
256        if let Ok(zeros) = Tensor::zeros(&self.local_gradient.shape()) {
257            self.local_gradient = zeros;
258        }
259        self.accumulated_gradient = None;
260        self.accumulation_steps = 0;
261    }
262
263    /// Accumulate gradient
264    pub fn accumulate(&mut self, gradient: &Tensor) -> Result<()> {
265        if let Some(acc_grad) = &mut self.accumulated_gradient {
266            *acc_grad = acc_grad.add(gradient)?;
267        } else {
268            self.accumulated_gradient = Some(gradient.clone());
269        }
270        self.accumulation_steps += 1;
271        Ok(())
272    }
273
274    /// Get the accumulated gradient (averaged if needed)
275    pub fn get_accumulated(&self) -> Option<Tensor> {
276        if let Some(acc_grad) = &self.accumulated_gradient {
277            if self.accumulation_steps > 1 {
278                acc_grad.scalar_div(self.accumulation_steps as f32).ok()
279            } else {
280                Some(acc_grad.clone())
281            }
282        } else {
283            None
284        }
285    }
286
287    /// Get memory usage of this buffer
288    pub fn memory_usage(&self) -> usize {
289        let mut usage = self.local_gradient.memory_usage();
290        if let Some(acc_grad) = &self.accumulated_gradient {
291            usage += acc_grad.memory_usage();
292        }
293        usage
294    }
295}
296
297/// Partition parameters across devices for ZeRO Stage 3
298pub fn partition_parameters(
299    parameters: &HashMap<String, Tensor>,
300    world_size: usize,
301    rank: usize,
302) -> Result<HashMap<String, ParameterPartition>> {
303    let mut partitions = HashMap::new();
304
305    for (name, param) in parameters {
306        let shape = param.shape();
307        let (start_idx, end_idx) = shard_range(shape.iter().product::<usize>(), world_size, rank)?;
308        let local_shard = slice_flat(param, start_idx, end_idx)?;
309
310        let partition_info = PartitionInfo {
311            rank,
312            world_size,
313            start_idx,
314            end_idx,
315            global_shape: shape.to_vec(),
316            local_shape: local_shard.shape().to_vec(),
317        };
318
319        let partition = ParameterPartition::new(name.clone(), local_shard, partition_info);
320        partitions.insert(name.clone(), partition);
321    }
322
323    Ok(partitions)
324}
325
326/// Computes the half-open element range `[start, end)` owned by `rank`.
327///
328/// Ranks are assigned contiguous, balanced slices of the *flattened* tensor:
329/// rank `r` owns `[r·⌈n/W⌉, min((r+1)·⌈n/W⌉, n))`. Trailing ranks may own an empty
330/// range when `n` is not divisible by `W`; the ranges are disjoint and their lengths
331/// sum to exactly `n`, which is what makes ZeRO's memory saving real.
332///
333/// # Errors
334///
335/// Returns an error for `world_size == 0` or `rank >= world_size`.
336pub fn shard_range(
337    total_elements: usize,
338    world_size: usize,
339    rank: usize,
340) -> Result<(usize, usize)> {
341    if world_size == 0 {
342        return Err(TrustformersError::invalid_input(
343            "ZeRO partitioning requires world_size > 0".to_string(),
344        ));
345    }
346    if rank >= world_size {
347        return Err(TrustformersError::invalid_input(format!(
348            "rank {rank} is out of range for world_size {world_size}"
349        )));
350    }
351
352    let elements_per_rank = total_elements.div_ceil(world_size);
353    let start_idx = (rank * elements_per_rank).min(total_elements);
354    let end_idx = ((rank + 1) * elements_per_rank).min(total_elements);
355    Ok((start_idx, end_idx))
356}
357
358/// Extracts `[start, end)` of a tensor's flattened data as a genuinely smaller 1-D
359/// tensor.
360///
361/// The whole point of ZeRO is that a rank only *retains* its own slice, so this really
362/// allocates `end - start` elements rather than rescaling the original. (The source
363/// tensor is read in full here because partitioning happens once, at setup, from a
364/// materialised parameter map.)
365///
366/// # Errors
367///
368/// Returns an error when the range is out of bounds or the tensor is not
369/// `f32`-readable.
370pub fn slice_flat(tensor: &Tensor, start: usize, end: usize) -> Result<Tensor> {
371    let data = tensor.data_f32()?;
372    if end > data.len() || start > end {
373        return Err(TrustformersError::invalid_input(format!(
374            "shard range {start}..{end} is out of bounds for a tensor of {} elements",
375            data.len()
376        )));
377    }
378    let shard = data[start..end].to_vec();
379    let len = shard.len();
380    Tensor::from_vec(shard, &[len])
381}
382
383/// Reassembles a full tensor from shards presented in rank order.
384///
385/// This is the inverse of [`partition_parameters`]/[`partition_gradients`]: the shards
386/// are concatenated and reshaped back to `global_shape`.
387///
388/// # Errors
389///
390/// Returns an error when the concatenated length does not match `global_shape`.
391pub fn gather_shards(shards: &[Tensor], global_shape: &[usize]) -> Result<Tensor> {
392    let mut values = Vec::new();
393    for shard in shards {
394        values.extend_from_slice(&shard.data_f32()?);
395    }
396
397    let expected: usize = global_shape.iter().product();
398    if values.len() != expected {
399        return Err(TrustformersError::invalid_input(format!(
400            "gathered {} elements but the global shape {global_shape:?} needs {expected}",
401            values.len()
402        )));
403    }
404
405    Tensor::from_vec(values, global_shape)
406}
407
408/// Gather parameters from all devices
409pub fn gather_parameters(
410    partitions: &mut HashMap<String, ParameterPartition>,
411    mp_context: &ModelParallelContext,
412) -> Result<HashMap<String, Tensor>> {
413    let mut gathered = HashMap::new();
414
415    for (name, partition) in partitions.iter_mut() {
416        partition.gather(mp_context)?;
417        if let Some(full_param) = &partition.full_parameter {
418            gathered.insert(name.clone(), full_param.clone());
419        }
420    }
421
422    Ok(gathered)
423}
424
425/// Partition gradients across devices for ZeRO Stage 2+
426pub fn partition_gradients(
427    gradients: &HashMap<String, Tensor>,
428    world_size: usize,
429    rank: usize,
430) -> Result<HashMap<String, GradientBuffer>> {
431    let mut buffers = HashMap::new();
432
433    for (name, grad) in gradients {
434        let shape = grad.shape();
435        let (start_idx, end_idx) = shard_range(shape.iter().product::<usize>(), world_size, rank)?;
436        let local_gradient = slice_flat(grad, start_idx, end_idx)?;
437
438        let partition_info = PartitionInfo {
439            rank,
440            world_size,
441            start_idx,
442            end_idx,
443            global_shape: shape.to_vec(),
444            local_shape: local_gradient.shape().to_vec(),
445        };
446
447        let buffer = GradientBuffer::new(name.clone(), local_gradient, partition_info);
448        buffers.insert(name.clone(), buffer);
449    }
450
451    Ok(buffers)
452}
453
454/// All-gather gradients from all devices
455pub fn all_gather_gradients(
456    buffers: &HashMap<String, GradientBuffer>,
457    mp_context: &ModelParallelContext,
458) -> Result<HashMap<String, Tensor>> {
459    let mut gathered = HashMap::new();
460
461    for (name, buffer) in buffers {
462        let distributed_tensor = trustformers_core::parallel::DistributedTensor::new(
463            buffer.local_gradient.clone(),
464            buffer.partition_info.global_shape.clone(),
465            trustformers_core::parallel::TensorPartition {
466                split_dim: 0,
467                start_idx: buffer.partition_info.start_idx,
468                end_idx: buffer.partition_info.end_idx,
469                num_partitions: buffer.partition_info.world_size,
470                partition_rank: buffer.partition_info.rank,
471            },
472            buffer.partition_info.rank,
473        );
474
475        let full_gradient = mp_context.all_gather(&distributed_tensor)?;
476        gathered.insert(name.clone(), full_gradient);
477    }
478
479    Ok(gathered)
480}
481
482/// Reduce-scatter gradients across devices
483pub fn reduce_scatter_gradients(
484    gradients: &HashMap<String, Tensor>,
485    mp_context: &ModelParallelContext,
486) -> Result<HashMap<String, Tensor>> {
487    let mut scattered = HashMap::new();
488
489    for (name, grad) in gradients {
490        let scattered_grad = mp_context.reduce_scatter(grad, 0)?;
491        scattered.insert(name.clone(), scattered_grad);
492    }
493
494    Ok(scattered)
495}
496
497/// Calculate optimal bucket size for gradient communication
498pub fn calculate_bucket_size(
499    parameter_sizes: &[usize],
500    target_bucket_size: usize,
501) -> Vec<Vec<usize>> {
502    let mut buckets = Vec::new();
503    let mut current_bucket = Vec::new();
504    let mut current_size = 0;
505
506    for (i, &size) in parameter_sizes.iter().enumerate() {
507        if current_size + size > target_bucket_size && !current_bucket.is_empty() {
508            buckets.push(current_bucket);
509            current_bucket = Vec::new();
510            current_size = 0;
511        }
512
513        current_bucket.push(i);
514        current_size += size;
515    }
516
517    if !current_bucket.is_empty() {
518        buckets.push(current_bucket);
519    }
520
521    buckets
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_zero_state_creation() {
530        let state = ZeROState::new();
531        assert_eq!(state.step, 0);
532        assert!(state.optimizer_states.is_empty());
533        assert!(state.gradient_partitions.is_empty());
534        assert!(state.parameter_partitions.is_empty());
535    }
536
537    #[test]
538    fn test_parameter_group() {
539        let mut group = ParameterGroup::new("test_group".to_string(), vec!["param1".to_string()]);
540        let tensor = Tensor::ones(&[2, 2]).expect("Failed to create tensor");
541        group.add_parameter("param1".to_string(), tensor);
542
543        assert_eq!(group.parameter_names.len(), 1);
544        assert_eq!(group.local_parameters.len(), 1);
545        assert!(group.memory_usage() > 0);
546    }
547
548    #[test]
549    fn test_gradient_buffer() {
550        let tensor = Tensor::ones(&[2, 2]).expect("Failed to create tensor");
551        let partition_info = PartitionInfo::default();
552        let mut buffer = GradientBuffer::new("test_grad".to_string(), tensor, partition_info);
553
554        let grad = Tensor::ones(&[2, 2]).expect("Failed to create tensor");
555        buffer.accumulate(&grad).expect("Operation failed in test");
556
557        assert_eq!(buffer.accumulation_steps, 1);
558        assert!(buffer.get_accumulated().is_some());
559    }
560
561    #[test]
562    fn test_partition_parameters() {
563        let mut params = HashMap::new();
564        params.insert(
565            "param1".to_string(),
566            Tensor::ones(&[4, 4]).expect("Failed to create tensor"),
567        );
568        params.insert(
569            "param2".to_string(),
570            Tensor::ones(&[2, 2]).expect("Failed to create tensor"),
571        );
572
573        let partitions = partition_parameters(&params, 2, 0).expect("Operation failed in test");
574        assert_eq!(partitions.len(), 2);
575
576        for partition in partitions.values() {
577            assert_eq!(partition.partition_info.world_size, 2);
578            assert_eq!(partition.partition_info.rank, 0);
579        }
580    }
581
582    #[test]
583    fn test_calculate_bucket_size() {
584        let sizes = vec![100, 200, 150, 300, 50];
585        let buckets = calculate_bucket_size(&sizes, 400);
586
587        assert!(!buckets.is_empty());
588
589        // Check that no bucket exceeds the target size
590        for bucket in &buckets {
591            let bucket_size: usize = bucket.iter().map(|&i| sizes[i]).sum();
592            assert!(bucket_size <= 400 || bucket.len() == 1); // Single large item allowed
593        }
594    }
595
596    /// Regression: partitioning used to scale the whole tensor by `1/world_size` on
597    /// every rank, so no rank ever held less than the full parameter.
598    #[test]
599    fn shards_are_disjoint_and_cover_every_element() {
600        let mut parameters = HashMap::new();
601        parameters.insert(
602            "w".to_string(),
603            Tensor::from_vec((0..10).map(|i| i as f32).collect(), &[10]).expect("tensor"),
604        );
605
606        let world_size = 4;
607        let mut total = 0_usize;
608        let mut seen = Vec::new();
609        for rank in 0..world_size {
610            let partitions =
611                partition_parameters(&parameters, world_size, rank).expect("partition");
612            let shard = &partitions.get("w").expect("shard").local_shard;
613            let values = shard.data_f32().expect("values");
614            total += values.len();
615            seen.extend(values);
616        }
617
618        assert_eq!(total, 10, "shard lengths must sum to the element count");
619        let expected: Vec<f32> = (0..10).map(|i| i as f32).collect();
620        assert_eq!(seen, expected, "rank r must own slice r, in order");
621    }
622
623    /// Every rank's shard must be strictly smaller than the global tensor — that is
624    /// the entire memory saving ZeRO promises.
625    #[test]
626    fn each_shard_is_smaller_than_the_global_tensor() {
627        let mut parameters = HashMap::new();
628        parameters.insert(
629            "w".to_string(),
630            Tensor::from_vec(vec![1.0_f32; 16], &[4, 4]).expect("tensor"),
631        );
632
633        let partitions = partition_parameters(&parameters, 4, 1).expect("partition");
634        let partition = partitions.get("w").expect("partition");
635        assert_eq!(partition.local_shard.len(), 4);
636        assert_eq!(partition.partition_info.start_idx, 4);
637        assert_eq!(partition.partition_info.end_idx, 8);
638        assert_eq!(partition.partition_info.global_shape, vec![4, 4]);
639        assert_eq!(partition.partition_info.local_shape, vec![4]);
640    }
641
642    /// Gathering the shards in rank order must reproduce the original tensor exactly.
643    #[test]
644    fn gather_round_trips_the_original_tensor() {
645        let original =
646            Tensor::from_vec((0..12).map(|i| i as f32 * 0.5).collect(), &[3, 4]).expect("tensor");
647        let mut parameters = HashMap::new();
648        parameters.insert("w".to_string(), original.clone());
649
650        let world_size = 5;
651        let shards: Vec<Tensor> = (0..world_size)
652            .map(|rank| {
653                partition_parameters(&parameters, world_size, rank)
654                    .expect("partition")
655                    .get("w")
656                    .expect("shard")
657                    .local_shard
658                    .clone()
659            })
660            .collect();
661
662        let gathered = gather_shards(&shards, &[3, 4]).expect("gather");
663        assert_eq!(gathered.shape(), original.shape());
664        assert_eq!(
665            gathered.data_f32().expect("values"),
666            original.data_f32().expect("values")
667        );
668    }
669
670    /// Gradients shard exactly like parameters.
671    #[test]
672    fn gradients_are_sharded_not_rescaled() {
673        let mut gradients = HashMap::new();
674        gradients.insert(
675            "g".to_string(),
676            Tensor::from_vec(vec![2.0_f32; 8], &[8]).expect("tensor"),
677        );
678
679        let buffers = partition_gradients(&gradients, 2, 0).expect("partition");
680        let local = &buffers.get("g").expect("buffer").local_gradient;
681        assert_eq!(local.len(), 4, "the shard must be half the gradient");
682        assert!(
683            local.data_f32().expect("values").iter().all(|v| (*v - 2.0).abs() < 1e-6),
684            "values must be copied verbatim, not scaled by 1/world_size"
685        );
686    }
687
688    /// A trailing rank with nothing left to own gets an empty shard, not a copy.
689    #[test]
690    fn trailing_ranks_may_own_nothing() {
691        assert_eq!(shard_range(3, 4, 3).expect("range"), (3, 3));
692        assert_eq!(shard_range(3, 4, 0).expect("range"), (0, 1));
693        assert!(shard_range(3, 0, 0).is_err());
694        assert!(shard_range(3, 2, 2).is_err());
695    }
696}