Skip to main content

trustformers_optim/
compression.rs

1use anyhow::{anyhow, Result};
2use std::collections::HashMap;
3use trustformers_core::tensor::Tensor;
4
5/// Gradient compression algorithms for distributed training.
6///
7/// Reduces communication overhead by compressing gradients before
8/// sending them across the network in distributed training setups.
9
10#[derive(Debug, Clone)]
11pub enum CompressionMethod {
12    /// Top-K sparsification: only send the K largest gradients
13    TopK { k: usize },
14    /// Random-K sparsification: randomly sample K gradients
15    RandomK { k: usize },
16    /// Threshold-based sparsification: send gradients above threshold
17    Threshold { threshold: f32 },
18    /// Quantization-based compression
19    Quantization { bits: u8 },
20    /// SignSGD: send only the sign of gradients
21    SignSGD,
22    /// Error feedback compression
23    ErrorFeedback { base_method: Box<CompressionMethod> },
24}
25
26#[derive(Debug)]
27pub struct GradientCompressor {
28    method: CompressionMethod,
29    compression_ratio: f32,
30    error_buffer: HashMap<String, Vec<f32>>, // For error feedback
31}
32
33/// One gradient after compression, in the form it would be put on the wire.
34///
35/// `indices` is **empty for dense methods** (quantization, SignSGD): those transmit a
36/// value for every coordinate, so an explicit `0..n` index list would be pure
37/// overhead. Sparse methods (top-k, random-k, threshold) list the coordinates they
38/// kept.
39///
40/// `values` always holds the *dequantized* `f32` values so callers can do arithmetic
41/// with them directly; `value_bits` records how many bits each value actually needs on
42/// the wire, which is what [`CompressedGradient::payload_bytes`] uses.
43#[derive(Debug, Clone)]
44pub struct CompressedGradient {
45    /// Kept coordinates, or empty for a dense positional payload.
46    pub indices: Vec<usize>,
47    /// Dequantized values, in `indices` order (or coordinate order when dense).
48    pub values: Vec<f32>,
49    /// Number of elements in the uncompressed gradient.
50    pub original_size: usize,
51    /// Fraction of the original payload this representation occupies.
52    pub compression_ratio: f32,
53    /// Bits each value needs on the wire (`32` for an untransformed `f32`, `8` for
54    /// 8-bit quantization, `1` for a sign).
55    pub value_bits: u8,
56}
57
58impl CompressedGradient {
59    /// Bytes this representation would put on the wire.
60    ///
61    /// Indices cost `size_of::<usize>()` each; values cost `value_bits` bits each,
62    /// rounded up to whole bytes.
63    pub fn payload_bytes(&self) -> usize {
64        let index_bytes = self.indices.len() * std::mem::size_of::<usize>();
65        let value_bits = self.values.len() * self.value_bits as usize;
66        index_bytes + value_bits.div_ceil(8)
67    }
68
69    /// Bytes the uncompressed `f32` gradient would occupy.
70    pub fn dense_bytes(&self) -> usize {
71        self.original_size * std::mem::size_of::<f32>()
72    }
73}
74
75impl GradientCompressor {
76    pub fn new(method: CompressionMethod) -> Self {
77        Self {
78            method,
79            compression_ratio: 0.0,
80            error_buffer: HashMap::new(),
81        }
82    }
83
84    pub fn compress(
85        &mut self,
86        gradients: &HashMap<String, Tensor>,
87    ) -> Result<HashMap<String, CompressedGradient>> {
88        let mut compressed = HashMap::new();
89
90        for (name, gradient) in gradients.iter() {
91            let grad_data = gradient.data()?;
92            let compressed_grad = self.compress_single(&grad_data, name)?;
93            compressed.insert(name.clone(), compressed_grad);
94        }
95
96        Ok(compressed)
97    }
98
99    pub fn decompress(
100        &self,
101        compressed: &HashMap<String, CompressedGradient>,
102    ) -> Result<HashMap<String, Tensor>> {
103        let mut decompressed = HashMap::new();
104
105        for (name, compressed_grad) in compressed.iter() {
106            let grad_data = self.decompress_single(compressed_grad)?;
107            decompressed.insert(name.clone(), Tensor::new(grad_data)?);
108        }
109
110        Ok(decompressed)
111    }
112
113    fn compress_single(
114        &mut self,
115        gradient: &[f32],
116        param_name: &str,
117    ) -> Result<CompressedGradient> {
118        match self.method.clone() {
119            CompressionMethod::TopK { k } => self.compress_topk(gradient, k),
120            CompressionMethod::RandomK { k } => self.compress_randomk(gradient, k),
121            CompressionMethod::Threshold { threshold } => {
122                self.compress_threshold(gradient, threshold)
123            },
124            CompressionMethod::Quantization { bits } => self.compress_quantized(gradient, bits),
125            CompressionMethod::SignSGD => self.compress_signsgd(gradient),
126            CompressionMethod::ErrorFeedback { base_method } => {
127                self.compress_with_error_feedback(gradient, param_name, &base_method)
128            },
129        }
130    }
131
132    fn compress_topk(&self, gradient: &[f32], k: usize) -> Result<CompressedGradient> {
133        let mut indexed_grads: Vec<(usize, f32)> =
134            gradient.iter().enumerate().map(|(i, &val)| (i, val.abs())).collect();
135
136        // Sort by absolute value in descending order
137        indexed_grads.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
138
139        let k = k.min(gradient.len());
140        let mut indices = Vec::with_capacity(k);
141        let mut values = Vec::with_capacity(k);
142
143        for i in 0..k {
144            let (idx, _) = indexed_grads[i];
145            indices.push(idx);
146            values.push(gradient[idx]);
147        }
148
149        Ok(CompressedGradient {
150            indices,
151            values,
152            original_size: gradient.len(),
153            compression_ratio: k as f32 / gradient.len() as f32,
154            value_bits: 32,
155        })
156    }
157
158    fn compress_randomk(&self, gradient: &[f32], k: usize) -> Result<CompressedGradient> {
159        use std::collections::HashSet;
160
161        let k = k.min(gradient.len());
162        let mut indices = Vec::with_capacity(k);
163        let mut values = Vec::with_capacity(k);
164        let mut selected_indices = HashSet::new();
165
166        // Simple random sampling (in practice, would use proper RNG)
167        let step = gradient.len() / k.max(1);
168        for i in (0..gradient.len()).step_by(step) {
169            if indices.len() < k && !selected_indices.contains(&i) {
170                indices.push(i);
171                values.push(gradient[i]);
172                selected_indices.insert(i);
173            }
174        }
175
176        Ok(CompressedGradient {
177            indices,
178            values,
179            original_size: gradient.len(),
180            compression_ratio: k as f32 / gradient.len() as f32,
181            value_bits: 32,
182        })
183    }
184
185    fn compress_threshold(&self, gradient: &[f32], threshold: f32) -> Result<CompressedGradient> {
186        let mut indices = Vec::new();
187        let mut values = Vec::new();
188
189        for (i, &val) in gradient.iter().enumerate() {
190            if val.abs() > threshold {
191                indices.push(i);
192                values.push(val);
193            }
194        }
195
196        let compression_ratio = indices.len() as f32 / gradient.len() as f32;
197
198        Ok(CompressedGradient {
199            indices,
200            values,
201            original_size: gradient.len(),
202            compression_ratio,
203            value_bits: 32,
204        })
205    }
206
207    fn compress_quantized(&self, gradient: &[f32], bits: u8) -> Result<CompressedGradient> {
208        let levels = (1 << bits) - 1;
209        let min_val = gradient.iter().fold(f32::INFINITY, |a, &b| a.min(b));
210        let max_val = gradient.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
211        let scale = (max_val - min_val) / levels as f32;
212
213        let mut quantized_values = Vec::with_capacity(gradient.len());
214
215        for &val in gradient.iter() {
216            let quantized = if scale > 0.0 { ((val - min_val) / scale).round() } else { 0.0 };
217            quantized_values.push(min_val + quantized * scale);
218        }
219
220        Ok(CompressedGradient {
221            // Dense: every coordinate is transmitted, so no index list is needed.
222            indices: Vec::new(),
223            values: quantized_values,
224            original_size: gradient.len(),
225            compression_ratio: (bits as f32) / 32.0, // Assuming f32 gradients
226            value_bits: bits,
227        })
228    }
229
230    fn compress_signsgd(&self, gradient: &[f32]) -> Result<CompressedGradient> {
231        let values: Vec<f32> =
232            gradient.iter().map(|&val| if val >= 0.0 { 1.0 } else { -1.0 }).collect();
233
234        Ok(CompressedGradient {
235            // Dense: one sign bit per coordinate, so no index list is needed.
236            indices: Vec::new(),
237            values,
238            original_size: gradient.len(),
239            compression_ratio: 1.0 / 32.0, // 1 bit vs 32 bits
240            value_bits: 1,
241        })
242    }
243
244    fn compress_with_error_feedback(
245        &mut self,
246        gradient: &[f32],
247        param_name: &str,
248        base_method: &Box<CompressionMethod>,
249    ) -> Result<CompressedGradient> {
250        // Add accumulated error to current gradient
251        let mut corrected_gradient = gradient.to_vec();
252
253        if let Some(error) = self.error_buffer.get(param_name) {
254            for i in 0..corrected_gradient.len().min(error.len()) {
255                corrected_gradient[i] += error[i];
256            }
257        }
258
259        // Compress the corrected gradient
260        let mut temp_compressor = GradientCompressor::new((**base_method).clone());
261        let compressed = temp_compressor.compress_single(&corrected_gradient, param_name)?;
262
263        // Compute and store the new error
264        let decompressed = self.decompress_single(&compressed)?;
265        let mut new_error = vec![0.0; corrected_gradient.len()];
266
267        for i in 0..new_error.len() {
268            new_error[i] = corrected_gradient[i] - decompressed.get(i).copied().unwrap_or(0.0);
269        }
270
271        self.error_buffer.insert(param_name.to_string(), new_error);
272
273        Ok(compressed)
274    }
275
276    fn decompress_single(&self, compressed: &CompressedGradient) -> Result<Vec<f32>> {
277        let mut gradient = vec![0.0; compressed.original_size];
278
279        if compressed.indices.is_empty() {
280            // Dense payload: values are already in coordinate order.
281            if compressed.values.len() != compressed.original_size && !compressed.values.is_empty()
282            {
283                return Err(anyhow!(
284                    "dense compressed payload has {} values but the gradient has {}",
285                    compressed.values.len(),
286                    compressed.original_size
287                ));
288            }
289            gradient[..compressed.values.len()].copy_from_slice(&compressed.values);
290            return Ok(gradient);
291        }
292
293        for (&i, &value) in compressed.indices.iter().zip(compressed.values.iter()) {
294            if i < gradient.len() {
295                gradient[i] = value;
296            }
297        }
298
299        Ok(gradient)
300    }
301
302    pub fn get_compression_ratio(&self) -> f32 {
303        self.compression_ratio
304    }
305
306    pub fn reset_error_buffer(&mut self) {
307        self.error_buffer.clear();
308    }
309}
310
311/// Distributed gradient aggregator with compression support
312#[derive(Debug)]
313pub struct CompressedAllReduce {
314    compressor: GradientCompressor,
315    world_size: usize,
316}
317
318impl CompressedAllReduce {
319    pub fn new(compression_method: CompressionMethod, world_size: usize) -> Self {
320        Self {
321            compressor: GradientCompressor::new(compression_method),
322            world_size,
323        }
324    }
325
326    /// Compresses, aggregates and averages gradients across the process group.
327    ///
328    /// # Aggregation semantics
329    ///
330    /// No transport is wired up in this crate, so there is exactly one honest thing
331    /// this can do:
332    ///
333    /// * `world_size == 1` — the local rank *is* the group. The gradient is compressed
334    ///   and decompressed (so the caller sees the real compression error) and returned.
335    /// * `world_size > 1` with peer contributions supplied — see
336    ///   [`CompressedAllReduce::all_reduce_with_peers`], which performs the real sum.
337    /// * `world_size > 1` with no peers — an error. Scaling the local gradient by
338    ///   `world_size` (what this used to do) is not an approximation of a sum across
339    ///   workers; it is numerically worse than doing nothing.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error when `world_size > 1`, because no communicator is configured.
344    pub fn all_reduce(
345        &mut self,
346        gradients: &HashMap<String, Tensor>,
347    ) -> Result<HashMap<String, Tensor>> {
348        if self.world_size != 1 {
349            return Err(anyhow!(
350                "CompressedAllReduce has no communicator: cannot aggregate across {} ranks. \
351                 Use all_reduce_with_peers to supply the other ranks' compressed gradients.",
352                self.world_size
353            ));
354        }
355
356        let compressed = self.compressor.compress(gradients)?;
357        self.compressor.decompress(&compressed)
358    }
359
360    /// Performs a genuine compressed all-reduce given the peers' compressed gradients.
361    ///
362    /// `peers` holds one map per *other* rank. Each parameter's contributions are
363    /// summed in the dense domain and divided by the number of contributing ranks, so
364    /// the result is the true average of the compressed gradients — not a rescaling of
365    /// the local one.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error when a peer supplies a gradient of a different length, or when
370    /// the number of contributions does not match `world_size`.
371    pub fn all_reduce_with_peers(
372        &mut self,
373        gradients: &HashMap<String, Tensor>,
374        peers: &[HashMap<String, CompressedGradient>],
375    ) -> Result<HashMap<String, Tensor>> {
376        if peers.len() + 1 != self.world_size {
377            return Err(anyhow!(
378                "expected {} peer contributions for world_size {}, got {}",
379                self.world_size - 1,
380                self.world_size,
381                peers.len()
382            ));
383        }
384
385        let local = self.compressor.compress(gradients)?;
386        let mut summed: HashMap<String, Vec<f32>> = HashMap::new();
387
388        for contribution in std::iter::once(&local).chain(peers.iter()) {
389            for (name, compressed) in contribution {
390                let dense = self.compressor.decompress_single(compressed)?;
391                match summed.get_mut(name) {
392                    Some(accumulator) => {
393                        if accumulator.len() != dense.len() {
394                            return Err(anyhow!(
395                                "rank contributions for '{name}' disagree on length: {} vs {}",
396                                accumulator.len(),
397                                dense.len()
398                            ));
399                        }
400                        for (slot, value) in accumulator.iter_mut().zip(dense.iter()) {
401                            *slot += value;
402                        }
403                    },
404                    None => {
405                        summed.insert(name.clone(), dense);
406                    },
407                }
408            }
409        }
410
411        let divisor = self.world_size as f32;
412        let mut result = HashMap::new();
413        for (name, mut values) in summed {
414            for value in values.iter_mut() {
415                *value /= divisor;
416            }
417            let shape = gradients
418                .get(&name)
419                .map(|t| t.shape().to_vec())
420                .unwrap_or_else(|| vec![values.len()]);
421            result.insert(name, Tensor::from_vec(values, &shape)?);
422        }
423
424        Ok(result)
425    }
426
427    /// Compresses `gradients` for transmission to the other ranks.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error when a gradient cannot be read.
432    pub fn compress_for_transmission(
433        &mut self,
434        gradients: &HashMap<String, Tensor>,
435    ) -> Result<HashMap<String, CompressedGradient>> {
436        self.compressor.compress(gradients)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_topk_compression() {
446        let mut compressor = GradientCompressor::new(CompressionMethod::TopK { k: 3 });
447        let gradient = vec![0.1, 0.8, 0.2, -0.9, 0.3, -0.1];
448
449        let compressed =
450            compressor.compress_single(&gradient, "test").expect("Operation failed in test");
451
452        assert_eq!(compressed.indices.len(), 3);
453        assert_eq!(compressed.values.len(), 3);
454        assert_eq!(compressed.original_size, 6);
455        assert!(compressed.compression_ratio < 1.0);
456
457        // Should include the largest magnitude values: -0.9, 0.8, 0.3
458        assert!(compressed.values.contains(&-0.9));
459        assert!(compressed.values.contains(&0.8));
460        assert!(compressed.values.contains(&0.3));
461    }
462
463    #[test]
464    fn test_threshold_compression() {
465        let mut compressor =
466            GradientCompressor::new(CompressionMethod::Threshold { threshold: 0.5 });
467        let gradient = vec![0.1, 0.8, 0.2, -0.9, 0.3, -0.1];
468
469        let compressed =
470            compressor.compress_single(&gradient, "test").expect("Operation failed in test");
471
472        // Only values with abs > 0.5 should be included: 0.8, -0.9
473        assert_eq!(compressed.values.len(), 2);
474        assert!(compressed.values.contains(&0.8));
475        assert!(compressed.values.contains(&-0.9));
476    }
477
478    #[test]
479    fn test_signsgd_compression() {
480        let mut compressor = GradientCompressor::new(CompressionMethod::SignSGD);
481        let gradient = vec![0.1, -0.8, 0.2, -0.9, 0.3, -0.1];
482
483        let compressed =
484            compressor.compress_single(&gradient, "test").expect("Operation failed in test");
485
486        assert_eq!(compressed.values.len(), gradient.len());
487        assert_eq!(compressed.compression_ratio, 1.0 / 32.0);
488
489        // All values should be either 1.0 or -1.0
490        for &val in &compressed.values {
491            assert!(val == 1.0 || val == -1.0);
492        }
493    }
494
495    #[test]
496    fn test_compression_decompression_roundtrip() {
497        let mut compressor = GradientCompressor::new(CompressionMethod::TopK { k: 3 });
498        let mut gradients = HashMap::new();
499
500        let grad_data = vec![0.1, 0.8, 0.2, -0.9, 0.3, -0.1];
501        gradients.insert(
502            "param1".to_string(),
503            Tensor::new(grad_data.clone()).expect("Failed to create tensor"),
504        );
505
506        let compressed = compressor.compress(&gradients).expect("Operation failed in test");
507        let decompressed = compressor.decompress(&compressed).expect("Operation failed in test");
508
509        let result_data = decompressed
510            .get("param1")
511            .expect("Key not found")
512            .data()
513            .expect("Operation failed in test");
514        assert_eq!(result_data.len(), grad_data.len());
515
516        // Check that the largest values are preserved
517        assert!(result_data.contains(&0.8));
518        assert!(result_data.contains(&-0.9));
519    }
520
521    #[test]
522    fn test_compressed_all_reduce() {
523        // Regression: `all_reduce` used to multiply the *local* gradient by
524        // `world_size` and call the result an aggregation across workers.
525        let mut all_reduce = CompressedAllReduce::new(CompressionMethod::TopK { k: 2 }, 4);
526
527        let mut gradients = HashMap::new();
528        gradients.insert(
529            "param1".to_string(),
530            Tensor::from_vec(vec![0.4_f32, 0.8, 0.2, -0.6], &[4]).expect("tensor"),
531        );
532
533        assert!(
534            all_reduce.all_reduce(&gradients).is_err(),
535            "aggregating across 4 ranks with no communicator must be an error"
536        );
537    }
538
539    #[test]
540    fn test_single_rank_all_reduce_is_a_round_trip() {
541        let mut all_reduce = CompressedAllReduce::new(CompressionMethod::SignSGD, 1);
542
543        let mut gradients = HashMap::new();
544        gradients.insert(
545            "param1".to_string(),
546            Tensor::from_vec(vec![0.4_f32, -0.8, 0.2, -0.6], &[4]).expect("tensor"),
547        );
548
549        let result = all_reduce.all_reduce(&gradients).expect("single-rank all-reduce");
550        let values = result.get("param1").expect("param1").data_f32().expect("data");
551        assert_eq!(
552            values,
553            vec![1.0, -1.0, 1.0, -1.0],
554            "SignSGD keeps only the sign"
555        );
556    }
557
558    #[test]
559    fn test_all_reduce_with_peers_sums_and_averages() {
560        let mut all_reduce = CompressedAllReduce::new(CompressionMethod::SignSGD, 3);
561
562        let mut local = HashMap::new();
563        local.insert(
564            "w".to_string(),
565            Tensor::from_vec(vec![1.0_f32, 1.0], &[2]).expect("tensor"),
566        );
567
568        // Two peers, both reporting the opposite sign on the second coordinate.
569        let mut peer = GradientCompressor::new(CompressionMethod::SignSGD);
570        let mut peer_gradients = HashMap::new();
571        peer_gradients.insert(
572            "w".to_string(),
573            Tensor::from_vec(vec![1.0_f32, -1.0], &[2]).expect("tensor"),
574        );
575        let peer_payload = peer.compress(&peer_gradients).expect("compress");
576
577        let result = all_reduce
578            .all_reduce_with_peers(&local, &[peer_payload.clone(), peer_payload])
579            .expect("all reduce");
580        let values = result.get("w").expect("w").data_f32().expect("data");
581
582        // Coordinate 0: (1 + 1 + 1)/3 = 1. Coordinate 1: (1 − 1 − 1)/3 = −1/3.
583        assert!((values[0] - 1.0).abs() < 1e-6, "{}", values[0]);
584        assert!((values[1] + 1.0 / 3.0).abs() < 1e-6, "{}", values[1]);
585    }
586
587    #[test]
588    fn test_payload_bytes_reflects_the_method() {
589        let mut compressor = GradientCompressor::new(CompressionMethod::SignSGD);
590        let gradient = vec![0.1_f32; 64];
591        let compressed = compressor.compress_single(&gradient, "w").expect("compress");
592
593        // One sign bit per coordinate, no index list.
594        assert!(compressed.indices.is_empty());
595        assert_eq!(compressed.payload_bytes(), 8);
596        assert_eq!(compressed.dense_bytes(), 256);
597
598        let mut sparse = GradientCompressor::new(CompressionMethod::TopK { k: 4 });
599        let compressed = sparse.compress_single(&gradient, "w").expect("compress");
600        // 4 indices (8 bytes each) plus 4 f32 values.
601        assert_eq!(compressed.payload_bytes(), 4 * 8 + 4 * 4);
602    }
603
604    #[test]
605    fn test_dense_round_trip_preserves_positions() {
606        let mut compressor = GradientCompressor::new(CompressionMethod::Quantization { bits: 8 });
607        let gradient = vec![-1.0_f32, -0.5, 0.0, 0.5, 1.0];
608        let compressed = compressor.compress_single(&gradient, "w").expect("compress");
609        assert!(
610            compressed.indices.is_empty(),
611            "a dense method needs no index list"
612        );
613
614        let restored = compressor.decompress_single(&compressed).expect("decompress");
615        assert_eq!(restored.len(), gradient.len());
616        for (a, b) in restored.iter().zip(gradient.iter()) {
617            assert!((a - b).abs() < 0.02, "8-bit round trip: {a} vs {b}");
618        }
619    }
620}