1use crate::GpuOptimError;
90use scirs2_core::ndarray::{Array1, Array2};
91
92#[derive(Debug, Clone, PartialEq)]
98pub struct CooGradient {
99 indices: Vec<usize>,
101 values: Vec<f64>,
103 dim: usize,
105}
106
107impl CooGradient {
108 pub fn new(indices: Vec<usize>, values: Vec<f64>, dim: usize) -> Result<Self, GpuOptimError> {
115 let grad = Self {
116 indices,
117 values,
118 dim,
119 };
120 grad.validate()?;
121 Ok(grad)
122 }
123
124 pub fn new_unsorted(
135 indices: Vec<usize>,
136 values: Vec<f64>,
137 dim: usize,
138 ) -> Result<Self, GpuOptimError> {
139 if indices.len() != values.len() {
140 return Err(GpuOptimError::InvalidState(format!(
141 "COO indices/values length mismatch: {} vs {}",
142 indices.len(),
143 values.len()
144 )));
145 }
146 let mut pairs: Vec<(usize, f64)> = indices.into_iter().zip(values).collect();
147 for &(idx, _) in &pairs {
148 if idx >= dim {
149 return Err(GpuOptimError::DimensionMismatch {
150 expected: vec![dim],
151 actual: vec![idx],
152 });
153 }
154 }
155 pairs.sort_by_key(|&(idx, _)| idx);
156
157 let mut out_indices: Vec<usize> = Vec::with_capacity(pairs.len());
158 let mut out_values: Vec<f64> = Vec::with_capacity(pairs.len());
159 for (idx, val) in pairs {
160 if let Some(&last) = out_indices.last() {
161 if last == idx {
162 if let Some(slot) = out_values.last_mut() {
163 *slot += val;
164 }
165 continue;
166 }
167 }
168 out_indices.push(idx);
169 out_values.push(val);
170 }
171
172 Ok(Self {
173 indices: out_indices,
174 values: out_values,
175 dim,
176 })
177 }
178
179 pub fn empty(dim: usize) -> Self {
181 Self {
182 indices: Vec::new(),
183 values: Vec::new(),
184 dim,
185 }
186 }
187
188 pub fn validate(&self) -> Result<(), GpuOptimError> {
194 if self.indices.len() != self.values.len() {
195 return Err(GpuOptimError::InvalidState(format!(
196 "COO indices/values length mismatch: {} vs {}",
197 self.indices.len(),
198 self.values.len()
199 )));
200 }
201 for pair in self.indices.windows(2) {
202 if pair[0] >= pair[1] {
203 return Err(GpuOptimError::InvalidState(format!(
204 "COO indices must be strictly ascending, found {} >= {}",
205 pair[0], pair[1]
206 )));
207 }
208 }
209 if let Some(&max_idx) = self.indices.last() {
210 if max_idx >= self.dim {
211 return Err(GpuOptimError::DimensionMismatch {
212 expected: vec![self.dim],
213 actual: vec![max_idx],
214 });
215 }
216 }
217 Ok(())
218 }
219
220 pub fn dim(&self) -> usize {
222 self.dim
223 }
224
225 pub fn nnz(&self) -> usize {
227 self.indices.len()
228 }
229
230 pub fn is_empty(&self) -> bool {
232 self.indices.is_empty()
233 }
234
235 pub fn indices(&self) -> &[usize] {
237 &self.indices
238 }
239
240 pub fn values(&self) -> &[f64] {
242 &self.values
243 }
244
245 pub fn iter(&self) -> impl Iterator<Item = (usize, f64)> + '_ {
247 self.indices
248 .iter()
249 .copied()
250 .zip(self.values.iter().copied())
251 }
252}
253
254#[derive(Debug, Clone, PartialEq)]
263pub struct CsrGradient {
264 row_offsets: Vec<usize>,
266 col_indices: Vec<usize>,
268 values: Vec<f64>,
270 shape: (usize, usize),
272}
273
274impl CsrGradient {
275 pub fn new(
281 row_offsets: Vec<usize>,
282 col_indices: Vec<usize>,
283 values: Vec<f64>,
284 shape: (usize, usize),
285 ) -> Result<Self, GpuOptimError> {
286 let grad = Self {
287 row_offsets,
288 col_indices,
289 values,
290 shape,
291 };
292 grad.validate()?;
293 Ok(grad)
294 }
295
296 pub fn empty(shape: (usize, usize)) -> Self {
298 Self {
299 row_offsets: vec![0; shape.0 + 1],
300 col_indices: Vec::new(),
301 values: Vec::new(),
302 shape,
303 }
304 }
305
306 pub fn validate(&self) -> Result<(), GpuOptimError> {
311 let (rows, cols) = self.shape;
312 if self.row_offsets.len() != rows + 1 {
313 return Err(GpuOptimError::DimensionMismatch {
314 expected: vec![rows + 1],
315 actual: vec![self.row_offsets.len()],
316 });
317 }
318 if self.col_indices.len() != self.values.len() {
319 return Err(GpuOptimError::InvalidState(format!(
320 "CSR col_indices/values length mismatch: {} vs {}",
321 self.col_indices.len(),
322 self.values.len()
323 )));
324 }
325 if self.row_offsets[0] != 0 {
326 return Err(GpuOptimError::InvalidState(format!(
327 "CSR row_offsets must start at 0, found {}",
328 self.row_offsets[0]
329 )));
330 }
331 for pair in self.row_offsets.windows(2) {
332 if pair[0] > pair[1] {
333 return Err(GpuOptimError::InvalidState(format!(
334 "CSR row_offsets must be non-decreasing, found {} > {}",
335 pair[0], pair[1]
336 )));
337 }
338 }
339 if self.row_offsets[rows] != self.col_indices.len() {
340 return Err(GpuOptimError::InvalidState(format!(
341 "CSR final row_offset {} must equal nnz {}",
342 self.row_offsets[rows],
343 self.col_indices.len()
344 )));
345 }
346 for r in 0..rows {
347 let start = self.row_offsets[r];
348 let end = self.row_offsets[r + 1];
349 let row_cols = &self.col_indices[start..end];
350 for &c in row_cols {
351 if c >= cols {
352 return Err(GpuOptimError::DimensionMismatch {
353 expected: vec![cols],
354 actual: vec![c],
355 });
356 }
357 }
358 for pair in row_cols.windows(2) {
359 if pair[0] >= pair[1] {
360 return Err(GpuOptimError::InvalidState(format!(
361 "CSR column indices within row {} must be strictly ascending, found {} >= {}",
362 r, pair[0], pair[1]
363 )));
364 }
365 }
366 }
367 Ok(())
368 }
369
370 pub fn shape(&self) -> (usize, usize) {
372 self.shape
373 }
374
375 pub fn rows(&self) -> usize {
377 self.shape.0
378 }
379
380 pub fn cols(&self) -> usize {
382 self.shape.1
383 }
384
385 pub fn nnz(&self) -> usize {
387 self.col_indices.len()
388 }
389
390 pub fn row(&self, r: usize) -> Result<(&[usize], &[f64]), GpuOptimError> {
395 if r >= self.shape.0 {
396 return Err(GpuOptimError::DimensionMismatch {
397 expected: vec![self.shape.0],
398 actual: vec![r],
399 });
400 }
401 let start = self.row_offsets[r];
402 let end = self.row_offsets[r + 1];
403 Ok((&self.col_indices[start..end], &self.values[start..end]))
404 }
405
406 pub fn iter(&self) -> impl Iterator<Item = (usize, usize, f64)> + '_ {
409 (0..self.shape.0).flat_map(move |r| {
410 let start = self.row_offsets[r];
411 let end = self.row_offsets[r + 1];
412 (start..end).map(move |k| (r, self.col_indices[k], self.values[k]))
413 })
414 }
415
416 pub fn to_coo(&self) -> CooGradient {
420 let cols = self.shape.1;
421 let mut indices = Vec::with_capacity(self.col_indices.len());
422 let mut values = Vec::with_capacity(self.values.len());
423 for (r, c, val) in self.iter() {
424 indices.push(r * cols + c);
425 values.push(val);
426 }
427 CooGradient {
428 indices,
429 values,
430 dim: self.shape.0 * cols,
431 }
432 }
433
434 pub fn from_coo(coo: &CooGradient, shape: (usize, usize)) -> Result<Self, GpuOptimError> {
442 let (rows, cols) = shape;
443 let expected_dim = rows * cols;
444 if coo.dim() != expected_dim {
445 return Err(GpuOptimError::DimensionMismatch {
446 expected: vec![expected_dim],
447 actual: vec![coo.dim()],
448 });
449 }
450 if cols == 0 {
451 if coo.nnz() == 0 {
452 return Ok(Self::empty(shape));
453 }
454 return Err(GpuOptimError::InvalidState(
455 "cannot decode a CSR gradient with zero columns but non-zero entries".to_string(),
456 ));
457 }
458
459 let mut row_offsets = vec![0usize; rows + 1];
460 let mut col_indices = Vec::with_capacity(coo.nnz());
461 let mut values = Vec::with_capacity(coo.nnz());
462 for &lin in coo.indices() {
464 let r = lin / cols;
465 row_offsets[r + 1] += 1;
466 }
467 for r in 0..rows {
468 row_offsets[r + 1] += row_offsets[r];
469 }
470 for (lin, val) in coo.iter() {
474 let c = lin % cols;
475 col_indices.push(c);
476 values.push(val);
477 }
478
479 let grad = Self {
480 row_offsets,
481 col_indices,
482 values,
483 shape,
484 };
485 grad.validate()?;
486 Ok(grad)
487 }
488}
489
490impl From<&CsrGradient> for CooGradient {
491 fn from(csr: &CsrGradient) -> Self {
492 csr.to_coo()
493 }
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum LazyAdamMode {
501 Lazy,
505 DormancyDecay,
510}
511
512#[derive(Debug, Clone, Copy)]
515pub struct SparseAdamConfig {
516 pub lr: f64,
518 pub beta1: f64,
520 pub beta2: f64,
522 pub epsilon: f64,
524 pub weight_decay: f64,
526 pub mode: LazyAdamMode,
528}
529
530impl Default for SparseAdamConfig {
531 fn default() -> Self {
532 Self {
533 lr: 1e-3,
534 beta1: 0.9,
535 beta2: 0.999,
536 epsilon: 1e-8,
537 weight_decay: 0.0,
538 mode: LazyAdamMode::Lazy,
539 }
540 }
541}
542
543fn lazy_adam_coordinate(
550 param: &mut f64,
551 m: &mut f64,
552 v: &mut f64,
553 last_step: &mut usize,
554 grad: f64,
555 t: usize,
556 cfg: &SparseAdamConfig,
557) {
558 let g = grad + cfg.weight_decay * *param;
560
561 let (decay1, decay2) = match cfg.mode {
562 LazyAdamMode::Lazy => (cfg.beta1, cfg.beta2),
563 LazyAdamMode::DormancyDecay => {
564 let gap = (t - *last_step) as i32;
565 (cfg.beta1.powi(gap), cfg.beta2.powi(gap))
566 }
567 };
568
569 *m = decay1 * *m + (1.0 - cfg.beta1) * g;
570 *v = decay2 * *v + (1.0 - cfg.beta2) * g * g;
571
572 let bias1 = 1.0 - cfg.beta1.powi(t as i32);
573 let bias2 = 1.0 - cfg.beta2.powi(t as i32);
574 let m_hat = *m / bias1;
575 let v_hat = *v / bias2;
576
577 *param -= cfg.lr * m_hat / (v_hat.sqrt() + cfg.epsilon);
578 *last_step = t;
579}
580
581#[derive(Debug, Clone)]
588pub struct SparseAdam {
589 config: SparseAdamConfig,
590 m: Vec<f64>,
591 v: Vec<f64>,
592 last_step: Vec<usize>,
593 dim: usize,
594 global_step: usize,
595}
596
597impl SparseAdam {
598 pub fn new(config: SparseAdamConfig) -> Self {
601 Self {
602 config,
603 m: Vec::new(),
604 v: Vec::new(),
605 last_step: Vec::new(),
606 dim: 0,
607 global_step: 0,
608 }
609 }
610
611 pub fn global_step(&self) -> usize {
613 self.global_step
614 }
615
616 pub fn config(&self) -> &SparseAdamConfig {
618 &self.config
619 }
620
621 fn ensure_state(&mut self, dim: usize) -> Result<(), GpuOptimError> {
622 if self.global_step == 0 {
623 self.dim = dim;
624 self.m = vec![0.0; dim];
625 self.v = vec![0.0; dim];
626 self.last_step = vec![0usize; dim];
627 } else if self.dim != dim {
628 return Err(GpuOptimError::DimensionMismatch {
629 expected: vec![self.dim],
630 actual: vec![dim],
631 });
632 }
633 Ok(())
634 }
635
636 pub fn step(
644 &mut self,
645 params: &mut Array1<f64>,
646 grad: &CooGradient,
647 ) -> Result<(), GpuOptimError> {
648 grad.validate()?;
649 if grad.dim() != params.len() {
650 return Err(GpuOptimError::DimensionMismatch {
651 expected: vec![params.len()],
652 actual: vec![grad.dim()],
653 });
654 }
655 self.ensure_state(params.len())?;
656 self.global_step += 1;
657 let t = self.global_step;
658
659 for (idx, g) in grad.iter() {
660 let mut p = params[idx];
661 lazy_adam_coordinate(
662 &mut p,
663 &mut self.m[idx],
664 &mut self.v[idx],
665 &mut self.last_step[idx],
666 g,
667 t,
668 &self.config,
669 );
670 params[idx] = p;
671 }
672 Ok(())
673 }
674}
675
676#[derive(Debug, Clone)]
681pub struct SparseAdamTable {
682 config: SparseAdamConfig,
683 m: Vec<f64>,
684 v: Vec<f64>,
685 last_step: Vec<usize>,
686 shape: (usize, usize),
687 global_step: usize,
688}
689
690impl SparseAdamTable {
691 pub fn new(config: SparseAdamConfig) -> Self {
694 Self {
695 config,
696 m: Vec::new(),
697 v: Vec::new(),
698 last_step: Vec::new(),
699 shape: (0, 0),
700 global_step: 0,
701 }
702 }
703
704 pub fn global_step(&self) -> usize {
706 self.global_step
707 }
708
709 fn ensure_state(&mut self, shape: (usize, usize)) -> Result<(), GpuOptimError> {
710 if self.global_step == 0 {
711 self.shape = shape;
712 let n = shape.0 * shape.1;
713 self.m = vec![0.0; n];
714 self.v = vec![0.0; n];
715 self.last_step = vec![0usize; n];
716 } else if self.shape != shape {
717 return Err(GpuOptimError::DimensionMismatch {
718 expected: vec![self.shape.0, self.shape.1],
719 actual: vec![shape.0, shape.1],
720 });
721 }
722 Ok(())
723 }
724
725 pub fn step(
731 &mut self,
732 params: &mut Array2<f64>,
733 grad: &CsrGradient,
734 ) -> Result<(), GpuOptimError> {
735 grad.validate()?;
736 let shape = params.dim();
737 if grad.shape() != shape {
738 return Err(GpuOptimError::DimensionMismatch {
739 expected: vec![shape.0, shape.1],
740 actual: vec![grad.shape().0, grad.shape().1],
741 });
742 }
743 self.ensure_state(shape)?;
744 self.global_step += 1;
745 let t = self.global_step;
746 let cols = shape.1;
747
748 for (r, c, g) in grad.iter() {
749 let flat = r * cols + c;
750 let mut p = params[[r, c]];
751 lazy_adam_coordinate(
752 &mut p,
753 &mut self.m[flat],
754 &mut self.v[flat],
755 &mut self.last_step[flat],
756 g,
757 t,
758 &self.config,
759 );
760 params[[r, c]] = p;
761 }
762 Ok(())
763 }
764}
765
766#[derive(Debug, Clone, Copy)]
769pub struct SparseSgdConfig {
770 pub lr: f64,
772 pub weight_decay: f64,
774 pub momentum: f64,
776 pub nesterov: bool,
778}
779
780impl Default for SparseSgdConfig {
781 fn default() -> Self {
782 Self {
783 lr: 1e-2,
784 weight_decay: 0.0,
785 momentum: 0.0,
786 nesterov: false,
787 }
788 }
789}
790
791fn sparse_sgd_coordinate(param: &mut f64, buf: &mut f64, grad: f64, cfg: &SparseSgdConfig) {
797 let mut d = grad + cfg.weight_decay * *param;
798 if cfg.momentum > 0.0 {
799 *buf = cfg.momentum * *buf + d;
800 if cfg.nesterov {
801 d += cfg.momentum * *buf;
802 } else {
803 d = *buf;
804 }
805 }
806 *param -= cfg.lr * d;
807}
808
809#[derive(Debug, Clone)]
815pub struct SparseSgd {
816 config: SparseSgdConfig,
817 momentum_buf: Vec<f64>,
818 dim: usize,
819 initialized: bool,
820}
821
822impl SparseSgd {
823 pub fn new(config: SparseSgdConfig) -> Self {
825 Self {
826 config,
827 momentum_buf: Vec::new(),
828 dim: 0,
829 initialized: false,
830 }
831 }
832
833 pub fn config(&self) -> &SparseSgdConfig {
835 &self.config
836 }
837
838 fn ensure_state(&mut self, dim: usize) -> Result<(), GpuOptimError> {
839 if !self.initialized {
840 self.dim = dim;
841 if self.config.momentum > 0.0 {
842 self.momentum_buf = vec![0.0; dim];
843 }
844 self.initialized = true;
845 } else if self.dim != dim {
846 return Err(GpuOptimError::DimensionMismatch {
847 expected: vec![self.dim],
848 actual: vec![dim],
849 });
850 }
851 Ok(())
852 }
853
854 pub fn step(
861 &mut self,
862 params: &mut Array1<f64>,
863 grad: &CooGradient,
864 ) -> Result<(), GpuOptimError> {
865 grad.validate()?;
866 if grad.dim() != params.len() {
867 return Err(GpuOptimError::DimensionMismatch {
868 expected: vec![params.len()],
869 actual: vec![grad.dim()],
870 });
871 }
872 self.ensure_state(params.len())?;
873
874 let use_momentum = self.config.momentum > 0.0;
875 for (idx, g) in grad.iter() {
876 let mut p = params[idx];
877 if use_momentum {
878 let mut buf = self.momentum_buf[idx];
879 sparse_sgd_coordinate(&mut p, &mut buf, g, &self.config);
880 self.momentum_buf[idx] = buf;
881 } else {
882 let mut scratch = 0.0;
883 sparse_sgd_coordinate(&mut p, &mut scratch, g, &self.config);
884 }
885 params[idx] = p;
886 }
887 Ok(())
888 }
889}
890
891#[derive(Debug, Clone)]
893pub struct SparseSgdTable {
894 config: SparseSgdConfig,
895 momentum_buf: Vec<f64>,
896 shape: (usize, usize),
897 initialized: bool,
898}
899
900impl SparseSgdTable {
901 pub fn new(config: SparseSgdConfig) -> Self {
903 Self {
904 config,
905 momentum_buf: Vec::new(),
906 shape: (0, 0),
907 initialized: false,
908 }
909 }
910
911 fn ensure_state(&mut self, shape: (usize, usize)) -> Result<(), GpuOptimError> {
912 if !self.initialized {
913 self.shape = shape;
914 if self.config.momentum > 0.0 {
915 self.momentum_buf = vec![0.0; shape.0 * shape.1];
916 }
917 self.initialized = true;
918 } else if self.shape != shape {
919 return Err(GpuOptimError::DimensionMismatch {
920 expected: vec![self.shape.0, self.shape.1],
921 actual: vec![shape.0, shape.1],
922 });
923 }
924 Ok(())
925 }
926
927 pub fn step(
933 &mut self,
934 params: &mut Array2<f64>,
935 grad: &CsrGradient,
936 ) -> Result<(), GpuOptimError> {
937 grad.validate()?;
938 let shape = params.dim();
939 if grad.shape() != shape {
940 return Err(GpuOptimError::DimensionMismatch {
941 expected: vec![shape.0, shape.1],
942 actual: vec![grad.shape().0, grad.shape().1],
943 });
944 }
945 self.ensure_state(shape)?;
946
947 let use_momentum = self.config.momentum > 0.0;
948 let cols = shape.1;
949 for (r, c, g) in grad.iter() {
950 let mut p = params[[r, c]];
951 if use_momentum {
952 let flat = r * cols + c;
953 let mut buf = self.momentum_buf[flat];
954 sparse_sgd_coordinate(&mut p, &mut buf, g, &self.config);
955 self.momentum_buf[flat] = buf;
956 } else {
957 let mut scratch = 0.0;
958 sparse_sgd_coordinate(&mut p, &mut scratch, g, &self.config);
959 }
960 params[[r, c]] = p;
961 }
962 Ok(())
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969 use approx::assert_relative_eq;
970 use scirs2_core::ndarray::{Array1, Array2};
971
972 struct DenseAdam {
975 m: Vec<f64>,
976 v: Vec<f64>,
977 t: usize,
978 lr: f64,
979 beta1: f64,
980 beta2: f64,
981 epsilon: f64,
982 }
983
984 impl DenseAdam {
985 fn new(dim: usize, cfg: &SparseAdamConfig) -> Self {
986 Self {
987 m: vec![0.0; dim],
988 v: vec![0.0; dim],
989 t: 0,
990 lr: cfg.lr,
991 beta1: cfg.beta1,
992 beta2: cfg.beta2,
993 epsilon: cfg.epsilon,
994 }
995 }
996
997 fn step(&mut self, params: &mut [f64], grad: &[f64]) {
998 self.t += 1;
999 let bias1 = 1.0 - self.beta1.powi(self.t as i32);
1000 let bias2 = 1.0 - self.beta2.powi(self.t as i32);
1001 for i in 0..params.len() {
1002 self.m[i] = self.beta1 * self.m[i] + (1.0 - self.beta1) * grad[i];
1003 self.v[i] = self.beta2 * self.v[i] + (1.0 - self.beta2) * grad[i] * grad[i];
1004 let m_hat = self.m[i] / bias1;
1005 let v_hat = self.v[i] / bias2;
1006 params[i] -= self.lr * m_hat / (v_hat.sqrt() + self.epsilon);
1007 }
1008 }
1009 }
1010
1011 #[test]
1014 fn test_sparse_coo_new_valid() {
1015 let g = CooGradient::new(vec![0, 2, 5], vec![1.0, -2.0, 3.0], 8).expect("valid coo");
1016 assert_eq!(g.nnz(), 3);
1017 assert_eq!(g.dim(), 8);
1018 let collected: Vec<(usize, f64)> = g.iter().collect();
1019 assert_eq!(collected, vec![(0, 1.0), (2, -2.0), (5, 3.0)]);
1020 }
1021
1022 #[test]
1023 fn test_sparse_coo_out_of_bounds_errs() {
1024 let err = CooGradient::new(vec![0, 9], vec![1.0, 2.0], 8);
1025 assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1026 }
1027
1028 #[test]
1029 fn test_sparse_coo_length_mismatch_errs() {
1030 let err = CooGradient::new(vec![0, 1, 2], vec![1.0, 2.0], 8);
1031 assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1032 }
1033
1034 #[test]
1035 fn test_sparse_coo_unsorted_errs() {
1036 let err = CooGradient::new(vec![2, 1], vec![1.0, 2.0], 8);
1037 assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1038 }
1039
1040 #[test]
1041 fn test_sparse_coo_new_unsorted_accumulates_duplicates() {
1042 let g = CooGradient::new_unsorted(vec![3, 1, 3], vec![1.0, 5.0, 2.0], 8)
1043 .expect("canonicalizes");
1044 assert_eq!(g.indices(), &[1, 3]);
1045 assert_eq!(g.values(), &[5.0, 3.0]);
1046 }
1047
1048 #[test]
1051 fn test_sparse_csr_new_valid() {
1052 let csr = CsrGradient::new(
1054 vec![0, 2, 2, 4],
1055 vec![0, 2, 1, 3],
1056 vec![1.0, 2.0, 3.0, 4.0],
1057 (3, 4),
1058 )
1059 .expect("valid csr");
1060 assert_eq!(csr.nnz(), 4);
1061 let (cols, vals) = csr.row(2).expect("row 2");
1062 assert_eq!(cols, &[1, 3]);
1063 assert_eq!(vals, &[3.0, 4.0]);
1064 }
1065
1066 #[test]
1067 fn test_sparse_csr_bad_offsets_len_errs() {
1068 let err = CsrGradient::new(vec![0, 2], vec![0, 1], vec![1.0, 2.0], (3, 4));
1069 assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1070 }
1071
1072 #[test]
1073 fn test_sparse_csr_col_out_of_bounds_errs() {
1074 let err = CsrGradient::new(vec![0, 1, 1, 1], vec![9], vec![1.0], (3, 4));
1075 assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1076 }
1077
1078 #[test]
1079 fn test_sparse_csr_unsorted_cols_errs() {
1080 let err = CsrGradient::new(vec![0, 2, 2, 2], vec![2, 1], vec![1.0, 2.0], (3, 4));
1081 assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1082 }
1083
1084 #[test]
1087 fn test_sparse_coo_csr_round_trip() {
1088 let csr = CsrGradient::new(
1089 vec![0, 2, 2, 4],
1090 vec![0, 2, 1, 3],
1091 vec![1.0, 2.0, 3.0, 4.0],
1092 (3, 4),
1093 )
1094 .expect("valid csr");
1095
1096 let coo = csr.to_coo();
1098 assert_eq!(coo.dim(), 12);
1099 assert_eq!(coo.indices(), &[0, 2, 9, 11]); let csr2 = CsrGradient::from_coo(&coo, (3, 4)).expect("rebuild csr");
1101 assert_eq!(csr, csr2);
1102
1103 let coo2 = csr2.to_coo();
1105 assert_eq!(coo, coo2);
1106
1107 let coo3: CooGradient = (&csr).into();
1109 assert_eq!(coo, coo3);
1110 }
1111
1112 #[test]
1113 fn test_sparse_csr_from_coo_dim_mismatch_errs() {
1114 let coo = CooGradient::new(vec![0, 5], vec![1.0, 2.0], 10).expect("coo");
1115 let err = CsrGradient::from_coo(&coo, (3, 4)); assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1117 }
1118
1119 #[test]
1122 fn test_sparse_sgd_touches_only_nonzero() {
1123 let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1124 let original = params.clone();
1125 let grad = CooGradient::new(vec![1, 3], vec![0.5, -0.5], 5).expect("coo");
1126
1127 let cfg = SparseSgdConfig {
1128 lr: 0.1,
1129 ..Default::default()
1130 };
1131 let mut opt = SparseSgd::new(cfg);
1132 opt.step(&mut params, &grad).expect("sgd step");
1133
1134 assert_eq!(params[0].to_bits(), original[0].to_bits());
1136 assert_eq!(params[2].to_bits(), original[2].to_bits());
1137 assert_eq!(params[4].to_bits(), original[4].to_bits());
1138
1139 assert_relative_eq!(params[1], 2.0 - 0.1 * 0.5, epsilon = 1e-12);
1141 assert_relative_eq!(params[3], 4.0 - 0.1 * -0.5, epsilon = 1e-12);
1142 }
1143
1144 #[test]
1145 fn test_sparse_sgd_momentum_lazy() {
1146 let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1148 let cfg = SparseSgdConfig {
1149 lr: 0.1,
1150 momentum: 0.9,
1151 ..Default::default()
1152 };
1153 let mut opt = SparseSgd::new(cfg);
1154
1155 let g = CooGradient::new(vec![1], vec![1.0], 3).expect("coo");
1157 opt.step(&mut params, &g).expect("step 1");
1158 assert_relative_eq!(params[1], -0.1, epsilon = 1e-12);
1160 opt.step(&mut params, &g).expect("step 2");
1161 assert_relative_eq!(params[1], -0.29, epsilon = 1e-12);
1163 assert_eq!(params[0].to_bits(), 0.0_f64.to_bits());
1165 assert_eq!(params[2].to_bits(), 0.0_f64.to_bits());
1166 }
1167
1168 #[test]
1169 fn test_sparse_sgd_table_csr() {
1170 let mut params = Array2::<f64>::zeros((2, 3));
1171 params[[0, 0]] = 1.0;
1172 params[[1, 2]] = 2.0;
1173 let original = params.clone();
1174
1175 let csr =
1177 CsrGradient::new(vec![0, 1, 2], vec![1, 0], vec![0.5, -1.0], (2, 3)).expect("csr");
1178 let cfg = SparseSgdConfig {
1179 lr: 0.1,
1180 ..Default::default()
1181 };
1182 let mut opt = SparseSgdTable::new(cfg);
1183 opt.step(&mut params, &csr).expect("table step");
1184
1185 assert_relative_eq!(params[[0, 1]], -0.05, epsilon = 1e-12);
1186 assert_relative_eq!(params[[1, 0]], 0.1, epsilon = 1e-12);
1187 assert_eq!(params[[0, 0]].to_bits(), original[[0, 0]].to_bits());
1189 assert_eq!(params[[1, 2]].to_bits(), original[[1, 2]].to_bits());
1190 assert_eq!(params[[0, 2]].to_bits(), original[[0, 2]].to_bits());
1191 }
1192
1193 fn assert_every_step_matches_dense(mode: LazyAdamMode) {
1196 let cfg = SparseAdamConfig {
1197 lr: 0.05,
1198 beta1: 0.9,
1199 beta2: 0.999,
1200 epsilon: 1e-8,
1201 weight_decay: 0.0,
1202 mode,
1203 };
1204 let dim = 4;
1205 let touched = 2usize;
1206 let mut sparse = SparseAdam::new(cfg);
1207 let mut sparse_params = Array1::from_vec(vec![0.5, -0.3, 0.7, 0.1]);
1208
1209 let mut dense = DenseAdam::new(1, &cfg);
1210 let mut dense_param = vec![sparse_params[touched]];
1211
1212 let grads = [0.4_f64, -0.2, 0.05, 0.0, 0.33, -0.7, 0.15];
1213 for (k, &g) in grads.iter().enumerate() {
1214 let coo = CooGradient::new(vec![touched], vec![g], dim).expect("coo");
1216 sparse.step(&mut sparse_params, &coo).expect("sparse step");
1217
1218 dense.step(&mut dense_param, &[g]);
1220
1221 assert_relative_eq!(
1222 sparse_params[touched],
1223 dense_param[0],
1224 epsilon = 1e-12,
1225 max_relative = 1e-10
1226 );
1227 assert_eq!(sparse.global_step(), k + 1);
1228 }
1229 }
1230
1231 #[test]
1232 fn test_sparse_adam_every_step_matches_dense_lazy() {
1233 assert_every_step_matches_dense(LazyAdamMode::Lazy);
1234 }
1235
1236 #[test]
1237 fn test_sparse_adam_every_step_matches_dense_dormancy() {
1238 assert_every_step_matches_dense(LazyAdamMode::DormancyDecay);
1239 }
1240
1241 #[test]
1244 fn test_sparse_adam_bias_correction_first_step() {
1245 let cfg = SparseAdamConfig {
1248 lr: 0.1,
1249 epsilon: 1e-8,
1250 ..Default::default()
1251 };
1252 let mut opt = SparseAdam::new(cfg);
1253 let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1254 let grad = CooGradient::new(vec![1], vec![2.0], 3).expect("coo");
1255 opt.step(&mut params, &grad).expect("step");
1256 assert_relative_eq!(params[1], -0.1, epsilon = 1e-6);
1257
1258 let mut params2 = Array1::from_vec(vec![0.0]);
1260 let mut opt2 = SparseAdam::new(SparseAdamConfig {
1261 lr: 0.1,
1262 epsilon: 1e-8,
1263 ..Default::default()
1264 });
1265 let g_pos = CooGradient::new(vec![0], vec![1.0], 1).expect("coo");
1266 let g_neg = CooGradient::new(vec![0], vec![-1.0], 1).expect("coo");
1267 opt2.step(&mut params2, &g_pos).expect("step");
1268 let after_first = params2[0];
1269 opt2.step(&mut params2, &g_neg).expect("step");
1270 assert!(params2[0] > after_first);
1272 }
1273
1274 #[test]
1277 fn test_sparse_adam_intermittent_dormancy_decay() {
1278 let cfg = SparseAdamConfig {
1279 lr: 0.05,
1280 beta1: 0.9,
1281 beta2: 0.999,
1282 epsilon: 1e-8,
1283 weight_decay: 0.0,
1284 mode: LazyAdamMode::DormancyDecay,
1285 };
1286 let dim = 2;
1287 let j = 1usize;
1288 let mut opt = SparseAdam::new(cfg);
1289 let mut params = Array1::from_vec(vec![0.0, 1.0]);
1290
1291 let mut dense = DenseAdam::new(dim, &cfg);
1293 let mut dense_params = vec![0.0, 1.0];
1294
1295 opt.step(
1297 &mut params,
1298 &CooGradient::new(vec![j], vec![0.5], dim).expect("coo"),
1299 )
1300 .expect("t1");
1301 dense.step(&mut dense_params, &[0.0, 0.5]);
1302
1303 opt.step(
1305 &mut params,
1306 &CooGradient::new(vec![0], vec![0.3], dim).expect("coo"),
1307 )
1308 .expect("t2");
1309 dense.step(&mut dense_params, &[0.3, 0.0]);
1310
1311 opt.step(
1313 &mut params,
1314 &CooGradient::new(vec![j], vec![-0.2], dim).expect("coo"),
1315 )
1316 .expect("t3");
1317 dense.step(&mut dense_params, &[0.0, -0.2]);
1318
1319 assert_relative_eq!(opt.m[j], dense.m[j], epsilon = 1e-12);
1322 assert_relative_eq!(opt.v[j], dense.v[j], epsilon = 1e-12);
1323
1324 let (b1, b2, lr, eps) = (cfg.beta1, cfg.beta2, cfg.lr, cfg.epsilon);
1327 let mut p = 1.0_f64;
1328 let mut m = 0.0_f64;
1329 let mut vv = 0.0_f64;
1330 m = b1.powi(1) * m + (1.0 - b1) * 0.5;
1332 vv = b2.powi(1) * vv + (1.0 - b2) * 0.5 * 0.5;
1333 p -= lr * (m / (1.0 - b1.powi(1))) / ((vv / (1.0 - b2.powi(1))).sqrt() + eps);
1334 m = b1.powi(2) * m + (1.0 - b1) * -0.2;
1336 vv = b2.powi(2) * vv + (1.0 - b2) * 0.2 * 0.2;
1337 p -= lr * (m / (1.0 - b1.powi(3))) / ((vv / (1.0 - b2.powi(3))).sqrt() + eps);
1338
1339 assert_relative_eq!(params[j], p, epsilon = 1e-12);
1340
1341 assert!((params[j] - dense_params[j]).abs() > 1e-9);
1343 }
1344
1345 #[test]
1348 fn test_sparse_adam_intermittent_pure_lazy() {
1349 let cfg = SparseAdamConfig {
1350 lr: 0.05,
1351 beta1: 0.9,
1352 beta2: 0.999,
1353 epsilon: 1e-8,
1354 weight_decay: 0.0,
1355 mode: LazyAdamMode::Lazy,
1356 };
1357 let dim = 2;
1358 let j = 1usize;
1359 let mut opt = SparseAdam::new(cfg);
1360 let mut params = Array1::from_vec(vec![0.0, 1.0]);
1361
1362 opt.step(
1364 &mut params,
1365 &CooGradient::new(vec![j], vec![0.5], dim).expect("coo"),
1366 )
1367 .expect("t1");
1368 opt.step(
1369 &mut params,
1370 &CooGradient::new(vec![0], vec![0.3], dim).expect("coo"),
1371 )
1372 .expect("t2");
1373 opt.step(
1374 &mut params,
1375 &CooGradient::new(vec![j], vec![-0.2], dim).expect("coo"),
1376 )
1377 .expect("t3");
1378
1379 let (b1, b2, lr, eps) = (cfg.beta1, cfg.beta2, cfg.lr, cfg.epsilon);
1382 let mut p = 1.0_f64;
1383 let mut m = 0.0_f64;
1384 let mut vv = 0.0_f64;
1385 m = b1 * m + (1.0 - b1) * 0.5;
1387 vv = b2 * vv + (1.0 - b2) * 0.5 * 0.5;
1388 p -= lr * (m / (1.0 - b1.powi(1))) / ((vv / (1.0 - b2.powi(1))).sqrt() + eps);
1389 m = b1 * m + (1.0 - b1) * -0.2;
1391 vv = b2 * vv + (1.0 - b2) * 0.2 * 0.2;
1392 p -= lr * (m / (1.0 - b1.powi(3))) / ((vv / (1.0 - b2.powi(3))).sqrt() + eps);
1393
1394 assert_relative_eq!(params[j], p, epsilon = 1e-12);
1395 }
1396
1397 #[test]
1400 fn test_sparse_adam_table_matches_1d() {
1401 let cfg = SparseAdamConfig {
1403 lr: 0.05,
1404 ..Default::default()
1405 };
1406 let mut table_opt = SparseAdamTable::new(cfg);
1407 let mut table =
1408 Array2::<f64>::from_shape_vec((1, 4), vec![0.5, -0.3, 0.7, 0.1]).expect("table");
1409
1410 let mut vec_opt = SparseAdam::new(cfg);
1411 let mut vec_params = Array1::from_vec(vec![0.5, -0.3, 0.7, 0.1]);
1412
1413 let col = 2usize;
1414 let grads = [0.4_f64, -0.2, 0.33];
1415 for &g in &grads {
1416 let csr = CsrGradient::new(vec![0, 1], vec![col], vec![g], (1, 4)).expect("csr");
1417 table_opt.step(&mut table, &csr).expect("table step");
1418
1419 let coo = CooGradient::new(vec![col], vec![g], 4).expect("coo");
1420 vec_opt.step(&mut vec_params, &coo).expect("vec step");
1421
1422 assert_relative_eq!(table[[0, col]], vec_params[col], epsilon = 1e-12);
1423 }
1424 assert_eq!(table[[0, 0]].to_bits(), 0.5_f64.to_bits());
1426 assert_eq!(table[[0, 3]].to_bits(), 0.1_f64.to_bits());
1427 }
1428
1429 #[test]
1430 fn test_sparse_adam_dim_mismatch_errs() {
1431 let mut opt = SparseAdam::new(SparseAdamConfig::default());
1432 let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1433 let grad = CooGradient::new(vec![0], vec![1.0], 5).expect("coo");
1435 let err = opt.step(&mut params, &grad);
1436 assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1437 }
1438}