Skip to main content

oxicuda_sparse/
host_csr.rs

1//! Host-resident CSR matrix and shared CPU kernels for advanced solvers.
2//!
3//! The public [`CsrMatrix`] stores its arrays in GPU device memory and
4//! therefore cannot be constructed or manipulated without a live CUDA context.
5//! The classical sparse linear-algebra algorithms in this crate -- incomplete
6//! Cholesky with level-of-fill ([`ick`](crate::preconditioner::ick)), the
7//! LOBPCG eigensolver ([`lobpcg`](crate::eig::lobpcg::lobpcg)), and
8//! smoothed-aggregation algebraic multigrid ([`amg`](crate::preconditioner::amg))
9//! -- are inherently iterative host-driven procedures: they repeatedly form
10//! Galerkin products, solve small dense subproblems, and run sweeps that
11//! interleave CPU control flow with linear-algebra primitives.
12//!
13//! This module provides [`HostCsr`], a lightweight CPU-side CSR container
14//! holding plain `Vec<i32>`/`Vec<f64>` arrays, together with the shared
15//! building blocks (SpMV, transpose, sparse-sparse product, triangular solves,
16//! and a dense Gaussian-elimination solver) that the three algorithms reuse.
17//! A [`HostCsr`] can be lifted from a GPU [`CsrMatrix`] via
18//! [`HostCsr::from_gpu`] (which downloads the arrays through `to_host`) and
19//! materialised back onto the device through [`HostCsr::to_gpu`].
20
21use oxicuda_blas::GpuFloat;
22
23use crate::error::{SparseError, SparseResult};
24use crate::format::CsrMatrix;
25
26/// A square or rectangular sparse matrix stored on the host in CSR layout.
27///
28/// Column indices within each row are kept sorted in ascending order, which the
29/// numeric kernels rely on for efficient merging. All values are `f64`; the
30/// advanced solvers accumulate in double precision regardless of the storage
31/// precision of any originating device matrix.
32#[derive(Debug, Clone, PartialEq)]
33pub struct HostCsr {
34    /// Number of rows.
35    pub nrows: usize,
36    /// Number of columns.
37    pub ncols: usize,
38    /// Row pointer array of length `nrows + 1`.
39    pub row_ptr: Vec<usize>,
40    /// Column indices of length `nnz`, sorted ascending within each row.
41    pub col_indices: Vec<usize>,
42    /// Non-zero values of length `nnz`.
43    pub values: Vec<f64>,
44}
45
46impl HostCsr {
47    /// Builds a host CSR matrix from raw arrays, validating the structure.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`SparseError::InvalidFormat`] if the array lengths are
52    /// inconsistent, `row_ptr` is not monotone, or a column index is out of
53    /// range.
54    pub fn new(
55        nrows: usize,
56        ncols: usize,
57        row_ptr: Vec<usize>,
58        col_indices: Vec<usize>,
59        values: Vec<f64>,
60    ) -> SparseResult<Self> {
61        if row_ptr.len() != nrows + 1 {
62            return Err(SparseError::InvalidFormat(format!(
63                "row_ptr length ({}) must be nrows + 1 ({})",
64                row_ptr.len(),
65                nrows + 1
66            )));
67        }
68        if col_indices.len() != values.len() {
69            return Err(SparseError::InvalidFormat(format!(
70                "col_indices length ({}) must equal values length ({})",
71                col_indices.len(),
72                values.len()
73            )));
74        }
75        if !row_ptr.is_empty() && row_ptr[0] != 0 {
76            return Err(SparseError::InvalidFormat(
77                "row_ptr[0] must be 0".to_string(),
78            ));
79        }
80        if let Some(&last) = row_ptr.last() {
81            if last != values.len() {
82                return Err(SparseError::InvalidFormat(format!(
83                    "row_ptr[nrows] ({}) must equal nnz ({})",
84                    last,
85                    values.len()
86                )));
87            }
88        }
89        for i in 0..nrows {
90            if row_ptr[i] > row_ptr[i + 1] {
91                return Err(SparseError::InvalidFormat(
92                    "row_ptr must be non-decreasing".to_string(),
93                ));
94            }
95        }
96        for &c in &col_indices {
97            if c >= ncols {
98                return Err(SparseError::InvalidFormat(format!(
99                    "column index {c} out of range (ncols = {ncols})"
100                )));
101            }
102        }
103        Ok(Self {
104            nrows,
105            ncols,
106            row_ptr,
107            col_indices,
108            values,
109        })
110    }
111
112    /// Downloads a GPU CSR matrix into a host CSR matrix.
113    ///
114    /// The values are converted to `f64`. Column indices within each row are
115    /// sorted ascending so the host kernels can merge rows efficiently.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`SparseError::Cuda`] on a device-to-host transfer failure.
120    pub fn from_gpu<T: GpuFloat>(matrix: &CsrMatrix<T>) -> SparseResult<Self> {
121        let (h_row_ptr, h_col_idx, h_values) = matrix.to_host()?;
122        let nrows = matrix.rows() as usize;
123        let ncols = matrix.cols() as usize;
124
125        let mut row_ptr = vec![0usize; nrows + 1];
126        let mut col_indices = Vec::with_capacity(h_col_idx.len());
127        let mut values = Vec::with_capacity(h_values.len());
128
129        for i in 0..nrows {
130            let start = h_row_ptr[i] as usize;
131            let end = h_row_ptr[i + 1] as usize;
132            // Sort this row's entries by column index.
133            let mut entries: Vec<(usize, f64)> = (start..end)
134                .map(|k| (h_col_idx[k] as usize, gpu_to_f64(h_values[k])))
135                .collect();
136            entries.sort_by_key(|&(c, _)| c);
137            for (c, v) in entries {
138                col_indices.push(c);
139                values.push(v);
140            }
141            row_ptr[i + 1] = col_indices.len();
142        }
143
144        Self::new(nrows, ncols, row_ptr, col_indices, values)
145    }
146
147    /// Materialises this host matrix onto the device as a GPU CSR matrix.
148    ///
149    /// Empty matrices are rejected because the device format forbids zero nnz.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`SparseError::ZeroNnz`] if the matrix has no stored entries, or
154    /// [`SparseError::Cuda`] on an allocation/transfer failure.
155    pub fn to_gpu<T: GpuFloat>(&self) -> SparseResult<CsrMatrix<T>> {
156        if self.values.is_empty() {
157            return Err(SparseError::ZeroNnz);
158        }
159        let row_ptr: Vec<i32> = self.row_ptr.iter().map(|&x| x as i32).collect();
160        let col_idx: Vec<i32> = self.col_indices.iter().map(|&x| x as i32).collect();
161        let values: Vec<T> = self.values.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
162        CsrMatrix::from_host(
163            self.nrows as u32,
164            self.ncols as u32,
165            &row_ptr,
166            &col_idx,
167            &values,
168        )
169    }
170
171    /// Number of stored non-zeros.
172    #[inline]
173    pub fn nnz(&self) -> usize {
174        self.values.len()
175    }
176
177    /// Returns the value at `(row, col)` if present, else `None`.
178    pub fn get(&self, row: usize, col: usize) -> Option<f64> {
179        let start = self.row_ptr[row];
180        let end = self.row_ptr[row + 1];
181        // Column indices are sorted; binary search.
182        match self.col_indices[start..end].binary_search(&col) {
183            Ok(pos) => Some(self.values[start + pos]),
184            Err(_) => None,
185        }
186    }
187
188    /// Extracts the main diagonal as a dense vector of length `min(nrows, ncols)`.
189    pub fn diagonal(&self) -> Vec<f64> {
190        let n = self.nrows.min(self.ncols);
191        let mut diag = vec![0.0f64; n];
192        for (i, slot) in diag.iter_mut().enumerate() {
193            if let Some(v) = self.get(i, i) {
194                *slot = v;
195            }
196        }
197        diag
198    }
199
200    /// Computes `y = A * x` for a dense vector `x` of length `ncols`.
201    ///
202    /// The output has length `nrows`. This is the host SpMV reference reused by
203    /// every iterative method in the module.
204    pub fn matvec(&self, x: &[f64]) -> Vec<f64> {
205        let mut y = vec![0.0f64; self.nrows];
206        for (i, yi) in y.iter_mut().enumerate() {
207            let start = self.row_ptr[i];
208            let end = self.row_ptr[i + 1];
209            let mut acc = 0.0f64;
210            for k in start..end {
211                acc += self.values[k] * x[self.col_indices[k]];
212            }
213            *yi = acc;
214        }
215        y
216    }
217
218    /// Returns the transpose `Aᵀ` as a new host CSR matrix.
219    ///
220    /// Used to form the restriction operator `R = Pᵀ` in algebraic multigrid.
221    pub fn transpose(&self) -> HostCsr {
222        let mut col_counts = vec![0usize; self.ncols];
223        for &c in &self.col_indices {
224            col_counts[c] += 1;
225        }
226        let mut t_row_ptr = vec![0usize; self.ncols + 1];
227        for j in 0..self.ncols {
228            t_row_ptr[j + 1] = t_row_ptr[j] + col_counts[j];
229        }
230        let nnz = self.values.len();
231        let mut t_col_indices = vec![0usize; nnz];
232        let mut t_values = vec![0.0f64; nnz];
233        let mut write_pos = t_row_ptr.clone();
234        for i in 0..self.nrows {
235            let start = self.row_ptr[i];
236            let end = self.row_ptr[i + 1];
237            for k in start..end {
238                let c = self.col_indices[k];
239                let dest = write_pos[c];
240                t_col_indices[dest] = i;
241                t_values[dest] = self.values[k];
242                write_pos[c] += 1;
243            }
244        }
245        HostCsr {
246            nrows: self.ncols,
247            ncols: self.nrows,
248            row_ptr: t_row_ptr,
249            col_indices: t_col_indices,
250            values: t_values,
251        }
252    }
253
254    /// Computes the sparse-sparse product `C = A * B`.
255    ///
256    /// This is the host counterpart of the crate's GPU SpGEMM (symbolic +
257    /// numeric phases) and is the workhorse of the algebraic-multigrid Galerkin
258    /// triple product `Pᵀ A P`. The implementation uses a per-row dense
259    /// accumulator (sparse accumulator / SPA) with a touched-column list, which
260    /// is the standard Gustavson formulation.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`SparseError::DimensionMismatch`] if `self.ncols != rhs.nrows`.
265    pub fn matmul(&self, rhs: &HostCsr) -> SparseResult<HostCsr> {
266        if self.ncols != rhs.nrows {
267            return Err(SparseError::DimensionMismatch(format!(
268                "A.ncols ({}) != B.nrows ({})",
269                self.ncols, rhs.nrows
270            )));
271        }
272        let out_cols = rhs.ncols;
273        let mut c_row_ptr = vec![0usize; self.nrows + 1];
274        let mut c_col_indices: Vec<usize> = Vec::new();
275        let mut c_values: Vec<f64> = Vec::new();
276
277        // Dense accumulator across columns of the result, plus a marker array
278        // recording which columns are currently active in the row being built.
279        let mut accum = vec![0.0f64; out_cols];
280        let mut touched = vec![false; out_cols];
281        let mut touched_cols: Vec<usize> = Vec::new();
282
283        for i in 0..self.nrows {
284            let a_start = self.row_ptr[i];
285            let a_end = self.row_ptr[i + 1];
286            for ak in a_start..a_end {
287                let a_col = self.col_indices[ak];
288                let a_val = self.values[ak];
289                let b_start = rhs.row_ptr[a_col];
290                let b_end = rhs.row_ptr[a_col + 1];
291                for bk in b_start..b_end {
292                    let b_col = rhs.col_indices[bk];
293                    if !touched[b_col] {
294                        touched[b_col] = true;
295                        touched_cols.push(b_col);
296                    }
297                    accum[b_col] += a_val * rhs.values[bk];
298                }
299            }
300            // Emit the accumulated row in sorted column order, dropping exact
301            // zeros that arose from cancellation.
302            touched_cols.sort_unstable();
303            for &col in &touched_cols {
304                let v = accum[col];
305                if v != 0.0 {
306                    c_col_indices.push(col);
307                    c_values.push(v);
308                }
309                accum[col] = 0.0;
310                touched[col] = false;
311            }
312            touched_cols.clear();
313            c_row_ptr[i + 1] = c_col_indices.len();
314        }
315
316        Ok(HostCsr {
317            nrows: self.nrows,
318            ncols: out_cols,
319            row_ptr: c_row_ptr,
320            col_indices: c_col_indices,
321            values: c_values,
322        })
323    }
324
325    /// Converts the matrix to a dense row-major `nrows × ncols` array.
326    ///
327    /// Intended for small matrices (coarse-level operators, test assertions).
328    pub fn to_dense(&self) -> Vec<f64> {
329        let mut dense = vec![0.0f64; self.nrows * self.ncols];
330        for i in 0..self.nrows {
331            let start = self.row_ptr[i];
332            let end = self.row_ptr[i + 1];
333            for k in start..end {
334                dense[i * self.ncols + self.col_indices[k]] = self.values[k];
335            }
336        }
337        dense
338    }
339}
340
341/// Solves the dense linear system `A x = b` via Gaussian elimination with
342/// partial pivoting. `a` is row-major `n × n`; `b` has length `n`.
343///
344/// Used as the direct coarse-grid solver in algebraic multigrid.
345///
346/// # Errors
347///
348/// Returns [`SparseError::SingularMatrix`] if a pivot is numerically zero.
349pub fn dense_solve(a: &[f64], b: &[f64], n: usize) -> SparseResult<Vec<f64>> {
350    let mut m = a.to_vec();
351    let mut rhs = b.to_vec();
352
353    for col in 0..n {
354        // Partial pivot: find the largest magnitude entry in this column.
355        let mut pivot_row = col;
356        let mut pivot_mag = m[col * n + col].abs();
357        for r in (col + 1)..n {
358            let mag = m[r * n + col].abs();
359            if mag > pivot_mag {
360                pivot_mag = mag;
361                pivot_row = r;
362            }
363        }
364        if pivot_mag < 1e-300 {
365            return Err(SparseError::SingularMatrix);
366        }
367        if pivot_row != col {
368            for c in 0..n {
369                m.swap(col * n + c, pivot_row * n + c);
370            }
371            rhs.swap(col, pivot_row);
372        }
373        let pivot = m[col * n + col];
374        for r in (col + 1)..n {
375            let factor = m[r * n + col] / pivot;
376            if factor != 0.0 {
377                for c in col..n {
378                    m[r * n + c] -= factor * m[col * n + c];
379                }
380                rhs[r] -= factor * rhs[col];
381            }
382        }
383    }
384
385    // Back substitution.
386    let mut x = vec![0.0f64; n];
387    for col in (0..n).rev() {
388        let mut acc = rhs[col];
389        for c in (col + 1)..n {
390            acc -= m[col * n + c] * x[c];
391        }
392        x[col] = acc / m[col * n + col];
393    }
394    Ok(x)
395}
396
397/// Converts a [`GpuFloat`] storage value to `f64` using the crate's bit-cast
398/// convention (matching the conversion helpers in the IC(0)/ILU(k) modules).
399#[inline]
400pub fn gpu_to_f64<T: GpuFloat>(v: T) -> f64 {
401    if T::SIZE == 4 {
402        f64::from(f32::from_bits(v.to_bits_u64() as u32))
403    } else {
404        f64::from_bits(v.to_bits_u64())
405    }
406}
407
408/// Converts an `f64` to a [`GpuFloat`] storage value.
409#[inline]
410pub fn f64_to_gpu<T: GpuFloat>(v: f64) -> T {
411    if T::SIZE == 4 {
412        T::from_bits_u64(u64::from((v as f32).to_bits()))
413    } else {
414        T::from_bits_u64(v.to_bits())
415    }
416}
417
418/// Builds the 1-D Laplacian `tridiag(-1, 2, -1)` of order `n` as a host CSR
419/// matrix. Shared test helper for the SPD-matrix algorithms.
420#[cfg(test)]
421pub(crate) fn laplacian_1d(n: usize) -> HostCsr {
422    let mut row_ptr = vec![0usize; n + 1];
423    let mut col_indices = Vec::new();
424    let mut values = Vec::new();
425    for i in 0..n {
426        if i > 0 {
427            col_indices.push(i - 1);
428            values.push(-1.0);
429        }
430        col_indices.push(i);
431        values.push(2.0);
432        if i + 1 < n {
433            col_indices.push(i + 1);
434            values.push(-1.0);
435        }
436        row_ptr[i + 1] = col_indices.len();
437    }
438    HostCsr {
439        nrows: n,
440        ncols: n,
441        row_ptr,
442        col_indices,
443        values,
444    }
445}
446
447/// Builds the 2-D 5-point Laplacian on a `gx × gy` grid as a host CSR matrix
448/// of order `gx * gy`. Shared test helper.
449#[cfg(test)]
450pub(crate) fn laplacian_2d(gx: usize, gy: usize) -> HostCsr {
451    let n = gx * gy;
452    let mut row_ptr = vec![0usize; n + 1];
453    let mut col_indices = Vec::new();
454    let mut values = Vec::new();
455    let idx = |x: usize, y: usize| -> usize { y * gx + x };
456    for y in 0..gy {
457        for x in 0..gx {
458            // Collect neighbour entries, then sort by column index.
459            let mut entries: Vec<(usize, f64)> = Vec::new();
460            entries.push((idx(x, y), 4.0));
461            if x > 0 {
462                entries.push((idx(x - 1, y), -1.0));
463            }
464            if x + 1 < gx {
465                entries.push((idx(x + 1, y), -1.0));
466            }
467            if y > 0 {
468                entries.push((idx(x, y - 1), -1.0));
469            }
470            if y + 1 < gy {
471                entries.push((idx(x, y + 1), -1.0));
472            }
473            entries.sort_by_key(|&(c, _)| c);
474            for (c, v) in entries {
475                col_indices.push(c);
476                values.push(v);
477            }
478            row_ptr[idx(x, y) + 1] = col_indices.len();
479        }
480    }
481    HostCsr {
482        nrows: n,
483        ncols: n,
484        row_ptr,
485        col_indices,
486        values,
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn new_validates_row_ptr_length() {
496        let r = HostCsr::new(2, 2, vec![0, 1], vec![0], vec![1.0]);
497        assert!(r.is_err());
498    }
499
500    #[test]
501    fn new_validates_col_range() {
502        let r = HostCsr::new(2, 2, vec![0, 1, 2], vec![0, 5], vec![1.0, 2.0]);
503        assert!(r.is_err());
504    }
505
506    #[test]
507    fn diagonal_extraction() {
508        let a = laplacian_1d(4);
509        assert_eq!(a.diagonal(), vec![2.0, 2.0, 2.0, 2.0]);
510    }
511
512    #[test]
513    fn get_returns_entries() {
514        let a = laplacian_1d(3);
515        assert_eq!(a.get(0, 0), Some(2.0));
516        assert_eq!(a.get(0, 1), Some(-1.0));
517        assert_eq!(a.get(0, 2), None);
518        assert_eq!(a.get(1, 0), Some(-1.0));
519    }
520
521    #[test]
522    fn matvec_laplacian() {
523        let a = laplacian_1d(4);
524        // A * [1,1,1,1] = [1, 0, 0, 1]
525        let y = a.matvec(&[1.0, 1.0, 1.0, 1.0]);
526        assert_eq!(y, vec![1.0, 0.0, 0.0, 1.0]);
527    }
528
529    #[test]
530    fn transpose_of_symmetric_is_self() {
531        let a = laplacian_1d(5);
532        let at = a.transpose();
533        assert_eq!(at.nrows, a.nrows);
534        assert_eq!(at.ncols, a.ncols);
535        // Symmetric: A == Aᵀ entrywise.
536        for i in 0..a.nrows {
537            for j in 0..a.ncols {
538                assert_eq!(a.get(i, j), at.get(i, j));
539            }
540        }
541    }
542
543    #[test]
544    fn transpose_rectangular() {
545        // 2x3 matrix:
546        // [1 0 2]
547        // [0 3 0]
548        let a = HostCsr::new(2, 3, vec![0, 2, 3], vec![0, 2, 1], vec![1.0, 2.0, 3.0])
549            .expect("valid csr");
550        let at = a.transpose();
551        assert_eq!(at.nrows, 3);
552        assert_eq!(at.ncols, 2);
553        assert_eq!(at.get(0, 0), Some(1.0));
554        assert_eq!(at.get(2, 0), Some(2.0));
555        assert_eq!(at.get(1, 1), Some(3.0));
556    }
557
558    #[test]
559    fn matmul_identity() {
560        // I * A == A
561        let a = laplacian_1d(4);
562        let eye = HostCsr::new(
563            4,
564            4,
565            vec![0, 1, 2, 3, 4],
566            vec![0, 1, 2, 3],
567            vec![1.0, 1.0, 1.0, 1.0],
568        )
569        .expect("valid csr");
570        let c = eye.matmul(&a).expect("matmul");
571        for i in 0..4 {
572            for j in 0..4 {
573                assert_eq!(c.get(i, j), a.get(i, j));
574            }
575        }
576    }
577
578    #[test]
579    fn matmul_matches_dense() {
580        // A = [[1,2,0],[0,3,4],[5,0,6]] ; B = [[7,0],[0,8],[9,0]]
581        let a = HostCsr::new(
582            3,
583            3,
584            vec![0, 2, 4, 6],
585            vec![0, 1, 1, 2, 0, 2],
586            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
587        )
588        .expect("valid");
589        let b = HostCsr::new(3, 2, vec![0, 1, 2, 3], vec![0, 1, 0], vec![7.0, 8.0, 9.0])
590            .expect("valid");
591        let c = a.matmul(&b).expect("matmul");
592        // C = A*B = [[7,16],[36,24],[89,0]]
593        assert_eq!(c.get(0, 0), Some(7.0));
594        assert_eq!(c.get(0, 1), Some(16.0));
595        assert_eq!(c.get(1, 0), Some(36.0));
596        assert_eq!(c.get(1, 1), Some(24.0));
597        assert_eq!(c.get(2, 0), Some(89.0));
598        assert_eq!(c.get(2, 1), None);
599    }
600
601    #[test]
602    fn dense_solve_small() {
603        // [[2,1],[1,3]] x = [3,5] -> x = [0.8, 1.4]
604        let a = vec![2.0, 1.0, 1.0, 3.0];
605        let b = vec![3.0, 5.0];
606        let x = dense_solve(&a, &b, 2).expect("solve");
607        assert!((x[0] - 0.8).abs() < 1e-12);
608        assert!((x[1] - 1.4).abs() < 1e-12);
609    }
610
611    #[test]
612    fn dense_solve_singular_errors() {
613        let a = vec![1.0, 2.0, 2.0, 4.0];
614        let b = vec![1.0, 2.0];
615        assert!(dense_solve(&a, &b, 2).is_err());
616    }
617
618    #[test]
619    fn gpu_f64_roundtrip() {
620        let v = 3.5_f64;
621        let g = f64_to_gpu::<f64>(v);
622        assert!((gpu_to_f64(g) - v).abs() < 1e-15);
623        let gf = f64_to_gpu::<f32>(v);
624        assert!((gpu_to_f64(gf) - v).abs() < 1e-6);
625    }
626
627    #[test]
628    fn laplacian_2d_structure() {
629        let a = laplacian_2d(3, 3);
630        assert_eq!(a.nrows, 9);
631        // Center node (1,1) -> index 4 has diagonal 4 and four -1 neighbours.
632        assert_eq!(a.get(4, 4), Some(4.0));
633        let start = a.row_ptr[4];
634        let end = a.row_ptr[4 + 1];
635        assert_eq!(end - start, 5);
636    }
637}