use std::collections::HashSet;
use oxicuda_blas::GpuFloat;
use crate::error::{SparseError, SparseResult};
use crate::format::CsrMatrix;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EstimationMethod {
UpperBound,
Exact,
Sampling {
sample_count: usize,
},
}
#[derive(Debug, Clone)]
pub struct SpGEMMEstimate {
pub estimated_nnz: usize,
pub lower_bound: usize,
pub upper_bound: usize,
pub method: EstimationMethod,
}
const SMALL_THRESHOLD: usize = 1_000;
const LARGE_THRESHOLD: usize = 100_000;
const BITSET_THRESHOLD: usize = 65_536;
fn validate_dims<T: GpuFloat>(a: &CsrMatrix<T>, b: &CsrMatrix<T>) -> SparseResult<()> {
if a.cols() != b.rows() {
return Err(SparseError::DimensionMismatch(format!(
"A.cols ({}) != B.rows ({})",
a.cols(),
b.rows()
)));
}
Ok(())
}
pub fn estimate_nnz_upper_bound<T: GpuFloat>(
a: &CsrMatrix<T>,
b: &CsrMatrix<T>,
) -> SparseResult<SpGEMMEstimate> {
validate_dims(a, b)?;
let m = a.rows() as usize;
if m == 0 {
return Ok(SpGEMMEstimate {
estimated_nnz: 0,
lower_bound: 0,
upper_bound: 0,
method: EstimationMethod::UpperBound,
});
}
let (a_row_ptr, a_col_idx, _) = a.to_host()?;
let (b_row_ptr, _, _) = b.to_host()?;
let n = b.cols() as usize;
let mut total_upper: usize = 0;
for row in 0..m {
let a_start = a_row_ptr[row] as usize;
let a_end = a_row_ptr[row + 1] as usize;
let mut row_upper: usize = 0;
for &col_val in &a_col_idx[a_start..a_end] {
let k = col_val as usize;
let b_nnz_k = (b_row_ptr[k + 1] - b_row_ptr[k]) as usize;
row_upper += b_nnz_k;
}
total_upper += row_upper.min(n);
}
Ok(SpGEMMEstimate {
estimated_nnz: total_upper,
lower_bound: 0,
upper_bound: total_upper,
method: EstimationMethod::UpperBound,
})
}
pub fn count_nnz_exact<T: GpuFloat>(
a: &CsrMatrix<T>,
b: &CsrMatrix<T>,
) -> SparseResult<SpGEMMEstimate> {
validate_dims(a, b)?;
let m = a.rows() as usize;
if m == 0 {
return Ok(SpGEMMEstimate {
estimated_nnz: 0,
lower_bound: 0,
upper_bound: 0,
method: EstimationMethod::Exact,
});
}
let (a_row_ptr, a_col_idx, _) = a.to_host()?;
let (b_row_ptr, b_col_idx, _) = b.to_host()?;
let n = b.cols() as usize;
let use_bitset = n <= BITSET_THRESHOLD && n > 0;
let mut total_nnz: usize = 0;
if use_bitset {
let words = n.div_ceil(64);
let mut bitset = vec![0u64; words];
for row in 0..m {
for w in bitset.iter_mut() {
*w = 0;
}
let a_start = a_row_ptr[row] as usize;
let a_end = a_row_ptr[row + 1] as usize;
for &a_col in &a_col_idx[a_start..a_end] {
let k = a_col as usize;
let b_start = b_row_ptr[k] as usize;
let b_end = b_row_ptr[k + 1] as usize;
for &b_col in &b_col_idx[b_start..b_end] {
let col = b_col as usize;
let word = col / 64;
let bit = col % 64;
bitset[word] |= 1u64 << bit;
}
}
let row_nnz: u32 = bitset.iter().map(|w| w.count_ones()).sum();
total_nnz += row_nnz as usize;
}
} else {
let mut set = HashSet::new();
for row in 0..m {
set.clear();
let a_start = a_row_ptr[row] as usize;
let a_end = a_row_ptr[row + 1] as usize;
for &a_col in &a_col_idx[a_start..a_end] {
let k = a_col as usize;
let b_start = b_row_ptr[k] as usize;
let b_end = b_row_ptr[k + 1] as usize;
for &b_col in &b_col_idx[b_start..b_end] {
set.insert(b_col);
}
}
total_nnz += set.len();
}
}
Ok(SpGEMMEstimate {
estimated_nnz: total_nnz,
lower_bound: total_nnz,
upper_bound: total_nnz,
method: EstimationMethod::Exact,
})
}
pub fn estimate_nnz_sampling<T: GpuFloat>(
a: &CsrMatrix<T>,
b: &CsrMatrix<T>,
) -> SparseResult<SpGEMMEstimate> {
validate_dims(a, b)?;
let m = a.rows() as usize;
if m == 0 {
return Ok(SpGEMMEstimate {
estimated_nnz: 0,
lower_bound: 0,
upper_bound: 0,
method: EstimationMethod::Sampling { sample_count: 0 },
});
}
let (a_row_ptr, a_col_idx, _) = a.to_host()?;
let (b_row_ptr, b_col_idx, _) = b.to_host()?;
let n = b.cols() as usize;
let sample_count = (m as f64).sqrt().ceil() as usize;
let sample_count = sample_count.max(1).min(m);
let sample_indices = deterministic_sample_indices(m, sample_count);
let actual_sample_count = sample_indices.len();
let use_bitset = n <= BITSET_THRESHOLD && n > 0;
let mut row_nnz_samples = Vec::with_capacity(actual_sample_count);
if use_bitset {
let words = n.div_ceil(64);
let mut bitset = vec![0u64; words];
for &row in &sample_indices {
for w in bitset.iter_mut() {
*w = 0;
}
let a_start = a_row_ptr[row] as usize;
let a_end = a_row_ptr[row + 1] as usize;
for &a_col in &a_col_idx[a_start..a_end] {
let k = a_col as usize;
let b_start = b_row_ptr[k] as usize;
let b_end = b_row_ptr[k + 1] as usize;
for &b_col in &b_col_idx[b_start..b_end] {
let col = b_col as usize;
let word = col / 64;
let bit = col % 64;
bitset[word] |= 1u64 << bit;
}
}
let row_nnz: u32 = bitset.iter().map(|w| w.count_ones()).sum();
row_nnz_samples.push(row_nnz as f64);
}
} else {
let mut set = HashSet::new();
for &row in &sample_indices {
set.clear();
let a_start = a_row_ptr[row] as usize;
let a_end = a_row_ptr[row + 1] as usize;
for &a_col in &a_col_idx[a_start..a_end] {
let k = a_col as usize;
let b_start = b_row_ptr[k] as usize;
let b_end = b_row_ptr[k + 1] as usize;
for &b_col in &b_col_idx[b_start..b_end] {
set.insert(b_col);
}
}
row_nnz_samples.push(set.len() as f64);
}
}
let (mean, std_dev) = compute_mean_stddev(&row_nnz_samples);
let estimated_nnz = (mean * m as f64).round() as usize;
let margin = 2.0 * std_dev * (m as f64) / (actual_sample_count as f64).sqrt();
let lower_raw = (mean * m as f64 - margin).max(0.0);
let upper_raw = mean * m as f64 + margin;
let max_possible = m.saturating_mul(n);
let lower_bound = lower_raw.round() as usize;
let upper_bound = (upper_raw.round() as usize).min(max_possible);
Ok(SpGEMMEstimate {
estimated_nnz: estimated_nnz.min(max_possible),
lower_bound,
upper_bound,
method: EstimationMethod::Sampling {
sample_count: actual_sample_count,
},
})
}
pub fn estimate_spgemm_memory<T: GpuFloat>(
a: &CsrMatrix<T>,
b: &CsrMatrix<T>,
) -> SparseResult<SpGEMMEstimate> {
auto_estimate_spgemm(a, b)
}
pub fn auto_estimate_spgemm<T: GpuFloat>(
a: &CsrMatrix<T>,
b: &CsrMatrix<T>,
) -> SparseResult<SpGEMMEstimate> {
validate_dims(a, b)?;
let m = a.rows() as usize;
if m < SMALL_THRESHOLD {
count_nnz_exact(a, b)
} else if m <= LARGE_THRESHOLD {
estimate_nnz_sampling(a, b)
} else {
estimate_nnz_upper_bound(a, b)
}
}
fn deterministic_sample_indices(total: usize, count: usize) -> Vec<usize> {
if count >= total {
return (0..total).collect();
}
if count == 0 {
return Vec::new();
}
let mut indices = Vec::with_capacity(count);
for i in 0..count {
let idx = i * total / count;
indices.push(idx);
}
indices
}
fn compute_mean_stddev(samples: &[f64]) -> (f64, f64) {
if samples.is_empty() {
return (0.0, 0.0);
}
let n = samples.len() as f64;
let mean = samples.iter().sum::<f64>() / n;
if samples.len() == 1 {
return (mean, 0.0);
}
let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
(mean, variance.sqrt())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "gpu-tests")]
fn try_make_csr(
rows: u32,
cols: u32,
row_ptr: &[i32],
col_idx: &[i32],
values: &[f64],
) -> SparseResult<CsrMatrix<f64>> {
CsrMatrix::from_host(rows, cols, row_ptr, col_idx, values)
}
#[test]
fn deterministic_sample_full() {
let indices = deterministic_sample_indices(5, 10);
assert_eq!(indices, vec![0, 1, 2, 3, 4]);
}
#[test]
fn deterministic_sample_subset() {
let indices = deterministic_sample_indices(10, 3);
assert_eq!(indices.len(), 3);
assert_eq!(indices, vec![0, 3, 6]);
}
#[test]
fn deterministic_sample_zero() {
let indices = deterministic_sample_indices(10, 0);
assert!(indices.is_empty());
}
#[test]
fn deterministic_sample_one() {
let indices = deterministic_sample_indices(100, 1);
assert_eq!(indices, vec![0]);
}
#[test]
fn mean_stddev_empty() {
let (mean, std) = compute_mean_stddev(&[]);
assert!((mean - 0.0).abs() < f64::EPSILON);
assert!((std - 0.0).abs() < f64::EPSILON);
}
#[test]
fn mean_stddev_single() {
let (mean, std) = compute_mean_stddev(&[42.0]);
assert!((mean - 42.0).abs() < f64::EPSILON);
assert!((std - 0.0).abs() < f64::EPSILON);
}
#[test]
fn mean_stddev_basic() {
let (mean, std) = compute_mean_stddev(&[2.0, 4.0, 6.0, 8.0]);
assert!((mean - 5.0).abs() < 1e-10);
let expected_std = (20.0_f64 / 3.0).sqrt();
assert!((std - expected_std).abs() < 1e-10);
}
#[test]
fn mean_stddev_uniform() {
let (mean, std) = compute_mean_stddev(&[7.0, 7.0, 7.0]);
assert!((mean - 7.0).abs() < f64::EPSILON);
assert!((std - 0.0).abs() < 1e-15);
}
#[test]
fn estimation_method_debug() {
let m = EstimationMethod::UpperBound;
let s = format!("{:?}", m);
assert!(s.contains("UpperBound"));
let m2 = EstimationMethod::Sampling { sample_count: 10 };
let s2 = format!("{:?}", m2);
assert!(s2.contains("10"));
}
#[test]
fn estimation_method_eq() {
assert_eq!(EstimationMethod::Exact, EstimationMethod::Exact);
assert_ne!(EstimationMethod::Exact, EstimationMethod::UpperBound);
assert_eq!(
EstimationMethod::Sampling { sample_count: 5 },
EstimationMethod::Sampling { sample_count: 5 }
);
assert_ne!(
EstimationMethod::Sampling { sample_count: 5 },
EstimationMethod::Sampling { sample_count: 6 }
);
}
#[test]
fn estimate_clone() {
let est = SpGEMMEstimate {
estimated_nnz: 100,
lower_bound: 80,
upper_bound: 120,
method: EstimationMethod::Exact,
};
let cloned = est.clone();
assert_eq!(cloned.estimated_nnz, 100);
assert_eq!(cloned.lower_bound, 80);
assert_eq!(cloned.upper_bound, 120);
assert_eq!(cloned.method, EstimationMethod::Exact);
}
#[cfg(feature = "gpu-tests")]
mod gpu {
use super::*;
fn gpu_context() -> Option<oxicuda_driver::Context> {
oxicuda_driver::init().ok()?;
if oxicuda_driver::Device::count().ok()? == 0 {
return None;
}
let dev = oxicuda_driver::Device::get(0).ok()?;
oxicuda_driver::Context::new(&dev).ok()
}
fn identity_3x3() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(3, 3, &[0, 1, 2, 3], &[0, 1, 2], &[1.0, 1.0, 1.0])
}
fn diagonal_4x4() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(4, 4, &[0, 1, 2, 3, 4], &[0, 1, 2, 3], &[2.0, 3.0, 4.0, 5.0])
}
fn dense_2x3() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(
2,
3,
&[0, 3, 5],
&[0, 1, 2, 0, 1],
&[1.0, 2.0, 3.0, 4.0, 5.0],
)
}
fn sparse_3x2() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(3, 2, &[0, 1, 2, 4], &[0, 1, 0, 1], &[1.0, 2.0, 3.0, 4.0])
}
fn single_row() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(1, 3, &[0, 2], &[0, 2], &[1.0, 2.0])
}
fn single_col() -> SparseResult<CsrMatrix<f64>> {
try_make_csr(3, 1, &[0, 1, 2, 3], &[0, 0, 0], &[1.0, 2.0, 3.0])
}
#[test]
fn upper_bound_identity() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = identity_3x3().expect("test: GPU context required");
let est = estimate_nnz_upper_bound(&a, &a)
.expect("test: upper bound estimation should succeed");
assert!(est.upper_bound >= 3);
assert_eq!(est.method, EstimationMethod::UpperBound);
}
#[test]
fn exact_identity() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = identity_3x3().expect("test: GPU context required");
let est = count_nnz_exact(&a, &a).expect("test: exact counting should succeed");
assert_eq!(est.estimated_nnz, 3);
assert_eq!(est.lower_bound, 3);
assert_eq!(est.upper_bound, 3);
assert_eq!(est.method, EstimationMethod::Exact);
}
#[test]
fn exact_diagonal() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = diagonal_4x4().expect("test: GPU context required");
let est = count_nnz_exact(&a, &a).expect("test: exact counting should succeed");
assert_eq!(est.estimated_nnz, 4);
}
#[test]
fn upper_bound_ge_exact() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = dense_2x3().expect("test: GPU context required");
let b = sparse_3x2().expect("test: GPU context required");
let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
let upper = estimate_nnz_upper_bound(&a, &b).expect("test: upper bound should succeed");
assert!(
upper.upper_bound >= exact.estimated_nnz,
"upper bound ({}) must be >= exact ({})",
upper.upper_bound,
exact.estimated_nnz
);
}
#[test]
fn sampling_identity() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = identity_3x3().expect("test: GPU context required");
let est =
estimate_nnz_sampling(&a, &a).expect("test: sampling estimation should succeed");
assert!(est.estimated_nnz >= 1);
assert!(est.upper_bound >= est.lower_bound);
matches!(est.method, EstimationMethod::Sampling { .. });
}
#[test]
fn auto_small_uses_exact() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = identity_3x3().expect("test: GPU context required");
let est = auto_estimate_spgemm(&a, &a).expect("test: auto estimation should succeed");
assert_eq!(est.method, EstimationMethod::Exact);
assert_eq!(est.estimated_nnz, 3);
}
#[test]
fn dimension_mismatch_error() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = dense_2x3().expect("test: GPU context required");
let b = dense_2x3().expect("test: GPU context required");
let err = estimate_nnz_upper_bound(&a, &b);
assert!(err.is_err());
let err = count_nnz_exact(&a, &b);
assert!(err.is_err());
let err = estimate_nnz_sampling(&a, &b);
assert!(err.is_err());
let err = auto_estimate_spgemm(&a, &b);
assert!(err.is_err());
}
#[test]
fn single_row_matrix() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = single_row().expect("test: GPU context required");
let b = try_make_csr(3, 2, &[0, 1, 1, 2], &[0, 1], &[1.0, 1.0])
.expect("test: GPU context required");
let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
assert_eq!(exact.estimated_nnz, 2);
}
#[test]
fn single_col_times_single_row() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = single_col().expect("test: GPU context required");
let b = single_row().expect("test: GPU context required");
let exact = count_nnz_exact(&a, &b).expect("test: exact counting should succeed");
assert_eq!(exact.estimated_nnz, 6);
let upper = estimate_nnz_upper_bound(&a, &b).expect("test: upper bound should succeed");
assert!(upper.upper_bound >= 6);
}
#[test]
fn estimate_spgemm_memory_alias() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = identity_3x3().expect("test: GPU context required");
let est =
estimate_spgemm_memory(&a, &a).expect("test: memory estimation should succeed");
assert_eq!(est.estimated_nnz, 3);
}
#[test]
fn sampling_bounds_consistency() {
let Some(_ctx) = gpu_context() else {
return;
};
let a = diagonal_4x4().expect("test: GPU context required");
let est = estimate_nnz_sampling(&a, &a).expect("test: sampling should succeed");
assert!(
est.lower_bound <= est.estimated_nnz,
"lower_bound ({}) should be <= estimated_nnz ({})",
est.lower_bound,
est.estimated_nnz
);
assert!(
est.estimated_nnz <= est.upper_bound,
"estimated_nnz ({}) should be <= upper_bound ({})",
est.estimated_nnz,
est.upper_bound
);
}
}
}