Skip to main content

oxicuda_sparse/ops/
auto_spmv.rs

1//! Heuristic-based format selection and auto-dispatching SpMV.
2//!
3//! Analyzes the sparsity structure of a CSR matrix and recommends the optimal
4//! storage format and SpMV algorithm. The auto-dispatching SpMV function
5//! selects the best algorithm at runtime based on matrix characteristics.
6//!
7//! ## Heuristics
8//!
9//! The format recommendation is based on:
10//! - **Coefficient of variation** of nnz/row: measures regularity
11//! - **Max/avg ratio** of nnz/row: detects extreme outlier rows
12//! - **Block detection**: samples the matrix for dense sub-blocks
13//!
14//! | Format | Best when... |
15//! |--------|-------------|
16//! | CSR    | General, irregular sparsity |
17//! | ELL    | Regular sparsity, similar nnz/row (low CoV) |
18//! | BSR    | Block structure detected (e.g. from FEM) |
19//! | CSR5   | Highly irregular row lengths (high max/avg) |
20#![allow(dead_code)]
21
22use oxicuda_blas::GpuFloat;
23use oxicuda_driver::ffi::CUdeviceptr;
24
25use crate::error::SparseResult;
26use crate::format::CsrMatrix;
27use crate::handle::SparseHandle;
28use crate::ops::spmv::{SpMVAlgo, spmv};
29
30// ---------------------------------------------------------------------------
31// Recommended format
32// ---------------------------------------------------------------------------
33
34/// Recommended sparse matrix format for SpMV.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum RecommendedFormat {
37    /// General CSR: good default for irregular sparsity.
38    Csr,
39    /// ELLPACK: best for matrices with regular row lengths (similar nnz/row).
40    Ell,
41    /// Block Sparse Row: best when dense sub-blocks are detected.
42    Bsr,
43    /// CSR5: best for highly irregular row lengths (extreme max/avg ratio).
44    Csr5,
45}
46
47impl std::fmt::Display for RecommendedFormat {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Csr => write!(f, "CSR"),
51            Self::Ell => write!(f, "ELL"),
52            Self::Bsr => write!(f, "BSR"),
53            Self::Csr5 => write!(f, "CSR5"),
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Matrix structure analysis
60// ---------------------------------------------------------------------------
61
62/// Statistics about the sparsity structure of a CSR matrix.
63#[derive(Debug, Clone)]
64pub struct SparsityStats {
65    /// Number of rows.
66    pub rows: usize,
67    /// Number of non-zeros.
68    pub nnz: usize,
69    /// Average nnz per row.
70    pub avg_nnz_per_row: f64,
71    /// Maximum nnz in any row.
72    pub max_nnz_per_row: usize,
73    /// Minimum nnz in any row.
74    pub min_nnz_per_row: usize,
75    /// Standard deviation of nnz per row.
76    pub std_nnz_per_row: f64,
77    /// Coefficient of variation (std / avg).
78    pub cov_nnz_per_row: f64,
79    /// Ratio of max nnz to avg nnz.
80    pub max_avg_ratio: f64,
81    /// Whether block structure was detected.
82    pub block_detected: bool,
83    /// Detected block size (if any).
84    pub block_size: usize,
85}
86
87/// Analyze the sparsity structure of a CSR matrix.
88///
89/// Downloads the row pointer array to compute per-row nnz statistics and
90/// performs block detection via sampling.
91///
92/// # Errors
93///
94/// Returns [`crate::error::SparseError::Cuda`] on data transfer failure.
95pub fn analyze_sparsity<T: GpuFloat>(matrix: &CsrMatrix<T>) -> SparseResult<SparsityStats> {
96    let n = matrix.rows() as usize;
97    if n == 0 {
98        return Ok(SparsityStats {
99            rows: 0,
100            nnz: 0,
101            avg_nnz_per_row: 0.0,
102            max_nnz_per_row: 0,
103            min_nnz_per_row: 0,
104            std_nnz_per_row: 0.0,
105            cov_nnz_per_row: 0.0,
106            max_avg_ratio: 0.0,
107            block_detected: false,
108            block_size: 1,
109        });
110    }
111
112    let (h_row_ptr, h_col_idx, _) = matrix.to_host()?;
113
114    // Compute per-row nnz
115    let row_nnz: Vec<usize> = (0..n)
116        .map(|i| (h_row_ptr[i + 1] - h_row_ptr[i]) as usize)
117        .collect();
118
119    let total_nnz = matrix.nnz() as usize;
120    let avg = total_nnz as f64 / n as f64;
121    let max_nnz = row_nnz.iter().copied().max().unwrap_or(0);
122    let min_nnz = row_nnz.iter().copied().min().unwrap_or(0);
123
124    // Standard deviation
125    let variance: f64 = row_nnz
126        .iter()
127        .map(|&x| {
128            let diff = x as f64 - avg;
129            diff * diff
130        })
131        .sum::<f64>()
132        / n as f64;
133    let std_dev = variance.sqrt();
134
135    let cov = if avg > 0.0 { std_dev / avg } else { 0.0 };
136    let max_avg_ratio = if avg > 0.0 { max_nnz as f64 / avg } else { 0.0 };
137
138    // Block detection: sample rows and check for block structure
139    let (block_detected, block_size) =
140        detect_blocks(&h_row_ptr, &h_col_idx, n, matrix.cols() as usize);
141
142    Ok(SparsityStats {
143        rows: n,
144        nnz: total_nnz,
145        avg_nnz_per_row: avg,
146        max_nnz_per_row: max_nnz,
147        min_nnz_per_row: min_nnz,
148        std_nnz_per_row: std_dev,
149        cov_nnz_per_row: cov,
150        max_avg_ratio,
151        block_detected,
152        block_size,
153    })
154}
155
156/// Detect block structure in a CSR matrix by sampling.
157///
158/// For BSR with block size `bs`, each row should have nnz that is a multiple
159/// of `bs`, and columns should form groups of `bs` consecutive indices starting
160/// at multiples of `bs`. All rows within a block-row should have the same
161/// column structure.
162fn detect_blocks(row_ptr: &[i32], col_idx: &[i32], n: usize, _cols: usize) -> (bool, usize) {
163    if n < 4 {
164        return (false, 1);
165    }
166
167    // Try block sizes 4, 3, 2 (prefer larger blocks)
168    for bs in [4usize, 3, 2] {
169        if n % bs != 0 {
170            continue;
171        }
172
173        let num_block_rows = n / bs;
174        let sample_count = num_block_rows.min(16);
175        let step = if num_block_rows > sample_count {
176            num_block_rows / sample_count
177        } else {
178            1
179        };
180
181        let mut aligned_count = 0usize;
182        let mut total_checked = 0usize;
183
184        for block_row in (0..num_block_rows).step_by(step) {
185            let base_row = block_row * bs;
186            if base_row + bs > n {
187                break;
188            }
189
190            let r0_start = row_ptr[base_row] as usize;
191            let r0_end = row_ptr[base_row + 1] as usize;
192            let r0_nnz = r0_end - r0_start;
193
194            // Require nnz to be a multiple of bs and >= bs
195            if r0_nnz < bs || r0_nnz % bs != 0 {
196                total_checked += 1;
197                continue;
198            }
199
200            // Check that base row columns form groups of bs consecutive indices
201            // starting at multiples of bs
202            let num_blocks_in_row = r0_nnz / bs;
203            let base_cols_grouped = (0..num_blocks_in_row).all(|blk| {
204                let group_start = r0_start + blk * bs;
205                let first_col = col_idx[group_start] as usize;
206                // First column in group must be block-aligned
207                if first_col % bs != 0 {
208                    return false;
209                }
210                // Remaining columns in group must be consecutive
211                (1..bs).all(|offset| col_idx[group_start + offset] as usize == first_col + offset)
212            });
213
214            if !base_cols_grouped {
215                total_checked += 1;
216                continue;
217            }
218
219            // Check that all other rows in the block have identical column structure
220            let mut block_aligned = true;
221            for sub_row in 1..bs {
222                let ri_start = row_ptr[base_row + sub_row] as usize;
223                let ri_end = row_ptr[base_row + sub_row + 1] as usize;
224                let ri_nnz = ri_end - ri_start;
225
226                if ri_nnz != r0_nnz {
227                    block_aligned = false;
228                    break;
229                }
230
231                // Columns should be identical to the base row
232                let cols_match =
233                    (0..r0_nnz).all(|k| col_idx[ri_start + k] == col_idx[r0_start + k]);
234
235                if !cols_match {
236                    block_aligned = false;
237                    break;
238                }
239            }
240
241            total_checked += 1;
242            if block_aligned {
243                aligned_count += 1;
244            }
245        }
246
247        // If > 60% of sampled blocks are aligned, declare block structure
248        if total_checked > 0 && aligned_count * 10 > total_checked * 6 {
249            return (true, bs);
250        }
251    }
252
253    (false, 1)
254}
255
256// ---------------------------------------------------------------------------
257// Format recommendation
258// ---------------------------------------------------------------------------
259
260/// Thresholds for format selection.
261const COV_ELL_THRESHOLD: f64 = 0.3;
262const MAX_AVG_CSR5_THRESHOLD: f64 = 10.0;
263
264/// Analyze matrix structure and recommend the optimal sparse format.
265///
266/// # Arguments
267///
268/// * `matrix` -- CSR matrix to analyze.
269///
270/// # Returns
271///
272/// The recommended format based on sparsity structure heuristics.
273///
274/// # Errors
275///
276/// Returns [`crate::error::SparseError::Cuda`] on data transfer failure.
277pub fn recommend_format<T: GpuFloat>(matrix: &CsrMatrix<T>) -> SparseResult<RecommendedFormat> {
278    let stats = analyze_sparsity(matrix)?;
279
280    if stats.rows == 0 {
281        return Ok(RecommendedFormat::Csr);
282    }
283
284    // Priority: block > ELL > CSR5 > CSR
285    if stats.block_detected && stats.block_size > 1 {
286        return Ok(RecommendedFormat::Bsr);
287    }
288
289    if stats.cov_nnz_per_row < COV_ELL_THRESHOLD && stats.avg_nnz_per_row > 1.0 {
290        return Ok(RecommendedFormat::Ell);
291    }
292
293    if stats.max_avg_ratio > MAX_AVG_CSR5_THRESHOLD {
294        return Ok(RecommendedFormat::Csr5);
295    }
296
297    Ok(RecommendedFormat::Csr)
298}
299
300// ---------------------------------------------------------------------------
301// Simple heuristic format selection (pure CPU, no GPU struct required)
302// ---------------------------------------------------------------------------
303
304/// Sparse matrix format selected by the simple heuristic.
305///
306/// This complements [`RecommendedFormat`] (which analyses a live GPU matrix)
307/// by providing a lightweight, testable pure-function selector that takes
308/// pre-computed statistics as inputs.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
310pub enum SpMatFormat {
311    /// General Compressed Sparse Row. Best default for very sparse matrices
312    /// (avg_nnz_per_row ≤ 2).
313    Csr,
314    /// ELLPACK. Best for matrices with regular row lengths and moderate fill
315    /// (2 < avg_nnz_per_row ≤ 32).
316    Ell,
317    /// Hybrid ELL+COO. Best for matrices with irregular sparsity patterns
318    /// that do not fit CSR5 criteria.
319    Hyb,
320    /// CSR5 load-balanced format. Best for large matrices with high average
321    /// fill (avg_nnz_per_row > 128 and n_rows >= 1024).
322    Csr5,
323}
324
325/// Select the recommended sparse format based on average nnz per row and
326/// matrix size.
327///
328/// This is a pure heuristic function with no GPU I/O and no format conversion.
329/// It is intended to guide decisions about *which format to build*, not to
330/// dispatch an existing GPU matrix.
331///
332/// ## Selection rules (evaluated in order)
333///
334/// 1. `avg_nnz_per_row <= 2.0` → [`SpMatFormat::Csr`] (scalar kernel territory)
335/// 2. `avg_nnz_per_row <= 32.0` → [`SpMatFormat::Ell`] (regular, coalesced ELL)
336/// 3. `avg_nnz_per_row > 128.0 && n_rows >= 1024` → [`SpMatFormat::Csr5`] (load-balanced)
337/// 4. Otherwise → [`SpMatFormat::Hyb`] (irregular overflow pattern)
338#[must_use]
339pub fn select_format(avg_nnz_per_row: f64, n_rows: usize) -> SpMatFormat {
340    if avg_nnz_per_row <= 2.0 {
341        SpMatFormat::Csr
342    } else if avg_nnz_per_row <= 32.0 {
343        SpMatFormat::Ell
344    } else if avg_nnz_per_row > 128.0 && n_rows >= 1024 {
345        SpMatFormat::Csr5
346    } else {
347        SpMatFormat::Hyb
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Auto-dispatching SpMV
353// ---------------------------------------------------------------------------
354
355/// Auto-dispatching SpMV: selects the best CSR algorithm based on matrix
356/// structure, then executes `y = alpha * A * x + beta * y`.
357///
358/// This currently dispatches to the CSR-based SpMV (scalar or vector) since
359/// format conversion is expensive and should be done once, not per call.
360/// The [`recommend_format`] function can guide the user to convert beforehand.
361///
362/// # Arguments
363///
364/// * `handle` -- Sparse handle.
365/// * `matrix` -- CSR matrix `A`.
366/// * `x` -- Device pointer to input vector `x`.
367/// * `y` -- Device pointer to output vector `y`.
368/// * `alpha` -- Scalar multiplier.
369/// * `beta` -- Scalar multiplier for existing `y`.
370///
371/// # Errors
372///
373/// Returns errors from the underlying SpMV implementation.
374#[allow(clippy::too_many_arguments)]
375pub fn auto_spmv<T: GpuFloat>(
376    handle: &SparseHandle,
377    matrix: &CsrMatrix<T>,
378    x: CUdeviceptr,
379    y: CUdeviceptr,
380    alpha: T,
381    beta: T,
382) -> SparseResult<()> {
383    // Use adaptive algorithm selection from the CSR SpMV
384    spmv(handle, SpMVAlgo::Adaptive, alpha, matrix, x, beta, y)
385}
386
387// ---------------------------------------------------------------------------
388// Tests
389// ---------------------------------------------------------------------------
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn recommended_format_display() {
397        assert_eq!(format!("{}", RecommendedFormat::Csr), "CSR");
398        assert_eq!(format!("{}", RecommendedFormat::Ell), "ELL");
399        assert_eq!(format!("{}", RecommendedFormat::Bsr), "BSR");
400        assert_eq!(format!("{}", RecommendedFormat::Csr5), "CSR5");
401    }
402
403    #[test]
404    fn detect_blocks_too_small() {
405        // Matrix too small for block detection
406        let row_ptr = vec![0, 1, 2, 3];
407        let col_idx = vec![0, 1, 2];
408        let (detected, size) = detect_blocks(&row_ptr, &col_idx, 3, 3);
409        assert!(!detected);
410        assert_eq!(size, 1);
411    }
412
413    #[test]
414    fn detect_blocks_diagonal_4x4() {
415        // 4x4 diagonal: columns don't form blocks
416        let row_ptr = vec![0, 1, 2, 3, 4];
417        let col_idx = vec![0, 1, 2, 3];
418        let (detected, _) = detect_blocks(&row_ptr, &col_idx, 4, 4);
419        // Diagonal has same nnz but columns differ by 1, which matches bs=1
420        // For bs=2: col[0]=0, col[1]=1, diff=1 != sub_row(1), so no block
421        assert!(!detected);
422    }
423
424    #[test]
425    fn detect_blocks_2x2_block_diagonal() {
426        // 4x4 with 2x2 block diagonal:
427        // [1 1 0 0]
428        // [1 1 0 0]
429        // [0 0 1 1]
430        // [0 0 1 1]
431        let row_ptr = vec![0, 2, 4, 6, 8];
432        let col_idx = vec![0, 1, 0, 1, 2, 3, 2, 3];
433        let (detected, size) = detect_blocks(&row_ptr, &col_idx, 4, 4);
434        assert!(detected);
435        assert_eq!(size, 2);
436    }
437
438    #[test]
439    fn cov_threshold_sanity() {
440        const { assert!(COV_ELL_THRESHOLD > 0.0) };
441        const { assert!(COV_ELL_THRESHOLD < 1.0) };
442    }
443
444    #[test]
445    fn max_avg_threshold_sanity() {
446        const { assert!(MAX_AVG_CSR5_THRESHOLD > 1.0) };
447    }
448
449    #[test]
450    fn sparsity_stats_empty() {
451        let stats = SparsityStats {
452            rows: 0,
453            nnz: 0,
454            avg_nnz_per_row: 0.0,
455            max_nnz_per_row: 0,
456            min_nnz_per_row: 0,
457            std_nnz_per_row: 0.0,
458            cov_nnz_per_row: 0.0,
459            max_avg_ratio: 0.0,
460            block_detected: false,
461            block_size: 1,
462        };
463        assert_eq!(stats.rows, 0);
464    }
465
466    #[test]
467    fn detect_blocks_non_block() {
468        // 4x4 with irregular sparsity
469        let row_ptr = vec![0, 3, 5, 6, 8];
470        let col_idx = vec![0, 1, 3, 1, 2, 2, 0, 3];
471        let (detected, _) = detect_blocks(&row_ptr, &col_idx, 4, 4);
472        assert!(!detected);
473    }
474
475    // --- select_format tests ---
476
477    #[test]
478    fn format_select_very_sparse_uses_csr() {
479        // avg=1.0 is <= 2.0 => Csr
480        assert_eq!(select_format(1.0, 1000), SpMatFormat::Csr);
481    }
482
483    #[test]
484    fn format_select_boundary_2_uses_csr() {
485        // avg=2.0 exactly => still Csr (boundary condition)
486        assert_eq!(select_format(2.0, 1000), SpMatFormat::Csr);
487    }
488
489    #[test]
490    fn format_select_regular_uses_ell() {
491        // avg=8.0, 2.0 < 8.0 <= 32.0 => Ell
492        assert_eq!(select_format(8.0, 1000), SpMatFormat::Ell);
493    }
494
495    #[test]
496    fn format_select_boundary_32_uses_ell() {
497        // avg=32.0 exactly => still Ell (boundary condition)
498        assert_eq!(select_format(32.0, 1000), SpMatFormat::Ell);
499    }
500
501    #[test]
502    fn format_select_irregular_uses_hyb() {
503        // avg=50.0, n_rows=100: > 32.0 but not > 128.0 => Hyb
504        assert_eq!(select_format(50.0, 100), SpMatFormat::Hyb);
505    }
506
507    #[test]
508    fn format_select_large_but_insufficient_rows_uses_hyb() {
509        // avg=200.0, n_rows=500 (< 1024) => Hyb, not Csr5
510        assert_eq!(select_format(200.0, 500), SpMatFormat::Hyb);
511    }
512
513    #[test]
514    fn format_select_large_dense_uses_csr5() {
515        // avg=200.0, n_rows=2048: > 128.0 and n_rows >= 1024 => Csr5
516        assert_eq!(select_format(200.0, 2048), SpMatFormat::Csr5);
517    }
518
519    #[test]
520    fn format_select_exactly_128_avg_uses_hyb() {
521        // avg=128.0 is NOT > 128.0 => Hyb (boundary)
522        assert_eq!(select_format(128.0, 2048), SpMatFormat::Hyb);
523    }
524}