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}