Skip to main content

oxiphysics_gpu/
gpu_sparse_solver.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU sparse linear system solver (CPU mock implementation).
5//!
6//! Provides CSR sparse matrix storage, SpMV, dot product, AXPY, conjugate
7//! gradient (CG), and preconditioned CG solvers that mirror GPU dispatch
8//! semantics while executing on the CPU.
9
10// ── CSR Sparse Matrix ────────────────────────────────────────────────────────
11
12/// Compressed Sparse Row (CSR) matrix stored in GPU-friendly layout.
13///
14/// Rows have entries in `col_idx[row_ptr[i]..row_ptr[i+1]]` with
15/// corresponding values in `values[row_ptr[i]..row_ptr[i+1]]`.
16#[derive(Debug, Clone)]
17pub struct SparseMatrixGpu {
18    /// Number of rows (and columns — assumed square).
19    pub n: usize,
20    /// Number of non-zero entries.
21    pub nnz: usize,
22    /// Row pointer array (length n+1).
23    pub row_ptr: Vec<usize>,
24    /// Column index array (length nnz).
25    pub col_idx: Vec<usize>,
26    /// Value array (length nnz).
27    pub values: Vec<f64>,
28}
29
30impl SparseMatrixGpu {
31    /// Create a new empty n×n sparse matrix.
32    pub fn new(n: usize) -> Self {
33        Self {
34            n,
35            nnz: 0,
36            row_ptr: vec![0usize; n + 1],
37            col_idx: Vec::new(),
38            values: Vec::new(),
39        }
40    }
41
42    /// Construct from pre-built CSR arrays.
43    ///
44    /// # Panics
45    /// Panics in debug mode if `row_ptr.len() != n + 1`.
46    pub fn from_csr(n: usize, row_ptr: Vec<usize>, col_idx: Vec<usize>, values: Vec<f64>) -> Self {
47        debug_assert_eq!(row_ptr.len(), n + 1);
48        let nnz = values.len();
49        Self {
50            n,
51            nnz,
52            row_ptr,
53            col_idx,
54            values,
55        }
56    }
57
58    /// Append a single entry `(row, col, val)` in COO style.
59    ///
60    /// This invalidates the CSR structure until [`finalize`](Self::finalize) is called.
61    pub fn add_entry(&mut self, row: usize, col: usize, val: f64) {
62        // Store as a pending triplet encoded in the arrays.
63        self.col_idx.push(col);
64        self.values.push(val);
65        // Use row_ptr[row+1] as a count (finalize will prefix-sum them).
66        if row + 1 < self.row_ptr.len() {
67            self.row_ptr[row + 1] += 1;
68        }
69        self.nnz += 1;
70    }
71
72    /// Convert count-per-row in `row_ptr` to proper CSR offsets.
73    ///
74    /// Must be called after a series of [`add_entry`](Self::add_entry) calls
75    /// and before any SpMV or solver operations.
76    pub fn finalize(&mut self) {
77        // Prefix-sum row_ptr in-place.
78        for i in 1..=self.n {
79            self.row_ptr[i] += self.row_ptr[i - 1];
80        }
81        self.nnz = *self.row_ptr.last().unwrap_or(&0);
82    }
83
84    /// Return the number of non-zero entries.
85    pub fn nnz(&self) -> usize {
86        self.nnz
87    }
88
89    /// Return the number of rows.
90    pub fn rows(&self) -> usize {
91        self.n
92    }
93
94    /// Extract the main diagonal as a dense vector.
95    ///
96    /// Missing diagonal entries are filled with zero.
97    pub fn diagonal(&self) -> Vec<f64> {
98        let mut diag = vec![0.0f64; self.n];
99        for (row, d) in diag.iter_mut().enumerate() {
100            let start = self.row_ptr[row];
101            let end = self.row_ptr[row + 1];
102            for k in start..end {
103                if self.col_idx[k] == row {
104                    *d = self.values[k];
105                }
106            }
107        }
108        diag
109    }
110
111    /// Check whether the matrix is structurally symmetric (A\[i,j\] ↔ A\[j,i\]).
112    pub fn is_symmetric(&self) -> bool {
113        for row in 0..self.n {
114            let start = self.row_ptr[row];
115            let end = self.row_ptr[row + 1];
116            for k in start..end {
117                let col = self.col_idx[k];
118                let val = self.values[k];
119                // Look for the transpose entry.
120                let mut found = false;
121                let cs = self.row_ptr[col];
122                let ce = self.row_ptr[col + 1];
123                for m in cs..ce {
124                    if self.col_idx[m] == row {
125                        if (self.values[m] - val).abs() > 1e-12 {
126                            return false;
127                        }
128                        found = true;
129                        break;
130                    }
131                }
132                if !found {
133                    return false;
134                }
135            }
136        }
137        true
138    }
139
140    /// Frobenius norm: √(∑ aᵢⱼ²).
141    pub fn frobenius_norm(&self) -> f64 {
142        self.values.iter().map(|v| v * v).sum::<f64>().sqrt()
143    }
144}
145
146// ── Utility constructors ─────────────────────────────────────────────────────
147
148/// Build an n×n identity matrix in CSR format.
149pub fn sparse_identity(n: usize) -> SparseMatrixGpu {
150    let row_ptr: Vec<usize> = (0..=n).collect();
151    let col_idx: Vec<usize> = (0..n).collect();
152    let values = vec![1.0f64; n];
153    SparseMatrixGpu::from_csr(n, row_ptr, col_idx, values)
154}
155
156/// Build a diagonal matrix from a dense vector.
157pub fn sparse_diagonal_matrix(diag: &[f64]) -> SparseMatrixGpu {
158    let n = diag.len();
159    let row_ptr: Vec<usize> = (0..=n).collect();
160    let col_idx: Vec<usize> = (0..n).collect();
161    let values = diag.to_vec();
162    SparseMatrixGpu::from_csr(n, row_ptr, col_idx, values)
163}
164
165// ── BLAS-like primitives ─────────────────────────────────────────────────────
166
167/// Sparse matrix–vector product: y = A · x.
168///
169/// # Panics
170/// Panics if `x.len() != mat.n`.
171pub fn gpu_spmv(mat: &SparseMatrixGpu, x: &[f64]) -> Vec<f64> {
172    assert_eq!(x.len(), mat.n, "gpu_spmv: x length mismatch");
173    let mut y = vec![0.0f64; mat.n];
174    for (row, y_row) in y.iter_mut().enumerate() {
175        let start = mat.row_ptr[row];
176        let end = mat.row_ptr[row + 1];
177        let mut sum = 0.0f64;
178        for k in start..end {
179            sum += mat.values[k] * x[mat.col_idx[k]];
180        }
181        *y_row = sum;
182    }
183    y
184}
185
186/// Dense dot product: ∑ aᵢ bᵢ.
187///
188/// Returns 0 for empty slices.
189pub fn gpu_dot(a: &[f64], b: &[f64]) -> f64 {
190    a.iter().zip(b.iter()).map(|(ai, bi)| ai * bi).sum()
191}
192
193/// In-place AXPY: y ← a·x + y.
194///
195/// # Panics
196/// Panics if `x.len() != y.len()`.
197pub fn gpu_axpy(a: f64, x: &[f64], y: &mut [f64]) {
198    assert_eq!(x.len(), y.len(), "gpu_axpy: length mismatch");
199    for (yi, &xi) in y.iter_mut().zip(x.iter()) {
200        *yi += a * xi;
201    }
202}
203
204// ── Solvers ──────────────────────────────────────────────────────────────────
205
206/// Conjugate gradient solver for symmetric positive-definite systems A·x = b.
207///
208/// Returns `(x, iterations, final_residual_norm)`.
209/// Terminates when the relative residual ‖r‖/‖b‖ < `tol` or after `max_iter`
210/// steps.
211pub fn gpu_cg_solver(
212    mat: &SparseMatrixGpu,
213    b: &[f64],
214    max_iter: usize,
215    tol: f64,
216) -> (Vec<f64>, usize, f64) {
217    let n = mat.n;
218    let mut x = vec![0.0f64; n];
219    let mut r = b.to_vec(); // r = b - A*x; x=0 so r=b
220    let mut p = r.clone();
221    let mut rr = gpu_dot(&r, &r);
222    let b_norm = rr.sqrt().max(1e-100);
223
224    for iter in 0..max_iter {
225        if rr.sqrt() / b_norm < tol {
226            return (x, iter, rr.sqrt());
227        }
228        let ap = gpu_spmv(mat, &p);
229        let pap = gpu_dot(&p, &ap);
230        if pap.abs() < 1e-300 {
231            break;
232        }
233        let alpha = rr / pap;
234        gpu_axpy(alpha, &p, &mut x);
235        gpu_axpy(-alpha, &ap, &mut r);
236        let rr_new = gpu_dot(&r, &r);
237        let beta = rr_new / rr.max(1e-300);
238        // p = r + beta*p
239        for i in 0..n {
240            p[i] = r[i] + beta * p[i];
241        }
242        rr = rr_new;
243    }
244    (x, max_iter, rr.sqrt())
245}
246
247/// Build the Jacobi (diagonal) preconditioner for matrix `mat`.
248///
249/// Returns a vector `M⁻¹` where `M⁻¹[i] = 1/A[i,i]` (or 1 if the diagonal
250/// entry is zero).
251pub fn gpu_jacobi_preconditioner(mat: &SparseMatrixGpu) -> Vec<f64> {
252    mat.diagonal()
253        .iter()
254        .map(|&d| if d.abs() > 1e-15 { 1.0 / d } else { 1.0 })
255        .collect()
256}
257
258/// Preconditioned conjugate gradient solver.
259///
260/// `precond` should be a vector of reciprocal diagonal entries (from
261/// [`gpu_jacobi_preconditioner`]).  Returns `(x, iterations, residual_norm)`.
262pub fn gpu_pcg_solver(
263    mat: &SparseMatrixGpu,
264    b: &[f64],
265    precond: &[f64],
266    max_iter: usize,
267    tol: f64,
268) -> (Vec<f64>, usize, f64) {
269    let n = mat.n;
270    let mut x = vec![0.0f64; n];
271    let mut r = b.to_vec();
272    // z = M⁻¹ r
273    let mut z: Vec<f64> = r
274        .iter()
275        .zip(precond.iter())
276        .map(|(ri, mi)| ri * mi)
277        .collect();
278    let mut p = z.clone();
279    let mut rz = gpu_dot(&r, &z);
280    let b_norm = gpu_dot(b, b).sqrt().max(1e-100);
281
282    for iter in 0..max_iter {
283        if gpu_dot(&r, &r).sqrt() / b_norm < tol {
284            return (x, iter, gpu_dot(&r, &r).sqrt());
285        }
286        let ap = gpu_spmv(mat, &p);
287        let pap = gpu_dot(&p, &ap);
288        if pap.abs() < 1e-300 {
289            break;
290        }
291        let alpha = rz / pap;
292        gpu_axpy(alpha, &p, &mut x);
293        gpu_axpy(-alpha, &ap, &mut r);
294        z = r
295            .iter()
296            .zip(precond.iter())
297            .map(|(ri, mi)| ri * mi)
298            .collect();
299        let rz_new = gpu_dot(&r, &z);
300        let beta = rz_new / rz.max(1e-300);
301        for i in 0..n {
302            p[i] = z[i] + beta * p[i];
303        }
304        rz = rz_new;
305    }
306    (x, max_iter, gpu_dot(&r, &r).sqrt())
307}
308
309// ── Stats ────────────────────────────────────────────────────────────────────
310
311/// Performance statistics for a GPU sparse solve.
312#[derive(Debug, Clone)]
313pub struct GpuSparseSolverStats {
314    /// Number of solver iterations performed.
315    pub iterations: usize,
316    /// ‖r‖ after the final iteration.
317    pub final_residual: f64,
318    /// Whether the solver converged within the requested tolerance.
319    pub converged: bool,
320    /// Wall-clock time in milliseconds (mock — always 0.0 in CPU simulation).
321    pub time_ms: f64,
322}
323
324impl GpuSparseSolverStats {
325    /// Create a new stats record.
326    pub fn new(iterations: usize, final_residual: f64, converged: bool, time_ms: f64) -> Self {
327        Self {
328            iterations,
329            final_residual,
330            converged,
331            time_ms,
332        }
333    }
334}
335
336// ============================================================
337// Tests
338// ============================================================
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    // ── helpers ─────────────────────────────────────────────────────────────
344
345    /// Build a simple 3×3 SPD matrix:
346    /// \[ 4 -1  0 \]
347    /// \[-1  4 -1 \]
348    /// \[ 0 -1  4 \]
349    fn build_3x3_spd() -> SparseMatrixGpu {
350        let row_ptr = vec![0, 2, 5, 7];
351        let col_idx = vec![0, 1, 0, 1, 2, 1, 2];
352        let values = vec![4.0, -1.0, -1.0, 4.0, -1.0, -1.0, 4.0];
353        SparseMatrixGpu::from_csr(3, row_ptr, col_idx, values)
354    }
355
356    // ── identity ─────────────────────────────────────────────────────────────
357
358    #[test]
359    fn test_identity_spmv_returns_input() {
360        let id = sparse_identity(4);
361        let x = vec![1.0, 2.0, 3.0, 4.0];
362        let y = gpu_spmv(&id, &x);
363        for (yi, xi) in y.iter().zip(x.iter()) {
364            assert!((yi - xi).abs() < 1e-12);
365        }
366    }
367
368    #[test]
369    fn test_identity_nnz() {
370        let id = sparse_identity(5);
371        assert_eq!(id.nnz(), 5);
372    }
373
374    #[test]
375    fn test_identity_rows() {
376        let id = sparse_identity(7);
377        assert_eq!(id.rows(), 7);
378    }
379
380    #[test]
381    fn test_identity_diagonal() {
382        let id = sparse_identity(4);
383        let diag = id.diagonal();
384        assert_eq!(diag, vec![1.0; 4]);
385    }
386
387    #[test]
388    fn test_identity_frobenius() {
389        let n = 9usize;
390        let id = sparse_identity(n);
391        let expected = (n as f64).sqrt();
392        assert!((id.frobenius_norm() - expected).abs() < 1e-10);
393    }
394
395    #[test]
396    fn test_identity_is_symmetric() {
397        assert!(sparse_identity(4).is_symmetric());
398    }
399
400    // ── diagonal matrix ──────────────────────────────────────────────────────
401
402    #[test]
403    fn test_diagonal_matrix_spmv() {
404        let d = vec![2.0, 3.0, 5.0];
405        let mat = sparse_diagonal_matrix(&d);
406        let x = vec![1.0, 1.0, 1.0];
407        let y = gpu_spmv(&mat, &x);
408        assert!((y[0] - 2.0).abs() < 1e-12);
409        assert!((y[1] - 3.0).abs() < 1e-12);
410        assert!((y[2] - 5.0).abs() < 1e-12);
411    }
412
413    #[test]
414    fn test_diagonal_matrix_frobenius() {
415        let d = vec![3.0, 4.0];
416        let mat = sparse_diagonal_matrix(&d);
417        assert!((mat.frobenius_norm() - 5.0).abs() < 1e-10);
418    }
419
420    #[test]
421    fn test_diagonal_matrix_is_symmetric() {
422        let mat = sparse_diagonal_matrix(&[1.0, 2.0, 3.0]);
423        assert!(mat.is_symmetric());
424    }
425
426    #[test]
427    fn test_sparse_identity_zero_size() {
428        let id = sparse_identity(0);
429        assert_eq!(id.nnz(), 0);
430        assert_eq!(id.rows(), 0);
431    }
432
433    // ── dot product ──────────────────────────────────────────────────────────
434
435    #[test]
436    fn test_gpu_dot_basic() {
437        assert!((gpu_dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]) - 32.0).abs() < 1e-12);
438    }
439
440    #[test]
441    fn test_gpu_dot_empty() {
442        assert!((gpu_dot(&[], &[])).abs() < 1e-15);
443    }
444
445    #[test]
446    fn test_gpu_dot_orthogonal() {
447        assert!((gpu_dot(&[1.0, 0.0], &[0.0, 1.0])).abs() < 1e-15);
448    }
449
450    // ── axpy ─────────────────────────────────────────────────────────────────
451
452    #[test]
453    fn test_gpu_axpy_basic() {
454        let x = vec![1.0, 2.0, 3.0];
455        let mut y = vec![4.0, 5.0, 6.0];
456        gpu_axpy(2.0, &x, &mut y);
457        assert!((y[0] - 6.0).abs() < 1e-12);
458        assert!((y[1] - 9.0).abs() < 1e-12);
459        assert!((y[2] - 12.0).abs() < 1e-12);
460    }
461
462    #[test]
463    fn test_gpu_axpy_zero_alpha() {
464        let x = vec![1.0, 2.0];
465        let mut y = vec![3.0, 4.0];
466        gpu_axpy(0.0, &x, &mut y);
467        assert!((y[0] - 3.0).abs() < 1e-12);
468        assert!((y[1] - 4.0).abs() < 1e-12);
469    }
470
471    // ── spmv ─────────────────────────────────────────────────────────────────
472
473    #[test]
474    fn test_spmv_3x3_spd() {
475        let mat = build_3x3_spd();
476        let x = vec![1.0, 0.0, 0.0];
477        let y = gpu_spmv(&mat, &x);
478        assert!((y[0] - 4.0).abs() < 1e-12);
479        assert!((y[1] + 1.0).abs() < 1e-12);
480        assert!((y[2]).abs() < 1e-12);
481    }
482
483    #[test]
484    fn test_spmv_zeros_input() {
485        let mat = build_3x3_spd();
486        let y = gpu_spmv(&mat, &[0.0, 0.0, 0.0]);
487        for yi in y {
488            assert!(yi.abs() < 1e-15);
489        }
490    }
491
492    // ── from_csr / add_entry / finalize ──────────────────────────────────────
493
494    #[test]
495    fn test_from_csr_nnz() {
496        let mat = build_3x3_spd();
497        assert_eq!(mat.nnz(), 7);
498    }
499
500    #[test]
501    fn test_add_entry_finalize() {
502        let mut mat = SparseMatrixGpu::new(2);
503        mat.add_entry(0, 0, 2.0);
504        mat.add_entry(0, 1, -1.0);
505        mat.add_entry(1, 0, -1.0);
506        mat.add_entry(1, 1, 2.0);
507        mat.finalize();
508        assert_eq!(mat.nnz(), 4);
509        let y = gpu_spmv(&mat, &[1.0, 0.0]);
510        assert!((y[0] - 2.0).abs() < 1e-12);
511        assert!((y[1] + 1.0).abs() < 1e-12);
512    }
513
514    // ── CG solver ────────────────────────────────────────────────────────────
515
516    #[test]
517    fn test_cg_converges_3x3() {
518        let mat = build_3x3_spd();
519        let b = vec![1.0, 0.0, 0.0];
520        let (x, iters, res) = gpu_cg_solver(&mat, &b, 100, 1e-10);
521        assert!(iters < 100, "CG did not converge: iters={iters}");
522        // Verify A*x ≈ b
523        let ax = gpu_spmv(&mat, &x);
524        for (ai, bi) in ax.iter().zip(b.iter()) {
525            assert!((ai - bi).abs() < 1e-8, "residual too large");
526        }
527        let _ = res; // residual checked via A*x
528    }
529
530    #[test]
531    fn test_cg_identity_system() {
532        let id = sparse_identity(3);
533        let b = vec![1.0, 2.0, 3.0];
534        let (x, _iters, _res) = gpu_cg_solver(&id, &b, 50, 1e-10);
535        for (xi, bi) in x.iter().zip(b.iter()) {
536            assert!((xi - bi).abs() < 1e-8);
537        }
538    }
539
540    // ── Jacobi preconditioner ────────────────────────────────────────────────
541
542    #[test]
543    fn test_jacobi_preconditioner_diagonal() {
544        let d = vec![2.0, 4.0, 5.0];
545        let mat = sparse_diagonal_matrix(&d);
546        let prec = gpu_jacobi_preconditioner(&mat);
547        assert!((prec[0] - 0.5).abs() < 1e-12);
548        assert!((prec[1] - 0.25).abs() < 1e-12);
549        assert!((prec[2] - 0.2).abs() < 1e-12);
550    }
551
552    #[test]
553    fn test_jacobi_preconditioner_identity() {
554        let id = sparse_identity(3);
555        let prec = gpu_jacobi_preconditioner(&id);
556        for p in prec {
557            assert!((p - 1.0).abs() < 1e-12);
558        }
559    }
560
561    // ── PCG solver ───────────────────────────────────────────────────────────
562
563    #[test]
564    fn test_pcg_converges_3x3() {
565        let mat = build_3x3_spd();
566        let b = vec![1.0, 0.0, 1.0];
567        let prec = gpu_jacobi_preconditioner(&mat);
568        let (x, iters, _res) = gpu_pcg_solver(&mat, &b, &prec, 100, 1e-10);
569        assert!(iters < 100);
570        let ax = gpu_spmv(&mat, &x);
571        for (ai, bi) in ax.iter().zip(b.iter()) {
572            assert!((ai - bi).abs() < 1e-7);
573        }
574    }
575
576    #[test]
577    fn test_pcg_identity_trivial() {
578        let id = sparse_identity(4);
579        let b = vec![1.0, 2.0, 3.0, 4.0];
580        let prec = gpu_jacobi_preconditioner(&id);
581        let (x, _iters, _res) = gpu_pcg_solver(&id, &b, &prec, 10, 1e-12);
582        for (xi, bi) in x.iter().zip(b.iter()) {
583            assert!((xi - bi).abs() < 1e-8);
584        }
585    }
586
587    // ── stats ────────────────────────────────────────────────────────────────
588
589    #[test]
590    fn test_solver_stats_fields() {
591        let s = GpuSparseSolverStats::new(5, 1e-8, true, 0.0);
592        assert_eq!(s.iterations, 5);
593        assert!(s.converged);
594        assert!((s.final_residual - 1e-8).abs() < 1e-20);
595    }
596
597    // ── is_symmetric ────────────────────────────────────────────────────────
598
599    #[test]
600    fn test_asymmetric_matrix() {
601        // Upper triangular only — not symmetric
602        let row_ptr = vec![0, 2, 3, 3];
603        let col_idx = vec![0, 1, 1, 2];
604        let values = vec![1.0, 2.0, 3.0, 4.0];
605        let mat = SparseMatrixGpu::from_csr(3, row_ptr, col_idx, values);
606        assert!(!mat.is_symmetric());
607    }
608
609    #[test]
610    fn test_frobenius_zero_matrix() {
611        let mat = SparseMatrixGpu::new(4);
612        assert!((mat.frobenius_norm()).abs() < 1e-15);
613    }
614
615    // ── new() ────────────────────────────────────────────────────────────────
616
617    #[test]
618    fn test_new_empty_matrix() {
619        let mat = SparseMatrixGpu::new(3);
620        assert_eq!(mat.n, 3);
621        assert_eq!(mat.nnz, 0);
622        assert_eq!(mat.row_ptr, vec![0; 4]);
623    }
624
625    #[test]
626    fn test_diagonal_of_empty_matrix() {
627        let mat = SparseMatrixGpu::new(3);
628        let diag = mat.diagonal();
629        assert_eq!(diag, vec![0.0; 3]);
630    }
631
632    #[test]
633    fn test_cg_zero_rhs() {
634        let id = sparse_identity(3);
635        let b = vec![0.0; 3];
636        let (x, _iters, res) = gpu_cg_solver(&id, &b, 20, 1e-12);
637        for xi in &x {
638            assert!(xi.abs() < 1e-12);
639        }
640        assert!(res < 1e-10);
641    }
642}