Skip to main content

oxicuda_sparse/ops/
spgemm_estimate.rs

1//! SpGEMM memory estimation for `C = A * B`.
2//!
3//! Pre-computes the output nnz (number of non-zeros) to avoid over-allocation
4//! in the symbolic phase. Three estimation strategies are provided:
5//!
6//! 1. **Upper bound** -- O(nnz(A)) time, overestimates due to column collisions.
7//! 2. **Hash-based exact** -- O(flops) time, exact count using hash sets / bitsets.
8//! 3. **Sampling-based** -- sqrt(nrows) sampled rows, extrapolated with bounds.
9//!
10//! [`auto_estimate_spgemm`] selects the best strategy based on matrix dimensions.
11
12use std::collections::HashSet;
13
14use oxicuda_blas::GpuFloat;
15
16use crate::error::{SparseError, SparseResult};
17use crate::format::CsrMatrix;
18
19// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// The method used for SpGEMM nnz estimation.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum EstimationMethod {
26    /// Per-row upper bound summing nnz of accessed B-rows.
27    UpperBound,
28    /// Exact count using hash sets (or bitsets for narrow matrices).
29    Exact,
30    /// Sampling-based estimate with the given number of sampled rows.
31    Sampling {
32        /// Number of rows actually sampled.
33        sample_count: usize,
34    },
35}
36
37/// Result of a SpGEMM nnz estimation.
38#[derive(Debug, Clone)]
39pub struct SpGEMMEstimate {
40    /// Best estimate of nnz(C).
41    pub estimated_nnz: usize,
42    /// Lower bound on nnz(C).
43    pub lower_bound: usize,
44    /// Upper bound on nnz(C).
45    pub upper_bound: usize,
46    /// Which estimation method was used.
47    pub method: EstimationMethod,
48}
49
50// ---------------------------------------------------------------------------
51// Threshold constants
52// ---------------------------------------------------------------------------
53
54/// Matrices with fewer rows than this use exact counting in auto mode.
55const SMALL_THRESHOLD: usize = 1_000;
56
57/// Matrices with more rows than this use upper-bound in auto mode.
58const LARGE_THRESHOLD: usize = 100_000;
59
60/// Maximum column count for which we use a bitset instead of a hash set.
61const BITSET_THRESHOLD: usize = 65_536;
62
63// ---------------------------------------------------------------------------
64// Dimension validation helper
65// ---------------------------------------------------------------------------
66
67fn 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
78// ---------------------------------------------------------------------------
79// 1. Upper-bound estimation
80// ---------------------------------------------------------------------------
81
82/// Computes an upper bound on nnz(C) where `C = A * B`.
83///
84/// For each row `i` of A, sums `nnz(B[j, :])` for every column `j` where
85/// `A[i, j] != 0`. This overestimates because it does not account for column
86/// collisions (multiple A-row entries mapping to the same C-column).
87///
88/// Time complexity: O(nnz(A)).
89///
90/// # Errors
91///
92/// Returns [`SparseError::DimensionMismatch`] if `A.cols() != B.rows()`.
93/// Returns [`SparseError::Cuda`] on device-to-host transfer failure.
94pub 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        // Clamp to n (a single row cannot have more than n non-zeros)
128        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
139// ---------------------------------------------------------------------------
140// 2. Hash-based exact count
141// ---------------------------------------------------------------------------
142
143/// Computes the exact nnz(C) where `C = A * B`.
144///
145/// For each row of C, uses a hash set (or a bitset for narrow matrices) to
146/// track unique column indices produced by the row-wise dot product.
147///
148/// Time complexity: O(flops) where flops = sum of nnz(A\[i,:\]) * nnz(B\[j,:\]).
149///
150/// # Errors
151///
152/// Returns [`SparseError::DimensionMismatch`] if `A.cols() != B.rows()`.
153/// Returns [`SparseError::Cuda`] on device-to-host transfer failure.
154pub 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        // Reuse a single bitset buffer across rows
180        let words = n.div_ceil(64);
181        let mut bitset = vec![0u64; words];
182
183        for row in 0..m {
184            // Clear the bitset
185            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            // Popcount the bitset
206            let row_nnz: u32 = bitset.iter().map(|w| w.count_ones()).sum();
207            total_nnz += row_nnz as usize;
208        }
209    } else {
210        // Use hash set for wide matrices
211        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
241// ---------------------------------------------------------------------------
242// 3. Sampling-based estimation
243// ---------------------------------------------------------------------------
244
245/// Estimates nnz(C) by sampling a subset of rows and extrapolating.
246///
247/// Samples approximately `sqrt(nrows)` rows (at least 1, at most nrows),
248/// computes exact nnz for those rows, and extrapolates to the full matrix
249/// with conservative lower and upper confidence bounds.
250///
251/// # Errors
252///
253/// Returns [`SparseError::DimensionMismatch`] if `A.cols() != B.rows()`.
254/// Returns [`SparseError::Cuda`] on device-to-host transfer failure.
255pub 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    // Determine sample count: sqrt(m), clamped to [1, m]
277    let sample_count = (m as f64).sqrt().ceil() as usize;
278    let sample_count = sample_count.max(1).min(m);
279
280    // Deterministic sampling: evenly spaced rows
281    let sample_indices = deterministic_sample_indices(m, sample_count);
282    let actual_sample_count = sample_indices.len();
283
284    // Compute exact nnz for each sampled row
285    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    // Compute statistics
340    let (mean, std_dev) = compute_mean_stddev(&row_nnz_samples);
341
342    let estimated_nnz = (mean * m as f64).round() as usize;
343
344    // Conservative bounds using 2-sigma (approx 95% confidence)
345    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    // Clamp upper bound to theoretical maximum m * n
350    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
364// ---------------------------------------------------------------------------
365// Auto-selection
366// ---------------------------------------------------------------------------
367
368/// Estimates workspace memory needed for SpGEMM `C = A * B`.
369///
370/// This is an alias for [`auto_estimate_spgemm`].
371pub 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
378/// Chooses the best estimation strategy based on matrix sizes and computes
379/// the nnz estimate for `C = A * B`.
380///
381/// - Small matrices (< 1000 rows): exact hash-based count.
382/// - Medium matrices (1000 -- 100K rows): sampling-based estimate.
383/// - Large matrices (> 100K rows): upper-bound estimate.
384///
385/// # Errors
386///
387/// Returns [`SparseError::DimensionMismatch`] if `A.cols() != B.rows()`.
388/// Returns [`SparseError::Cuda`] on device-to-host transfer failure.
389pub 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
406// ---------------------------------------------------------------------------
407// Internal helpers
408// ---------------------------------------------------------------------------
409
410/// Produces `count` deterministic, evenly-spaced sample indices from `[0, total)`.
411fn 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        // Evenly spaced: floor(i * total / count)
422        let idx = i * total / count;
423        indices.push(idx);
424    }
425    indices
426}
427
428/// Computes mean and sample standard deviation of a slice.
429///
430/// Returns `(0.0, 0.0)` for empty slices and `(val, 0.0)` for single-element
431/// slices.
432fn 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// ---------------------------------------------------------------------------
449// Tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    /// Helper: build a small CSR matrix on the host for testing.
457    /// Returns an error if no GPU context is available, so tests that call
458    /// this are effectively integration tests requiring a GPU.
459    ///
460    /// For unit tests that do NOT need a GPU we test the internal helpers.
461    #[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    // ------------------------------------------------------------------
473    // Internal helper tests (no GPU required)
474    // ------------------------------------------------------------------
475
476    #[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        // Evenly spaced: 0, 3, 6
487        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        // [2, 4, 6, 8] => mean = 5, sample std = sqrt(20/3) ~= 2.5820
519        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        // All same => stddev 0
528        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    // ------------------------------------------------------------------
574    // GPU integration tests (behind feature gate)
575    // ------------------------------------------------------------------
576
577    #[cfg(feature = "gpu-tests")]
578    mod gpu {
579        use super::*;
580
581        /// Create a live CUDA context for the calling test thread, or return
582        /// `None` if no GPU is available.
583        ///
584        /// The CUDA driver API (`cuMemAlloc`, etc.) requires an *active* context
585        /// on the **calling thread** — simply checking device count is not enough.
586        /// `Context::new` calls `cuCtxCreate` which both creates and sets the
587        /// context as current for the calling thread.
588        ///
589        /// Callers must hold the returned `Context` alive for the duration of
590        /// the test:
591        ///
592        /// ```ignore
593        /// let Some(_ctx) = gpu_context() else { return; };
594        /// // GPU operations here ...
595        /// ```
596        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        /// Identity 3x3 matrix: C = I * I = I, nnz = 3.
606        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        /// Diagonal 4x4 matrix.
611        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        /// Dense-ish 2x3 matrix:
616        /// [1 2 3]
617        /// [4 5 0]
618        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        /// 3x2 matrix:
629        /// [1 0]
630        /// [0 2]
631        /// [3 4]
632        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        /// Single-row matrix: [1 0 2]
637        fn single_row() -> SparseResult<CsrMatrix<f64>> {
638            try_make_csr(1, 3, &[0, 2], &[0, 2], &[1.0, 2.0])
639        }
640
641        /// Single-column matrix:
642        /// [1]
643        /// [2]
644        /// [3]
645        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            // I * I = I, exact nnz = 3, upper bound >= 3
658            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            // D * D = D^2, still diagonal => nnz = 4
683            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            // With only 3 rows, sample_count = ceil(sqrt(3)) = 2
714            // But for 3 rows sample covers most/all, should be close to 3
715            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            // 3 rows < 1000 => should pick exact
728            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            // A is 2x3, B is 2x3 => A.cols (3) != B.rows (2)
740            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            // a is 1x3 with nnz=2 at cols 0 and 2
757            // Need a 3x? matrix for b
758            let b = try_make_csr(3, 2, &[0, 1, 1, 2], &[0, 1], &[1.0, 1.0])
759                .expect("test: GPU context required");
760            // C = a * b: row 0 picks B rows 0 and 2
761            //   B row 0 has col 0, B row 2 has col 1 => C row 0 has cols {0, 1} => nnz = 2
762            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            // A is 3x1, B is 1x3 => C is 3x3
774            // Each row of A has one entry in col 0, B row 0 has 2 entries
775            // => each C row has 2 entries => nnz = 6
776            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            // Should behave like auto_estimate
792            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}