1use std::collections::HashSet;
13
14use oxicuda_blas::GpuFloat;
15
16use crate::error::{SparseError, SparseResult};
17use crate::format::CsrMatrix;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum EstimationMethod {
26 UpperBound,
28 Exact,
30 Sampling {
32 sample_count: usize,
34 },
35}
36
37#[derive(Debug, Clone)]
39pub struct SpGEMMEstimate {
40 pub estimated_nnz: usize,
42 pub lower_bound: usize,
44 pub upper_bound: usize,
46 pub method: EstimationMethod,
48}
49
50const SMALL_THRESHOLD: usize = 1_000;
56
57const LARGE_THRESHOLD: usize = 100_000;
59
60const BITSET_THRESHOLD: usize = 65_536;
62
63fn validate_dims<T: GpuFloat>(a: &CsrMatrix<T>, b: &CsrMatrix<T>) -> SparseResult<()> {
68 if a.cols() != b.rows() {
69 return Err(SparseError::DimensionMismatch(format!(
70 "A.cols ({}) != B.rows ({})",
71 a.cols(),
72 b.rows()
73 )));
74 }
75 Ok(())
76}
77
78pub fn estimate_nnz_upper_bound<T: GpuFloat>(
95 a: &CsrMatrix<T>,
96 b: &CsrMatrix<T>,
97) -> SparseResult<SpGEMMEstimate> {
98 validate_dims(a, b)?;
99
100 let m = a.rows() as usize;
101 if m == 0 {
102 return Ok(SpGEMMEstimate {
103 estimated_nnz: 0,
104 lower_bound: 0,
105 upper_bound: 0,
106 method: EstimationMethod::UpperBound,
107 });
108 }
109
110 let (a_row_ptr, a_col_idx, _) = a.to_host()?;
111 let (b_row_ptr, _, _) = b.to_host()?;
112
113 let n = b.cols() as usize;
114 let mut total_upper: usize = 0;
115
116 for row in 0..m {
117 let a_start = a_row_ptr[row] as usize;
118 let a_end = a_row_ptr[row + 1] as usize;
119 let mut row_upper: usize = 0;
120
121 for &col_val in &a_col_idx[a_start..a_end] {
122 let k = col_val as usize;
123 let b_nnz_k = (b_row_ptr[k + 1] - b_row_ptr[k]) as usize;
124 row_upper += b_nnz_k;
125 }
126
127 total_upper += row_upper.min(n);
129 }
130
131 Ok(SpGEMMEstimate {
132 estimated_nnz: total_upper,
133 lower_bound: 0,
134 upper_bound: total_upper,
135 method: EstimationMethod::UpperBound,
136 })
137}
138
139pub fn count_nnz_exact<T: GpuFloat>(
155 a: &CsrMatrix<T>,
156 b: &CsrMatrix<T>,
157) -> SparseResult<SpGEMMEstimate> {
158 validate_dims(a, b)?;
159
160 let m = a.rows() as usize;
161 if m == 0 {
162 return Ok(SpGEMMEstimate {
163 estimated_nnz: 0,
164 lower_bound: 0,
165 upper_bound: 0,
166 method: EstimationMethod::Exact,
167 });
168 }
169
170 let (a_row_ptr, a_col_idx, _) = a.to_host()?;
171 let (b_row_ptr, b_col_idx, _) = b.to_host()?;
172
173 let n = b.cols() as usize;
174 let use_bitset = n <= BITSET_THRESHOLD && n > 0;
175
176 let mut total_nnz: usize = 0;
177
178 if use_bitset {
179 let words = n.div_ceil(64);
181 let mut bitset = vec![0u64; words];
182
183 for row in 0..m {
184 for w in bitset.iter_mut() {
186 *w = 0;
187 }
188
189 let a_start = a_row_ptr[row] as usize;
190 let a_end = a_row_ptr[row + 1] as usize;
191
192 for &a_col in &a_col_idx[a_start..a_end] {
193 let k = a_col as usize;
194 let b_start = b_row_ptr[k] as usize;
195 let b_end = b_row_ptr[k + 1] as usize;
196
197 for &b_col in &b_col_idx[b_start..b_end] {
198 let col = b_col as usize;
199 let word = col / 64;
200 let bit = col % 64;
201 bitset[word] |= 1u64 << bit;
202 }
203 }
204
205 let row_nnz: u32 = bitset.iter().map(|w| w.count_ones()).sum();
207 total_nnz += row_nnz as usize;
208 }
209 } else {
210 let mut set = HashSet::new();
212
213 for row in 0..m {
214 set.clear();
215
216 let a_start = a_row_ptr[row] as usize;
217 let a_end = a_row_ptr[row + 1] as usize;
218
219 for &a_col in &a_col_idx[a_start..a_end] {
220 let k = a_col as usize;
221 let b_start = b_row_ptr[k] as usize;
222 let b_end = b_row_ptr[k + 1] as usize;
223
224 for &b_col in &b_col_idx[b_start..b_end] {
225 set.insert(b_col);
226 }
227 }
228
229 total_nnz += set.len();
230 }
231 }
232
233 Ok(SpGEMMEstimate {
234 estimated_nnz: total_nnz,
235 lower_bound: total_nnz,
236 upper_bound: total_nnz,
237 method: EstimationMethod::Exact,
238 })
239}
240
241pub fn estimate_nnz_sampling<T: GpuFloat>(
256 a: &CsrMatrix<T>,
257 b: &CsrMatrix<T>,
258) -> SparseResult<SpGEMMEstimate> {
259 validate_dims(a, b)?;
260
261 let m = a.rows() as usize;
262 if m == 0 {
263 return Ok(SpGEMMEstimate {
264 estimated_nnz: 0,
265 lower_bound: 0,
266 upper_bound: 0,
267 method: EstimationMethod::Sampling { sample_count: 0 },
268 });
269 }
270
271 let (a_row_ptr, a_col_idx, _) = a.to_host()?;
272 let (b_row_ptr, b_col_idx, _) = b.to_host()?;
273
274 let n = b.cols() as usize;
275
276 let sample_count = (m as f64).sqrt().ceil() as usize;
278 let sample_count = sample_count.max(1).min(m);
279
280 let sample_indices = deterministic_sample_indices(m, sample_count);
282 let actual_sample_count = sample_indices.len();
283
284 let use_bitset = n <= BITSET_THRESHOLD && n > 0;
286 let mut row_nnz_samples = Vec::with_capacity(actual_sample_count);
287
288 if use_bitset {
289 let words = n.div_ceil(64);
290 let mut bitset = vec![0u64; words];
291
292 for &row in &sample_indices {
293 for w in bitset.iter_mut() {
294 *w = 0;
295 }
296
297 let a_start = a_row_ptr[row] as usize;
298 let a_end = a_row_ptr[row + 1] as usize;
299
300 for &a_col in &a_col_idx[a_start..a_end] {
301 let k = a_col as usize;
302 let b_start = b_row_ptr[k] as usize;
303 let b_end = b_row_ptr[k + 1] as usize;
304
305 for &b_col in &b_col_idx[b_start..b_end] {
306 let col = b_col as usize;
307 let word = col / 64;
308 let bit = col % 64;
309 bitset[word] |= 1u64 << bit;
310 }
311 }
312
313 let row_nnz: u32 = bitset.iter().map(|w| w.count_ones()).sum();
314 row_nnz_samples.push(row_nnz as f64);
315 }
316 } else {
317 let mut set = HashSet::new();
318
319 for &row in &sample_indices {
320 set.clear();
321
322 let a_start = a_row_ptr[row] as usize;
323 let a_end = a_row_ptr[row + 1] as usize;
324
325 for &a_col in &a_col_idx[a_start..a_end] {
326 let k = a_col as usize;
327 let b_start = b_row_ptr[k] as usize;
328 let b_end = b_row_ptr[k + 1] as usize;
329
330 for &b_col in &b_col_idx[b_start..b_end] {
331 set.insert(b_col);
332 }
333 }
334
335 row_nnz_samples.push(set.len() as f64);
336 }
337 }
338
339 let (mean, std_dev) = compute_mean_stddev(&row_nnz_samples);
341
342 let estimated_nnz = (mean * m as f64).round() as usize;
343
344 let margin = 2.0 * std_dev * (m as f64) / (actual_sample_count as f64).sqrt();
346 let lower_raw = (mean * m as f64 - margin).max(0.0);
347 let upper_raw = mean * m as f64 + margin;
348
349 let max_possible = m.saturating_mul(n);
351 let lower_bound = lower_raw.round() as usize;
352 let upper_bound = (upper_raw.round() as usize).min(max_possible);
353
354 Ok(SpGEMMEstimate {
355 estimated_nnz: estimated_nnz.min(max_possible),
356 lower_bound,
357 upper_bound,
358 method: EstimationMethod::Sampling {
359 sample_count: actual_sample_count,
360 },
361 })
362}
363
364pub fn estimate_spgemm_memory<T: GpuFloat>(
372 a: &CsrMatrix<T>,
373 b: &CsrMatrix<T>,
374) -> SparseResult<SpGEMMEstimate> {
375 auto_estimate_spgemm(a, b)
376}
377
378pub fn auto_estimate_spgemm<T: GpuFloat>(
390 a: &CsrMatrix<T>,
391 b: &CsrMatrix<T>,
392) -> SparseResult<SpGEMMEstimate> {
393 validate_dims(a, b)?;
394
395 let m = a.rows() as usize;
396
397 if m < SMALL_THRESHOLD {
398 count_nnz_exact(a, b)
399 } else if m <= LARGE_THRESHOLD {
400 estimate_nnz_sampling(a, b)
401 } else {
402 estimate_nnz_upper_bound(a, b)
403 }
404}
405
406fn deterministic_sample_indices(total: usize, count: usize) -> Vec<usize> {
412 if count >= total {
413 return (0..total).collect();
414 }
415 if count == 0 {
416 return Vec::new();
417 }
418
419 let mut indices = Vec::with_capacity(count);
420 for i in 0..count {
421 let idx = i * total / count;
423 indices.push(idx);
424 }
425 indices
426}
427
428fn compute_mean_stddev(samples: &[f64]) -> (f64, f64) {
433 if samples.is_empty() {
434 return (0.0, 0.0);
435 }
436
437 let n = samples.len() as f64;
438 let mean = samples.iter().sum::<f64>() / n;
439
440 if samples.len() == 1 {
441 return (mean, 0.0);
442 }
443
444 let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
445 (mean, variance.sqrt())
446}
447
448#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[cfg(feature = "gpu-tests")]
462 fn try_make_csr(
463 rows: u32,
464 cols: u32,
465 row_ptr: &[i32],
466 col_idx: &[i32],
467 values: &[f64],
468 ) -> SparseResult<CsrMatrix<f64>> {
469 CsrMatrix::from_host(rows, cols, row_ptr, col_idx, values)
470 }
471
472 #[test]
477 fn deterministic_sample_full() {
478 let indices = deterministic_sample_indices(5, 10);
479 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
480 }
481
482 #[test]
483 fn deterministic_sample_subset() {
484 let indices = deterministic_sample_indices(10, 3);
485 assert_eq!(indices.len(), 3);
486 assert_eq!(indices, vec![0, 3, 6]);
488 }
489
490 #[test]
491 fn deterministic_sample_zero() {
492 let indices = deterministic_sample_indices(10, 0);
493 assert!(indices.is_empty());
494 }
495
496 #[test]
497 fn deterministic_sample_one() {
498 let indices = deterministic_sample_indices(100, 1);
499 assert_eq!(indices, vec![0]);
500 }
501
502 #[test]
503 fn mean_stddev_empty() {
504 let (mean, std) = compute_mean_stddev(&[]);
505 assert!((mean - 0.0).abs() < f64::EPSILON);
506 assert!((std - 0.0).abs() < f64::EPSILON);
507 }
508
509 #[test]
510 fn mean_stddev_single() {
511 let (mean, std) = compute_mean_stddev(&[42.0]);
512 assert!((mean - 42.0).abs() < f64::EPSILON);
513 assert!((std - 0.0).abs() < f64::EPSILON);
514 }
515
516 #[test]
517 fn mean_stddev_basic() {
518 let (mean, std) = compute_mean_stddev(&[2.0, 4.0, 6.0, 8.0]);
520 assert!((mean - 5.0).abs() < 1e-10);
521 let expected_std = (20.0_f64 / 3.0).sqrt();
522 assert!((std - expected_std).abs() < 1e-10);
523 }
524
525 #[test]
526 fn mean_stddev_uniform() {
527 let (mean, std) = compute_mean_stddev(&[7.0, 7.0, 7.0]);
529 assert!((mean - 7.0).abs() < f64::EPSILON);
530 assert!((std - 0.0).abs() < 1e-15);
531 }
532
533 #[test]
534 fn estimation_method_debug() {
535 let m = EstimationMethod::UpperBound;
536 let s = format!("{:?}", m);
537 assert!(s.contains("UpperBound"));
538
539 let m2 = EstimationMethod::Sampling { sample_count: 10 };
540 let s2 = format!("{:?}", m2);
541 assert!(s2.contains("10"));
542 }
543
544 #[test]
545 fn estimation_method_eq() {
546 assert_eq!(EstimationMethod::Exact, EstimationMethod::Exact);
547 assert_ne!(EstimationMethod::Exact, EstimationMethod::UpperBound);
548 assert_eq!(
549 EstimationMethod::Sampling { sample_count: 5 },
550 EstimationMethod::Sampling { sample_count: 5 }
551 );
552 assert_ne!(
553 EstimationMethod::Sampling { sample_count: 5 },
554 EstimationMethod::Sampling { sample_count: 6 }
555 );
556 }
557
558 #[test]
559 fn estimate_clone() {
560 let est = SpGEMMEstimate {
561 estimated_nnz: 100,
562 lower_bound: 80,
563 upper_bound: 120,
564 method: EstimationMethod::Exact,
565 };
566 let cloned = est.clone();
567 assert_eq!(cloned.estimated_nnz, 100);
568 assert_eq!(cloned.lower_bound, 80);
569 assert_eq!(cloned.upper_bound, 120);
570 assert_eq!(cloned.method, EstimationMethod::Exact);
571 }
572
573 #[cfg(feature = "gpu-tests")]
578 mod gpu {
579 use super::*;
580
581 fn gpu_context() -> Option<oxicuda_driver::Context> {
597 oxicuda_driver::init().ok()?;
598 if oxicuda_driver::Device::count().ok()? == 0 {
599 return None;
600 }
601 let dev = oxicuda_driver::Device::get(0).ok()?;
602 oxicuda_driver::Context::new(&dev).ok()
603 }
604
605 fn identity_3x3() -> SparseResult<CsrMatrix<f64>> {
607 try_make_csr(3, 3, &[0, 1, 2, 3], &[0, 1, 2], &[1.0, 1.0, 1.0])
608 }
609
610 fn diagonal_4x4() -> SparseResult<CsrMatrix<f64>> {
612 try_make_csr(4, 4, &[0, 1, 2, 3, 4], &[0, 1, 2, 3], &[2.0, 3.0, 4.0, 5.0])
613 }
614
615 fn dense_2x3() -> SparseResult<CsrMatrix<f64>> {
619 try_make_csr(
620 2,
621 3,
622 &[0, 3, 5],
623 &[0, 1, 2, 0, 1],
624 &[1.0, 2.0, 3.0, 4.0, 5.0],
625 )
626 }
627
628 fn sparse_3x2() -> SparseResult<CsrMatrix<f64>> {
633 try_make_csr(3, 2, &[0, 1, 2, 4], &[0, 1, 0, 1], &[1.0, 2.0, 3.0, 4.0])
634 }
635
636 fn single_row() -> SparseResult<CsrMatrix<f64>> {
638 try_make_csr(1, 3, &[0, 2], &[0, 2], &[1.0, 2.0])
639 }
640
641 fn single_col() -> SparseResult<CsrMatrix<f64>> {
646 try_make_csr(3, 1, &[0, 1, 2, 3], &[0, 0, 0], &[1.0, 2.0, 3.0])
647 }
648
649 #[test]
650 fn upper_bound_identity() {
651 let Some(_ctx) = gpu_context() else {
652 return;
653 };
654 let a = identity_3x3().expect("test: GPU context required");
655 let est = estimate_nnz_upper_bound(&a, &a)
656 .expect("test: upper bound estimation should succeed");
657 assert!(est.upper_bound >= 3);
659 assert_eq!(est.method, EstimationMethod::UpperBound);
660 }
661
662 #[test]
663 fn exact_identity() {
664 let Some(_ctx) = gpu_context() else {
665 return;
666 };
667 let a = identity_3x3().expect("test: GPU context required");
668 let est = count_nnz_exact(&a, &a).expect("test: exact counting should succeed");
669 assert_eq!(est.estimated_nnz, 3);
670 assert_eq!(est.lower_bound, 3);
671 assert_eq!(est.upper_bound, 3);
672 assert_eq!(est.method, EstimationMethod::Exact);
673 }
674
675 #[test]
676 fn exact_diagonal() {
677 let Some(_ctx) = gpu_context() else {
678 return;
679 };
680 let a = diagonal_4x4().expect("test: GPU context required");
681 let est = count_nnz_exact(&a, &a).expect("test: exact counting should succeed");
682 assert_eq!(est.estimated_nnz, 4);
684 }
685
686 #[test]
687 fn upper_bound_ge_exact() {
688 let Some(_ctx) = gpu_context() else {
689 return;
690 };
691 let a = dense_2x3().expect("test: GPU context required");
692 let b = sparse_3x2().expect("test: GPU context required");
693
694 let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
695 let upper = estimate_nnz_upper_bound(&a, &b).expect("test: upper bound should succeed");
696
697 assert!(
698 upper.upper_bound >= exact.estimated_nnz,
699 "upper bound ({}) must be >= exact ({})",
700 upper.upper_bound,
701 exact.estimated_nnz
702 );
703 }
704
705 #[test]
706 fn sampling_identity() {
707 let Some(_ctx) = gpu_context() else {
708 return;
709 };
710 let a = identity_3x3().expect("test: GPU context required");
711 let est =
712 estimate_nnz_sampling(&a, &a).expect("test: sampling estimation should succeed");
713 assert!(est.estimated_nnz >= 1);
716 assert!(est.upper_bound >= est.lower_bound);
717 matches!(est.method, EstimationMethod::Sampling { .. });
718 }
719
720 #[test]
721 fn auto_small_uses_exact() {
722 let Some(_ctx) = gpu_context() else {
723 return;
724 };
725 let a = identity_3x3().expect("test: GPU context required");
726 let est = auto_estimate_spgemm(&a, &a).expect("test: auto estimation should succeed");
727 assert_eq!(est.method, EstimationMethod::Exact);
729 assert_eq!(est.estimated_nnz, 3);
730 }
731
732 #[test]
733 fn dimension_mismatch_error() {
734 let Some(_ctx) = gpu_context() else {
735 return;
736 };
737 let a = dense_2x3().expect("test: GPU context required");
738 let b = dense_2x3().expect("test: GPU context required");
739 let err = estimate_nnz_upper_bound(&a, &b);
741 assert!(err.is_err());
742 let err = count_nnz_exact(&a, &b);
743 assert!(err.is_err());
744 let err = estimate_nnz_sampling(&a, &b);
745 assert!(err.is_err());
746 let err = auto_estimate_spgemm(&a, &b);
747 assert!(err.is_err());
748 }
749
750 #[test]
751 fn single_row_matrix() {
752 let Some(_ctx) = gpu_context() else {
753 return;
754 };
755 let a = single_row().expect("test: GPU context required");
756 let b = try_make_csr(3, 2, &[0, 1, 1, 2], &[0, 1], &[1.0, 1.0])
759 .expect("test: GPU context required");
760 let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
763 assert_eq!(exact.estimated_nnz, 2);
764 }
765
766 #[test]
767 fn single_col_times_single_row() {
768 let Some(_ctx) = gpu_context() else {
769 return;
770 };
771 let a = single_col().expect("test: GPU context required");
772 let b = single_row().expect("test: GPU context required");
773 let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
777 assert_eq!(exact.estimated_nnz, 6);
778
779 let upper = estimate_nnz_upper_bound(&a, &b).expect("test: upper bound should succeed");
780 assert!(upper.upper_bound >= 6);
781 }
782
783 #[test]
784 fn estimate_spgemm_memory_alias() {
785 let Some(_ctx) = gpu_context() else {
786 return;
787 };
788 let a = identity_3x3().expect("test: GPU context required");
789 let est =
790 estimate_spgemm_memory(&a, &a).expect("test: memory estimation should succeed");
791 assert_eq!(est.estimated_nnz, 3);
793 }
794
795 #[test]
796 fn sampling_bounds_consistency() {
797 let Some(_ctx) = gpu_context() else {
798 return;
799 };
800 let a = diagonal_4x4().expect("test: GPU context required");
801 let est = estimate_nnz_sampling(&a, &a).expect("test: sampling should succeed");
802 assert!(
803 est.lower_bound <= est.estimated_nnz,
804 "lower_bound ({}) should be <= estimated_nnz ({})",
805 est.lower_bound,
806 est.estimated_nnz
807 );
808 assert!(
809 est.estimated_nnz <= est.upper_bound,
810 "estimated_nnz ({}) should be <= upper_bound ({})",
811 est.estimated_nnz,
812 est.upper_bound
813 );
814 }
815 }
816}