Skip to main content

oxicuda_sparse/compat/
cusparse_compat.rs

1//! cuSPARSE-compatible API shim over the crate's host sparse kernels.
2//!
3//! This module mirrors the shape of the core cuSPARSE generic-API entry points
4//! so that code written against cuSPARSE reads almost unchanged when ported to
5//! OxiCUDA. The naming is cuSPARSE-flavoured (a library *handle* with a
6//! create/destroy lifecycle and a scalar *pointer mode*, plus `cusparse_spmv` /
7//! `cusparse_spgemm` / `cusparse_spsv` operations) but the surface is fully
8//! idiomatic Rust: operations take `&` references, return
9//! [`SparseResult`], validate their arguments, and expose no raw pointers or
10//! `unsafe`.
11//!
12//! All operations act on the crate's host CSR type [`HostCsr`] and delegate to
13//! its existing kernels:
14//!
15//! - [`cusparse_spmv`] computes `y = alpha * A * x + beta * y` by reusing
16//!   [`HostCsr::matvec`];
17//! - [`cusparse_spgemm`] computes `C = A * B` by reusing [`HostCsr::matmul`]
18//!   (the crate's Gustavson sparse-sparse product);
19//! - [`cusparse_spsv`] solves the triangular system `A * x = alpha * b` for a
20//!   lower- or upper-triangular `A` by sparse forward/backward substitution.
21//!
22//! The triangular descriptor is expressed with the crate's existing
23//! [`FillMode`] and [`DiagType`] enums (re-exported from `oxicuda-blas`), the
24//! same descriptor types the GPU [`sptrsv`](mod@crate::ops::sptrsv) uses.
25
26use std::cell::Cell;
27
28use oxicuda_blas::{DiagType, FillMode};
29
30use crate::error::{SparseError, SparseResult};
31use crate::host_csr::HostCsr;
32
33/// How scalar arguments (`alpha`, `beta`) are interpreted, mirroring
34/// `cusparsePointerMode_t`.
35///
36/// In this host shim both modes pass scalars by value; the distinction is
37/// retained for API faithfulness and round-trips through the handle so that
38/// porting code which sets a pointer mode keeps compiling and behaving sanely.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum CusparsePointerMode {
41    /// Scalars are taken from host values (the default).
42    #[default]
43    Host,
44    /// Scalars are nominally device-resident; treated identically here.
45    Device,
46}
47
48/// A cuSPARSE-compatible library handle, analogous to `cusparseHandle_t`.
49///
50/// Created with [`CusparseCompatHandle::create`] and released with
51/// [`CusparseCompatHandle::destroy`] (which consumes the handle, matching
52/// `cusparseDestroy`). Every operation in this module takes a handle reference
53/// as its first argument and records itself in the handle's operation counter,
54/// so a caller can observe activity over the handle's lifetime.
55#[derive(Debug)]
56pub struct CusparseCompatHandle {
57    /// Scalar pointer mode for `alpha`/`beta` arguments.
58    pointer_mode: CusparsePointerMode,
59    /// Count of operations dispatched through this handle.
60    op_count: Cell<u64>,
61}
62
63impl CusparseCompatHandle {
64    /// Creates a new handle with the default ([`CusparsePointerMode::Host`])
65    /// pointer mode, analogous to `cusparseCreate`.
66    ///
67    /// # Errors
68    ///
69    /// This host shim never fails to create a handle; the [`SparseResult`]
70    /// return type preserves the cuSPARSE signature shape.
71    pub fn create() -> SparseResult<Self> {
72        Ok(Self {
73            pointer_mode: CusparsePointerMode::default(),
74            op_count: Cell::new(0),
75        })
76    }
77
78    /// Releases the handle, analogous to `cusparseDestroy`.
79    ///
80    /// Consuming `self` makes use-after-destroy a compile-time error, the
81    /// idiomatic-Rust equivalent of invalidating the opaque cuSPARSE handle.
82    ///
83    /// # Errors
84    ///
85    /// Never returns an error in this host shim.
86    pub fn destroy(self) -> SparseResult<()> {
87        Ok(())
88    }
89
90    /// Returns the current scalar pointer mode.
91    #[inline]
92    pub fn pointer_mode(&self) -> CusparsePointerMode {
93        self.pointer_mode
94    }
95
96    /// Sets the scalar pointer mode, analogous to `cusparseSetPointerMode`.
97    #[inline]
98    pub fn set_pointer_mode(&mut self, mode: CusparsePointerMode) {
99        self.pointer_mode = mode;
100    }
101
102    /// Number of operations dispatched through this handle so far.
103    #[inline]
104    pub fn op_count(&self) -> u64 {
105        self.op_count.get()
106    }
107
108    /// Records one dispatched operation (internal bookkeeping).
109    #[inline]
110    fn record_op(&self) {
111        self.op_count.set(self.op_count.get() + 1);
112    }
113}
114
115/// Computes the sparse matrix-vector product `y = alpha * A * x + beta * y`,
116/// mirroring `cusparseSpMV` for a CSR operand.
117///
118/// `x` must have length `A.ncols` and `y` length `A.nrows`. When `beta == 0`
119/// the prior contents of `y` are ignored (overwritten), matching cuSPARSE.
120///
121/// # Errors
122///
123/// Returns [`SparseError::DimensionMismatch`] if `x.len() != A.ncols` or
124/// `y.len() != A.nrows`.
125pub fn cusparse_spmv(
126    handle: &CusparseCompatHandle,
127    alpha: f64,
128    a: &HostCsr,
129    x: &[f64],
130    beta: f64,
131    y: &mut [f64],
132) -> SparseResult<()> {
133    handle.record_op();
134    if x.len() != a.ncols {
135        return Err(SparseError::DimensionMismatch(format!(
136            "SpMV: x length ({}) must equal A.ncols ({})",
137            x.len(),
138            a.ncols
139        )));
140    }
141    if y.len() != a.nrows {
142        return Err(SparseError::DimensionMismatch(format!(
143            "SpMV: y length ({}) must equal A.nrows ({})",
144            y.len(),
145            a.nrows
146        )));
147    }
148
149    // Reuse the crate's host SpMV kernel for `A * x`, then apply the BLAS-style
150    // alpha/beta accumulation. `beta == 0` overwrites `y` (and is robust to a
151    // pre-existing non-finite value in `y`, as cuSPARSE requires).
152    let ax = a.matvec(x);
153    for (yi, &ax_i) in y.iter_mut().zip(ax.iter()) {
154        let scaled = if beta == 0.0 { 0.0 } else { beta * *yi };
155        *yi = alpha * ax_i + scaled;
156    }
157    Ok(())
158}
159
160/// Computes the sparse-sparse product `C = A * B`, mirroring `cusparseSpGEMM`.
161///
162/// Delegates to [`HostCsr::matmul`], the crate's Gustavson SpGEMM, returning a
163/// freshly allocated [`HostCsr`] with sorted column indices per row.
164///
165/// # Errors
166///
167/// Returns [`SparseError::DimensionMismatch`] if `A.ncols != B.nrows`.
168pub fn cusparse_spgemm(
169    handle: &CusparseCompatHandle,
170    a: &HostCsr,
171    b: &HostCsr,
172) -> SparseResult<HostCsr> {
173    handle.record_op();
174    a.matmul(b)
175}
176
177/// Solves the triangular system `A * x = alpha * b`, mirroring `cusparseSpSV`.
178///
179/// `A` must be square and is interpreted as lower-triangular when
180/// `fill_mode == FillMode::Lower` (forward substitution) or upper-triangular
181/// when `fill_mode == FillMode::Upper` (backward substitution). Entries on the
182/// "wrong" side of the diagonal for the chosen `fill_mode` are ignored, as in
183/// cuSPARSE. With `diag_type == DiagType::Unit` the diagonal is treated as an
184/// implicit all-ones diagonal and any stored diagonal entries are not read.
185///
186/// The returned vector `x` has length `A.nrows` and satisfies `A * x ≈ alpha * b`
187/// (taking the configured triangle and diagonal mode into account).
188///
189/// # Errors
190///
191/// Returns [`SparseError::InvalidArgument`] if `fill_mode == FillMode::Full`
192/// (the solve requires a triangle), [`SparseError::DimensionMismatch`] if `A`
193/// is not square or `b.len() != A.nrows`, and [`SparseError::SingularMatrix`]
194/// if a needed diagonal entry is missing or numerically zero under
195/// [`DiagType::NonUnit`].
196pub fn cusparse_spsv(
197    handle: &CusparseCompatHandle,
198    fill_mode: FillMode,
199    diag_type: DiagType,
200    alpha: f64,
201    a: &HostCsr,
202    b: &[f64],
203) -> SparseResult<Vec<f64>> {
204    handle.record_op();
205    if a.nrows != a.ncols {
206        return Err(SparseError::DimensionMismatch(format!(
207            "SpSV requires a square matrix, got {}x{}",
208            a.nrows, a.ncols
209        )));
210    }
211    if b.len() != a.nrows {
212        return Err(SparseError::DimensionMismatch(format!(
213            "SpSV: b length ({}) must equal A.nrows ({})",
214            b.len(),
215            a.nrows
216        )));
217    }
218    let n = a.nrows;
219    let mut x = vec![0.0f64; n];
220
221    match fill_mode {
222        FillMode::Lower => {
223            // Forward substitution: rows top-to-bottom. Each row reads the
224            // already-solved entries `x[c]` for `c < i`.
225            for i in 0..n {
226                let (acc, diag) = substitution_row(a, i, alpha * b[i], &x, c_below(i));
227                x[i] = finish_row(acc, diag, diag_type)?;
228            }
229        }
230        FillMode::Upper => {
231            // Backward substitution: rows bottom-to-top. Each row reads the
232            // already-solved entries `x[c]` for `c > i`.
233            for i in (0..n).rev() {
234                let (acc, diag) = substitution_row(a, i, alpha * b[i], &x, c_above(i));
235                x[i] = finish_row(acc, diag, diag_type)?;
236            }
237        }
238        FillMode::Full => {
239            return Err(SparseError::InvalidArgument(
240                "SpSV requires a triangular fill mode (Lower or Upper), got Full".to_string(),
241            ));
242        }
243    }
244
245    Ok(x)
246}
247
248/// Predicate selecting strictly-lower columns `c < i` (forward substitution).
249#[inline]
250fn c_below(i: usize) -> impl Fn(usize) -> bool {
251    move |c| c < i
252}
253
254/// Predicate selecting strictly-upper columns `c > i` (backward substitution).
255#[inline]
256fn c_above(i: usize) -> impl Fn(usize) -> bool {
257    move |c| c > i
258}
259
260/// Accumulates one substitution step for row `i`: starts from `rhs`, subtracts
261/// `A[i, c] * x[c]` for every already-solved column accepted by `is_solved`,
262/// and returns `(numerator, diagonal_entry)`.
263#[inline]
264fn substitution_row(
265    a: &HostCsr,
266    i: usize,
267    rhs: f64,
268    x: &[f64],
269    is_solved: impl Fn(usize) -> bool,
270) -> (f64, Option<f64>) {
271    let mut acc = rhs;
272    let mut diag: Option<f64> = None;
273    let start = a.row_ptr[i];
274    let end = a.row_ptr[i + 1];
275    for k in start..end {
276        let c = a.col_indices[k];
277        if c == i {
278            diag = Some(a.values[k]);
279        } else if is_solved(c) {
280            acc -= a.values[k] * x[c];
281        }
282    }
283    (acc, diag)
284}
285
286/// Completes a substitution step: divides the accumulated numerator by the
287/// diagonal (or treats it as unit), validating non-singularity.
288#[inline]
289fn finish_row(acc: f64, diag: Option<f64>, diag_type: DiagType) -> SparseResult<f64> {
290    match diag_type {
291        DiagType::Unit => Ok(acc),
292        DiagType::NonUnit => match diag {
293            Some(d) if d.abs() > 1e-300 => Ok(acc / d),
294            _ => Err(SparseError::SingularMatrix),
295        },
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    /// Dense `A * x` reference (row-major `nrows x ncols`).
304    fn dense_matvec(dense: &[f64], nrows: usize, ncols: usize, x: &[f64]) -> Vec<f64> {
305        (0..nrows)
306            .map(|i| {
307                dense[i * ncols..(i + 1) * ncols]
308                    .iter()
309                    .zip(x.iter())
310                    .map(|(&a, &xj)| a * xj)
311                    .sum()
312            })
313            .collect()
314    }
315
316    fn sample_matrix() -> HostCsr {
317        // [[1, 0, 2],
318        //  [0, 3, 0],
319        //  [4, 0, 5]]
320        HostCsr::new(
321            3,
322            3,
323            vec![0, 2, 3, 5],
324            vec![0, 2, 1, 0, 2],
325            vec![1.0, 2.0, 3.0, 4.0, 5.0],
326        )
327        .expect("sample")
328    }
329
330    #[test]
331    fn spmv_alpha1_beta0_matches_dense() {
332        let h = CusparseCompatHandle::create().expect("handle");
333        let a = sample_matrix();
334        let x = [1.0, 2.0, 3.0];
335        let mut y = vec![0.0; 3];
336        cusparse_spmv(&h, 1.0, &a, &x, 0.0, &mut y).expect("spmv");
337        let reference = dense_matvec(&a.to_dense(), 3, 3, &x);
338        for (got, want) in y.iter().zip(reference.iter()) {
339            assert!((got - want).abs() < 1e-12, "got {got} want {want}");
340        }
341        assert!(y.iter().all(|v| v.is_finite()));
342    }
343
344    #[test]
345    fn spmv_general_alpha_beta() {
346        let h = CusparseCompatHandle::create().expect("handle");
347        let a = sample_matrix();
348        let x = [2.0, -1.0, 0.5];
349        let y0 = [10.0, 20.0, 30.0];
350        let alpha = 1.5;
351        let beta = -2.0;
352        let mut y = y0.to_vec();
353        cusparse_spmv(&h, alpha, &a, &x, beta, &mut y).expect("spmv");
354        let ax = dense_matvec(&a.to_dense(), 3, 3, &x);
355        for i in 0..3 {
356            let want = alpha * ax[i] + beta * y0[i];
357            assert!((y[i] - want).abs() < 1e-12, "row {i}: {} vs {}", y[i], want);
358        }
359    }
360
361    #[test]
362    fn spmv_beta0_ignores_prior_nonfinite_y() {
363        let h = CusparseCompatHandle::create().expect("handle");
364        let a = sample_matrix();
365        let x = [1.0, 1.0, 1.0];
366        let mut y = vec![f64::NAN, f64::INFINITY, -f64::INFINITY];
367        cusparse_spmv(&h, 1.0, &a, &x, 0.0, &mut y).expect("spmv");
368        assert!(y.iter().all(|v| v.is_finite()), "beta=0 must overwrite y");
369    }
370
371    #[test]
372    fn spgemm_matches_dense() {
373        let h = CusparseCompatHandle::create().expect("handle");
374        let a = sample_matrix();
375        // B is 3x2.
376        let b = HostCsr::new(
377            3,
378            2,
379            vec![0, 1, 2, 4],
380            vec![0, 1, 0, 1],
381            vec![1.0, 2.0, 3.0, 4.0],
382        )
383        .expect("b");
384        let c = cusparse_spgemm(&h, &a, &b).expect("spgemm");
385
386        // Dense reference C = A*B.
387        let da = a.to_dense();
388        let db = b.to_dense();
389        let mut dc = [0.0f64; 3 * 2];
390        for i in 0..3 {
391            for j in 0..2 {
392                let mut acc = 0.0;
393                for k in 0..3 {
394                    acc += da[i * 3 + k] * db[k * 2 + j];
395                }
396                dc[i * 2 + j] = acc;
397            }
398        }
399        for i in 0..3 {
400            for j in 0..2 {
401                let got = c.get(i, j).unwrap_or(0.0);
402                assert!((got - dc[i * 2 + j]).abs() < 1e-12, "C[{i},{j}]");
403            }
404        }
405    }
406
407    #[test]
408    fn spsv_lower_solves_system() {
409        let h = CusparseCompatHandle::create().expect("handle");
410        // Lower-triangular L (non-unit diagonal):
411        // [[2, 0, 0],
412        //  [1, 3, 0],
413        //  [4, 5, 6]]
414        let l = HostCsr::new(
415            3,
416            3,
417            vec![0, 1, 3, 6],
418            vec![0, 0, 1, 0, 1, 2],
419            vec![2.0, 1.0, 3.0, 4.0, 5.0, 6.0],
420        )
421        .expect("l");
422        let x_true = [1.0, 2.0, 3.0];
423        let b = l.matvec(&x_true);
424        let x = cusparse_spsv(&h, FillMode::Lower, DiagType::NonUnit, 1.0, &l, &b).expect("spsv");
425        for (got, want) in x.iter().zip(x_true.iter()) {
426            assert!((got - want).abs() < 1e-12, "got {got} want {want}");
427        }
428        // Verify A * x ≈ b.
429        let check = l.matvec(&x);
430        for (c, bi) in check.iter().zip(b.iter()) {
431            assert!((c - bi).abs() < 1e-12);
432        }
433        assert!(x.iter().all(|v| v.is_finite()));
434    }
435
436    #[test]
437    fn spsv_upper_solves_system() {
438        let h = CusparseCompatHandle::create().expect("handle");
439        // Upper-triangular U:
440        // [[2, 1, 1],
441        //  [0, 3, 2],
442        //  [0, 0, 4]]
443        let u = HostCsr::new(
444            3,
445            3,
446            vec![0, 3, 5, 6],
447            vec![0, 1, 2, 1, 2, 2],
448            vec![2.0, 1.0, 1.0, 3.0, 2.0, 4.0],
449        )
450        .expect("u");
451        let x_true = [-1.0, 2.0, 0.5];
452        let b = u.matvec(&x_true);
453        let x = cusparse_spsv(&h, FillMode::Upper, DiagType::NonUnit, 1.0, &u, &b).expect("spsv");
454        for (got, want) in x.iter().zip(x_true.iter()) {
455            assert!((got - want).abs() < 1e-12, "got {got} want {want}");
456        }
457    }
458
459    #[test]
460    fn spsv_unit_diagonal() {
461        let h = CusparseCompatHandle::create().expect("handle");
462        // Unit lower-triangular: implicit 1 on the diagonal (stored diagonal,
463        // if present, is ignored). Use a matrix WITHOUT stored diagonal.
464        // L = [[1,0,0],[2,1,0],[3,4,1]] with the unit diagonal implicit.
465        let l =
466            HostCsr::new(3, 3, vec![0, 0, 1, 3], vec![0, 0, 1], vec![2.0, 3.0, 4.0]).expect("l");
467        let x_true = [5.0, -1.0, 2.0];
468        // b = L_full * x_true where L_full has unit diagonal.
469        let b = [
470            x_true[0],
471            2.0 * x_true[0] + x_true[1],
472            3.0 * x_true[0] + 4.0 * x_true[1] + x_true[2],
473        ];
474        let x = cusparse_spsv(&h, FillMode::Lower, DiagType::Unit, 1.0, &l, &b).expect("spsv");
475        for (got, want) in x.iter().zip(x_true.iter()) {
476            assert!((got - want).abs() < 1e-12, "got {got} want {want}");
477        }
478    }
479
480    #[test]
481    fn spsv_alpha_scales_rhs() {
482        let h = CusparseCompatHandle::create().expect("handle");
483        let l = HostCsr::new(2, 2, vec![0, 1, 3], vec![0, 0, 1], vec![2.0, 1.0, 4.0]).expect("l");
484        let b = [3.0, 7.0];
485        let alpha = 2.0;
486        let x = cusparse_spsv(&h, FillMode::Lower, DiagType::NonUnit, alpha, &l, &b).expect("spsv");
487        // A * x should equal alpha * b.
488        let ax = l.matvec(&x);
489        for (axi, bi) in ax.iter().zip(b.iter()) {
490            assert!((axi - alpha * bi).abs() < 1e-12);
491        }
492    }
493
494    #[test]
495    fn handle_lifecycle_and_op_count() {
496        let mut h = CusparseCompatHandle::create().expect("create");
497        assert_eq!(h.pointer_mode(), CusparsePointerMode::Host);
498        assert_eq!(h.op_count(), 0);
499
500        let a = sample_matrix();
501        let x = [1.0, 1.0, 1.0];
502        let mut y = vec![0.0; 3];
503        cusparse_spmv(&h, 1.0, &a, &x, 0.0, &mut y).expect("spmv");
504        assert_eq!(h.op_count(), 1);
505
506        h.set_pointer_mode(CusparsePointerMode::Device);
507        assert_eq!(h.pointer_mode(), CusparsePointerMode::Device);
508
509        let _ = cusparse_spgemm(&h, &a, &a).expect("spgemm");
510        assert_eq!(h.op_count(), 2);
511
512        h.destroy().expect("destroy");
513    }
514
515    #[test]
516    fn spmv_dimension_mismatch_errors() {
517        let h = CusparseCompatHandle::create().expect("handle");
518        let a = sample_matrix();
519        let bad_x = [1.0, 2.0]; // len 2 != ncols 3
520        let mut y = vec![0.0; 3];
521        assert!(cusparse_spmv(&h, 1.0, &a, &bad_x, 0.0, &mut y).is_err());
522        let x = [1.0, 2.0, 3.0];
523        let mut bad_y = vec![0.0; 2]; // len 2 != nrows 3
524        assert!(cusparse_spmv(&h, 1.0, &a, &x, 0.0, &mut bad_y).is_err());
525    }
526
527    #[test]
528    fn spgemm_dimension_mismatch_errors() {
529        let h = CusparseCompatHandle::create().expect("handle");
530        let a = sample_matrix(); // 3x3
531        let b = HostCsr::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![1.0, 1.0]).expect("b"); // 2x2
532        assert!(cusparse_spgemm(&h, &a, &b).is_err());
533    }
534
535    #[test]
536    fn spsv_rejects_non_square_and_full() {
537        let h = CusparseCompatHandle::create().expect("handle");
538        let rect = HostCsr::new(2, 3, vec![0, 1, 2], vec![0, 1], vec![1.0, 1.0]).expect("rect");
539        let b = [1.0, 2.0];
540        assert!(cusparse_spsv(&h, FillMode::Lower, DiagType::NonUnit, 1.0, &rect, &b).is_err());
541
542        let sq = sample_matrix();
543        let b3 = [1.0, 2.0, 3.0];
544        assert!(
545            cusparse_spsv(&h, FillMode::Full, DiagType::NonUnit, 1.0, &sq, &b3).is_err(),
546            "Full fill mode must be rejected"
547        );
548    }
549
550    #[test]
551    fn spsv_singular_diagonal_errors() {
552        let h = CusparseCompatHandle::create().expect("handle");
553        // Lower-triangular with a missing diagonal on row 1 -> singular under
554        // NonUnit.
555        let l = HostCsr::new(2, 2, vec![0, 1, 2], vec![0, 0], vec![2.0, 5.0]).expect("l");
556        let b = [2.0, 5.0];
557        assert!(cusparse_spsv(&h, FillMode::Lower, DiagType::NonUnit, 1.0, &l, &b).is_err());
558    }
559}