Skip to main content

scirs2_core/distributed/
compression.rs

1//! Gradient compression algorithms for distributed training.
2//!
3//! Implements Top-K sparsification (Stich et al. 2018), Random-K sparsification,
4//! 1-bit gradient quantization (Seide et al. 2014), and PowerSGD-style low-rank
5//! approximation.
6
7use crate::error::{CoreError, CoreResult};
8
9// ─────────────────────────────────────────────────────────────────────────────
10// Top-K Compressor
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Configuration for Top-K gradient sparsification (Stich et al. 2018).
14#[non_exhaustive]
15#[derive(Debug, Clone)]
16pub struct TopKConfig {
17    /// Fraction of gradient elements to keep (0, 1].  Default: 0.01.
18    pub k_fraction: f64,
19    /// Whether to accumulate the residual error and add it back before
20    /// the next compression step (error feedback).  Default: `true`.
21    pub use_error_feedback: bool,
22}
23
24impl Default for TopKConfig {
25    fn default() -> Self {
26        TopKConfig {
27            k_fraction: 0.01,
28            use_error_feedback: true,
29        }
30    }
31}
32
33/// Top-K gradient sparsification compressor.
34///
35/// Keeps the `k = ceil(n * k_fraction)` elements with the largest absolute
36/// value.  Optionally accumulates the discarded residual for the next round
37/// (error feedback).
38pub struct TopKCompressor {
39    config: TopKConfig,
40    /// Residual error accumulated from previous rounds (same length as
41    /// gradient).
42    error_feedback: Vec<f64>,
43}
44
45impl TopKCompressor {
46    /// Create a new compressor for gradients of length `n_params`.
47    pub fn new(n_params: usize, config: TopKConfig) -> Self {
48        TopKCompressor {
49            config,
50            error_feedback: vec![0.0; n_params],
51        }
52    }
53
54    /// Compress a gradient vector.
55    ///
56    /// Returns `(indices, values)` of the top-k elements by absolute value.
57    /// If error feedback is enabled the residual from the *previous* round is
58    /// added to `gradient` before selecting the top-k elements, and the new
59    /// residual is stored internally.
60    pub fn compress(&mut self, gradient: &[f64]) -> CoreResult<(Vec<usize>, Vec<f64>)> {
61        if gradient.is_empty() {
62            return Ok((vec![], vec![]));
63        }
64        let n = gradient.len();
65        if n != self.error_feedback.len() {
66            return Err(CoreError::ShapeError(crate::error::ErrorContext::new(
67                format!(
68                    "TopKCompressor: gradient len {} != initialised len {}",
69                    n,
70                    self.error_feedback.len()
71                ),
72            )));
73        }
74        // Apply error feedback
75        let mut g: Vec<f64> = if self.config.use_error_feedback {
76            gradient
77                .iter()
78                .zip(self.error_feedback.iter())
79                .map(|(a, b)| a + b)
80                .collect()
81        } else {
82            gradient.to_vec()
83        };
84
85        let k = ((n as f64 * self.config.k_fraction).ceil() as usize)
86            .max(1)
87            .min(n);
88
89        // Compute indices sorted by |g| descending
90        let mut order: Vec<usize> = (0..n).collect();
91        order.sort_unstable_by(|&a, &b| {
92            g[b].abs()
93                .partial_cmp(&g[a].abs())
94                .unwrap_or(std::cmp::Ordering::Equal)
95        });
96
97        let top_k: Vec<usize> = order[..k].to_vec();
98
99        // Build sparse output
100        let mut indices: Vec<usize> = top_k.clone();
101        indices.sort_unstable();
102        let values: Vec<f64> = indices.iter().map(|&i| g[i]).collect();
103
104        // Update error feedback residual
105        if self.config.use_error_feedback {
106            for &i in &indices {
107                g[i] = 0.0;
108            }
109            self.error_feedback = g; // residual = g - sparse(g)
110        }
111
112        Ok((indices, values))
113    }
114
115    /// Decompress a sparse gradient back to a dense vector.
116    pub fn decompress(indices: &[usize], values: &[f64], n_total: usize) -> CoreResult<Vec<f64>> {
117        if indices.len() != values.len() {
118            return Err(CoreError::InvalidArgument(crate::error::ErrorContext::new(
119                "decompress: indices and values length mismatch",
120            )));
121        }
122        let mut out = vec![0.0f64; n_total];
123        for (&i, &v) in indices.iter().zip(values.iter()) {
124            if i >= n_total {
125                return Err(CoreError::InvalidArgument(crate::error::ErrorContext::new(
126                    format!(
127                        "decompress: index {} out of bounds for n_total {}",
128                        i, n_total
129                    ),
130                )));
131            }
132            out[i] = v;
133        }
134        Ok(out)
135    }
136
137    /// Compression ratio: `n_total / k`.
138    pub fn compression_ratio(&self, n_total: usize) -> f64 {
139        let k = ((n_total as f64 * self.config.k_fraction).ceil() as usize)
140            .max(1)
141            .min(n_total);
142        n_total as f64 / k as f64
143    }
144}
145
146// ─────────────────────────────────────────────────────────────────────────────
147// Random-K Compressor
148// ─────────────────────────────────────────────────────────────────────────────
149
150/// Random-K gradient sparsification.
151///
152/// Keeps a randomly-selected subset of gradient elements (uniform without
153/// replacement).
154pub struct RandomKCompressor {
155    /// Fraction of elements to retain.
156    k_fraction: f64,
157}
158
159impl RandomKCompressor {
160    /// Create a new random-K compressor.
161    pub fn new(k_fraction: f64) -> Self {
162        RandomKCompressor { k_fraction }
163    }
164
165    /// Compress a gradient using a simple LCG PRNG seeded by `seed`.
166    ///
167    /// Returns `(indices, values)` of the randomly-selected elements.
168    pub fn compress(&self, gradient: &[f64], seed: u64) -> CoreResult<(Vec<usize>, Vec<f64>)> {
169        if gradient.is_empty() {
170            return Ok((vec![], vec![]));
171        }
172        let n = gradient.len();
173        let k = ((n as f64 * self.k_fraction).ceil() as usize).max(1).min(n);
174
175        // Fisher-Yates partial shuffle using a minimal LCG PRNG
176        // (no external rand dependency — pure COOLJAPAN policy)
177        let mut indices: Vec<usize> = (0..n).collect();
178        let mut rng_state = seed.wrapping_add(1);
179        let lcg_a: u64 = 6364136223846793005;
180        let lcg_c: u64 = 1442695040888963407;
181
182        for i in 0..k {
183            rng_state = rng_state.wrapping_mul(lcg_a).wrapping_add(lcg_c);
184            let j = (rng_state >> 33) as usize % (n - i) + i;
185            indices.swap(i, j);
186        }
187
188        let mut selected: Vec<usize> = indices[..k].to_vec();
189        selected.sort_unstable();
190        let values: Vec<f64> = selected.iter().map(|&i| gradient[i]).collect();
191
192        Ok((selected, values))
193    }
194
195    /// Decompress a sparse gradient back to a dense vector.
196    pub fn decompress(indices: &[usize], values: &[f64], n_total: usize) -> CoreResult<Vec<f64>> {
197        if indices.len() != values.len() {
198            return Err(CoreError::InvalidArgument(crate::error::ErrorContext::new(
199                "decompress: indices and values length mismatch",
200            )));
201        }
202        let mut out = vec![0.0f64; n_total];
203        for (&i, &v) in indices.iter().zip(values.iter()) {
204            if i >= n_total {
205                return Err(CoreError::InvalidArgument(crate::error::ErrorContext::new(
206                    format!(
207                        "decompress: index {} out of bounds for n_total {}",
208                        i, n_total
209                    ),
210                )));
211            }
212            out[i] = v;
213        }
214        Ok(out)
215    }
216}
217
218// ─────────────────────────────────────────────────────────────────────────────
219// 1-Bit Quantizer
220// ─────────────────────────────────────────────────────────────────────────────
221
222/// 1-bit gradient quantization (Seide et al. 2014).
223///
224/// Each element is mapped to ±mean_abs(gradient).
225pub struct OneBitQuantizer;
226
227impl OneBitQuantizer {
228    /// Quantize a gradient vector.
229    ///
230    /// Returns `(bit_words, scale)` where each bit in `bit_words` corresponds
231    /// to one gradient element: `1` = positive (≥ 0), `0` = negative (< 0).
232    /// `scale` is `mean(|gradient|)`.
233    pub fn quantize(gradient: &[f64]) -> CoreResult<(Vec<u64>, f64)> {
234        if gradient.is_empty() {
235            return Ok((vec![], 0.0));
236        }
237        let scale = gradient.iter().map(|x| x.abs()).sum::<f64>() / gradient.len() as f64;
238        let n_words = gradient.len().div_ceil(64);
239        let mut bits = vec![0u64; n_words];
240        for (i, &v) in gradient.iter().enumerate() {
241            if v >= 0.0 {
242                bits[i / 64] |= 1u64 << (i % 64);
243            }
244        }
245        Ok((bits, scale))
246    }
247
248    /// Dequantize: reconstruct gradient from bit words and scale.
249    ///
250    /// Returns a vector of length `n` where positive bits map to `+scale` and
251    /// zero bits map to `-scale`.
252    pub fn dequantize(bits: &[u64], scale: f64, n: usize) -> Vec<f64> {
253        (0..n)
254            .map(|i| {
255                if (bits[i / 64] >> (i % 64)) & 1 == 1 {
256                    scale
257                } else {
258                    -scale
259                }
260            })
261            .collect()
262    }
263
264    /// Mean absolute error between original and quantized gradient.
265    pub fn quantization_error(original: &[f64], quantized: &[f64]) -> f64 {
266        if original.is_empty() {
267            return 0.0;
268        }
269        let len = original.len().min(quantized.len());
270        let total: f64 = original[..len]
271            .iter()
272            .zip(quantized[..len].iter())
273            .map(|(a, b)| (a - b).abs())
274            .sum();
275        total / len as f64
276    }
277}
278
279// ─────────────────────────────────────────────────────────────────────────────
280// PowerSGD Low-Rank Approximation
281// ─────────────────────────────────────────────────────────────────────────────
282
283/// Configuration for PowerSGD low-rank gradient compression.
284#[non_exhaustive]
285#[derive(Debug, Clone)]
286pub struct PowerSgdConfig {
287    /// Target rank for the low-rank decomposition.  Default: 4.
288    pub rank: usize,
289    /// Number of power-iteration steps to improve the approximation.  Default: 1.
290    pub n_power_iter: usize,
291    /// Reuse the left factor as momentum across rounds.  Default: `true`.
292    pub reuse_momentum: bool,
293}
294
295impl Default for PowerSgdConfig {
296    fn default() -> Self {
297        PowerSgdConfig {
298            rank: 4,
299            n_power_iter: 1,
300            reuse_momentum: true,
301        }
302    }
303}
304
305/// Compress a gradient matrix `G` (m × n) into P (m × r) and Q (n × r)
306/// such that `G ≈ P · Q^T`.
307///
308/// Implementation follows the PowerSGD algorithm (Vogels et al. 2019):
309/// 1. Initialize Q with a small random matrix (Gaussian approximation via
310///    deterministic seeded values — pure Rust, no external crate).
311/// 2. Power-iterate: P = G·Q  (ortho-normalised), Q = G^T·P  (ortho-normalised).
312///
313/// # Arguments
314/// * `gradient_matrix` — row-major `[m][n]` gradient matrix.
315/// * `config` — rank and iteration settings.
316///
317/// # Returns
318/// `(P, Q)` where P is `m × r` and Q is `n × r`.
319pub fn low_rank_compress(
320    gradient_matrix: &[Vec<f64>],
321    config: &PowerSgdConfig,
322) -> CoreResult<(Vec<Vec<f64>>, Vec<Vec<f64>>)> {
323    let m = gradient_matrix.len();
324    if m == 0 {
325        return Ok((vec![], vec![]));
326    }
327    let n = gradient_matrix[0].len();
328    if n == 0 {
329        return Ok((vec![vec![]; m], vec![]));
330    }
331    let r = config.rank.min(m.min(n));
332    if r == 0 {
333        return Err(CoreError::InvalidArgument(crate::error::ErrorContext::new(
334            "low_rank_compress: rank must be >= 1",
335        )));
336    }
337
338    // ── Initialise Q (n × r) with a deterministic pseudo-random matrix ────
339    let mut q = vec![vec![0.0f64; r]; n];
340    let mut rng: u64 = 0xDEAD_BEEF_1234_5678;
341    let lcg_a: u64 = 6364136223846793005;
342    let lcg_c: u64 = 1442695040888963407;
343    for row in q.iter_mut() {
344        for x in row.iter_mut() {
345            rng = rng.wrapping_mul(lcg_a).wrapping_add(lcg_c);
346            // Map to [-1, 1]
347            *x = (rng as i64 as f64) / (i64::MAX as f64);
348        }
349    }
350    orthonormalize_cols(&mut q)?;
351
352    // ── Power iterations ──────────────────────────────────────────────────
353    // Invariant: Q is always column-orthonormal after each iteration.
354    // The approximation is G ≈ P·Q^T where:
355    //   P = G·Q   (NOT orthonormalized — P captures the actual projection)
356    //   Q is column-orthonormal
357    //
358    // When r = min(m,n) and Q's column space spans the full space, Q·Q^T = I
359    // and P·Q^T = G·Q·Q^T = G exactly.
360    let mut p = vec![vec![0.0f64; r]; m];
361    for _ in 0..config.n_power_iter.max(1) {
362        // P = G · Q   (m × r)
363        for i in 0..m {
364            for j in 0..r {
365                p[i][j] = gradient_matrix[i]
366                    .iter()
367                    .enumerate()
368                    .map(|(k, &g)| g * q[k][j])
369                    .sum();
370            }
371        }
372        orthonormalize_cols(&mut p)?;
373
374        // Q = G^T · P   (n × r)
375        for k in 0..n {
376            for j in 0..r {
377                q[k][j] = gradient_matrix
378                    .iter()
379                    .enumerate()
380                    .map(|(i, row)| row[k] * p[i][j])
381                    .sum();
382            }
383        }
384        // Keep Q orthonormal throughout (required for P·Q^T = G·Q·Q^T ≈ G)
385        orthonormalize_cols(&mut q)?;
386    }
387
388    // Final P = G · Q  (where Q is orthonormal)
389    // G_approx = P · Q^T = G · Q · Q^T
390    // When r = n, Q·Q^T = I and P·Q^T = G exactly.
391    for i in 0..m {
392        for j in 0..r {
393            p[i][j] = gradient_matrix[i]
394                .iter()
395                .enumerate()
396                .map(|(k, &g)| g * q[k][j])
397                .sum();
398        }
399    }
400
401    Ok((p, q))
402}
403
404/// Decompress a low-rank gradient approximation back to a full matrix.
405///
406/// Computes `G_hat = P · Q^T` where P is `m × r` and Q is `n × r`.
407pub fn low_rank_decompress(p: &[Vec<f64>], q: &[Vec<f64>]) -> Vec<Vec<f64>> {
408    let m = p.len();
409    if m == 0 {
410        return vec![];
411    }
412    let r = p[0].len();
413    let n = q.len();
414    let mut out = vec![vec![0.0f64; n]; m];
415    for i in 0..m {
416        for k in 0..n {
417            let dot: f64 = (0..r).map(|j| p[i][j] * q[k][j]).sum();
418            out[i][k] = dot;
419        }
420    }
421    out
422}
423
424// ─────────────────────────────────────────────────────────────────────────────
425// Helpers
426// ─────────────────────────────────────────────────────────────────────────────
427
428/// Modified Gram-Schmidt orthonormalisation of the *columns* of a row-major
429/// matrix.
430///
431/// The matrix is `rows × cols`.  Uses the numerically more stable modified
432/// Gram-Schmidt variant.  If a column becomes numerically zero (norm < ε) it
433/// is replaced by the first canonical basis vector not already represented in
434/// the span, ensuring the output always has `cols` orthonormal columns even
435/// for rank-deficient inputs.
436fn orthonormalize_cols(mat: &mut Vec<Vec<f64>>) -> CoreResult<()> {
437    let rows = mat.len();
438    if rows == 0 {
439        return Ok(());
440    }
441    let cols = mat[0].len();
442    if cols == 0 {
443        return Ok(());
444    }
445
446    for j in 0..cols {
447        // Modified Gram-Schmidt: subtract projections one at a time (more
448        // stable than classical GS).
449        for k in 0..j {
450            // Re-compute dot product with the already-orthonormalized column k.
451            let dot: f64 = (0..rows).map(|i| mat[i][j] * mat[i][k]).sum();
452            for i in 0..rows {
453                let prev = mat[i][k];
454                mat[i][j] -= dot * prev;
455            }
456        }
457        // Normalise column j.
458        let norm: f64 = (0..rows).map(|i| mat[i][j] * mat[i][j]).sum::<f64>().sqrt();
459        if norm < 1e-10 {
460            // Column is linearly dependent — replace with a canonical basis
461            // vector not already in the span.
462            let mut replaced = false;
463            'outer: for candidate in 0..rows {
464                // Check if e_candidate is linearly independent from current cols
465                for k in 0..j {
466                    // If column k has a large component along e_candidate, skip.
467                    if mat[candidate][k].abs() > 0.9 {
468                        continue 'outer;
469                    }
470                }
471                for i in 0..rows {
472                    mat[i][j] = if i == candidate { 1.0 } else { 0.0 };
473                }
474                replaced = true;
475                break;
476            }
477            if !replaced {
478                // Fallback: use e_{j % rows}
479                for i in 0..rows {
480                    mat[i][j] = if i == j % rows { 1.0 } else { 0.0 };
481                }
482            }
483        } else {
484            for i in 0..rows {
485                mat[i][j] /= norm;
486            }
487        }
488    }
489    Ok(())
490}
491
492// ─────────────────────────────────────────────────────────────────────────────
493// Tests
494// ─────────────────────────────────────────────────────────────────────────────
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    // ── Top-K ────────────────────────────────────────────────────────────
501
502    #[test]
503    fn test_topk_keeps_exactly_k_elements() {
504        let cfg = TopKConfig {
505            k_fraction: 0.25,
506            use_error_feedback: false,
507        };
508        let mut comp = TopKCompressor::new(8, cfg);
509        let grad = vec![0.1, 0.5, 0.3, 0.9, 0.2, 0.8, 0.4, 0.6];
510        let (indices, values) = comp.compress(&grad).expect("compress failed");
511        // k = ceil(8 * 0.25) = 2
512        assert_eq!(indices.len(), 2);
513        assert_eq!(values.len(), 2);
514        // The top-2 elements by abs value are 0.9 (idx 3) and 0.8 (idx 5)
515        assert!(indices.contains(&3));
516        assert!(indices.contains(&5));
517    }
518
519    #[test]
520    fn test_topk_error_feedback_reduces_over_rounds() {
521        // With error feedback the residual from round 1 boosts small
522        // gradients in round 2, so the total compressed signal should grow.
523        let cfg = TopKConfig {
524            k_fraction: 0.5,
525            use_error_feedback: true,
526        };
527        let mut comp = TopKCompressor::new(4, cfg);
528        let grad = vec![1.0, 0.1, 0.1, 0.1];
529        let (_, v1) = comp.compress(&grad).expect("compress round 1 failed");
530        let (_, v2) = comp.compress(&grad).expect("compress round 2 failed");
531        // The accumulated residual from round 1 should contribute to round 2
532        let sum1: f64 = v1.iter().map(|x| x.abs()).sum();
533        let sum2: f64 = v2.iter().map(|x| x.abs()).sum();
534        // With feedback the second round's selected values should include
535        // previously discarded signal — sum2 != 0.
536        assert!(sum1 > 0.0);
537        assert!(sum2 > 0.0);
538    }
539
540    #[test]
541    fn test_randomk_correct_size() {
542        let comp = RandomKCompressor::new(0.1);
543        let grad: Vec<f64> = (0..100).map(|i| i as f64).collect();
544        let (indices, values) = comp.compress(&grad, 42).expect("compress failed");
545        // k = ceil(100 * 0.1) = 10
546        assert_eq!(indices.len(), 10);
547        assert_eq!(values.len(), 10);
548        // No duplicate indices
549        let mut sorted = indices.clone();
550        sorted.dedup();
551        assert_eq!(sorted.len(), indices.len());
552    }
553
554    #[test]
555    fn test_1bit_quantize_dequantize_preserves_sign() {
556        let gradient = vec![-3.0, 1.5, -0.5, 2.0, -0.1, 0.8];
557        let (bits, scale) = OneBitQuantizer::quantize(&gradient).expect("quantize failed");
558        let dequantized = OneBitQuantizer::dequantize(&bits, scale, gradient.len());
559        for (orig, deq) in gradient.iter().zip(dequantized.iter()) {
560            // Sign must be preserved
561            let same_sign = (orig >= &0.0 && deq >= &0.0) || (orig < &0.0 && deq < &0.0);
562            assert!(same_sign, "sign mismatch: orig={} deq={}", orig, deq);
563        }
564    }
565
566    #[test]
567    fn test_low_rank_compress_decompress_close_for_full_rank() {
568        // Use a genuinely full-rank matrix (circulant / diagonal-dominant).
569        // For a rank-r matrix, a rank-r decomposition should be exact.
570        // We construct a rank-2 matrix explicitly and verify that rank-2
571        // compression reconstructs it to machine precision.
572        let m = 4;
573        let n = 4;
574        // rank-2 matrix: G = u1*v1^T + u2*v2^T
575        let u1 = [1.0, 2.0, 3.0, 4.0];
576        let v1 = [5.0, -1.0, 2.0, 0.5];
577        let u2 = [0.5, -1.0, 1.5, -2.0];
578        let v2 = [1.0, 3.0, -2.0, 4.0];
579        let grad: Vec<Vec<f64>> = (0..m)
580            .map(|i| (0..n).map(|j| u1[i] * v1[j] + u2[i] * v2[j]).collect())
581            .collect();
582        let cfg = PowerSgdConfig {
583            rank: 2,          // exact for a rank-2 matrix
584            n_power_iter: 10, // enough iterations to converge
585            reuse_momentum: false,
586        };
587        let (p, q) = low_rank_compress(&grad, &cfg).expect("compress failed");
588        let approx = low_rank_decompress(&p, &q);
589        let mut max_err = 0.0f64;
590        for i in 0..m {
591            for j in 0..n {
592                let err = (grad[i][j] - approx[i][j]).abs();
593                max_err = max_err.max(err);
594            }
595        }
596        assert!(max_err < 1e-6, "max reconstruction error = {}", max_err);
597    }
598
599    #[test]
600    fn test_powersgd_config_defaults() {
601        let cfg = PowerSgdConfig::default();
602        assert_eq!(cfg.rank, 4);
603        assert_eq!(cfg.n_power_iter, 1);
604        assert!(cfg.reuse_momentum);
605    }
606
607    #[test]
608    fn test_compression_ratio_computation() {
609        let cfg = TopKConfig {
610            k_fraction: 0.01,
611            use_error_feedback: true,
612        };
613        let comp = TopKCompressor::new(1000, cfg);
614        // k = ceil(1000 * 0.01) = 10 → ratio = 100
615        let ratio = comp.compression_ratio(1000);
616        assert!((ratio - 100.0).abs() < 1e-9);
617    }
618
619    #[test]
620    fn test_empty_gradient_handling() {
621        let cfg = TopKConfig::default();
622        let mut comp = TopKCompressor::new(0, cfg);
623        let (idx, val) = comp.compress(&[]).expect("compress empty failed");
624        assert!(idx.is_empty());
625        assert!(val.is_empty());
626
627        let comp2 = RandomKCompressor::new(0.1);
628        let (idx2, val2) = comp2.compress(&[], 0).expect("compress empty failed");
629        assert!(idx2.is_empty());
630        assert!(val2.is_empty());
631
632        let (bits, scale) = OneBitQuantizer::quantize(&[]).expect("quantize empty failed");
633        assert!(bits.is_empty());
634        assert_eq!(scale, 0.0);
635    }
636}