Skip to main content

scirs2_graph/
gpu_cuda.rs

1#![cfg(feature = "cuda")]
2//! Optional, off-by-default, NVIDIA-only CUDA acceleration for sparse graph
3//! linear algebra — specifically a CSR sparse matrix-vector product (SpMV) on a
4//! graph's adjacency matrix.
5//!
6//! This module is compiled only when the `cuda` feature is enabled. Even then it
7//! is *runtime-probed*: [`cuda_is_available`] returns `false` on any platform
8//! without an NVIDIA CUDA device (for example macOS), so callers can compile
9//! everywhere and dispatch to the CPU path
10//! ([`crate::compressed::CsrGraph::spmv`]) when no device is present. Everything
11//! here is `f64`-native — there is no silent `f32` downcast at the boundary.
12//!
13//! # Library-call pattern (vs. custom-kernel pattern)
14//!
15//! Like `scirs2-interpolate`'s CUDA path — and unlike `scirs2-symbolic`'s, which
16//! *emits custom kernels* through the `oxicuda-ptx` builder — this module follows
17//! the **library-call pattern**: it hands the sparse matrix-vector multiply off
18//! to the pre-built, battle-tested `oxicuda-sparse` *library* crate (a pure-Rust
19//! cuSPARSE equivalent). No bespoke kernels are authored here; the
20//! adjacency-matrix CSR is uploaded and `oxicuda_sparse::ops::spmv` does the work.
21//!
22//! # What this accelerates, and where it generalizes
23//!
24//! [`cuda_spmv_csr`] computes `y = A · x`, where `A` is the graph's adjacency
25//! matrix in CSR form (`row_offsets` / `col_indices` / `values`) and `x` is a
26//! dense per-node vector. `y = A · x` is *the* fundamental graph-linear-algebra
27//! primitive: PageRank is iterated SpMV, and a single BFS / SSSP frontier-
28//! expansion step is an SpMV over an appropriate semiring (boolean OR-AND for
29//! BFS, min-plus for SSSP). This slice wires the **plus-times** (numeric) SpMV;
30//! the semiring variants are a natural future extension once `oxicuda-sparse`
31//! exposes semiring SpMV.
32
33use crate::error::{GraphError, Result as GraphResult};
34use oxicuda_sparse::ops::{spmv, SpMVAlgo};
35use oxicuda_sparse::{CsrMatrix, SparseHandle};
36
37/// Returns `true` only when an NVIDIA CUDA device is present and the driver
38/// initialises successfully.
39///
40/// This never panics: on a non-NVIDIA platform such as macOS the CUDA driver
41/// fails to initialise (or reports zero devices) and this returns `false`,
42/// allowing callers to fall back to the CPU SpMV in
43/// [`crate::compressed::CsrGraph::spmv`].
44pub fn cuda_is_available() -> bool {
45    oxicuda_driver::init().is_ok()
46        && oxicuda_driver::device::Device::count()
47            .map(|c| c > 0)
48            .unwrap_or(false)
49}
50
51/// Maps an `oxicuda-sparse` error onto the crate's honest `ComputationError`.
52fn sparse_err(e: oxicuda_sparse::error::SparseError) -> GraphError {
53    GraphError::ComputationError(format!("oxicuda-sparse: {e}"))
54}
55
56/// Maps an `oxicuda` CUDA driver/memory error onto `ComputationError`.
57fn cuda_err(e: oxicuda_driver::CudaError) -> GraphError {
58    GraphError::ComputationError(format!("oxicuda CUDA driver: {e}"))
59}
60
61/// Initialises the CUDA driver, selects device 0, and builds a shared context.
62///
63/// Returns a [`ComputationError`](GraphError::ComputationError) — never a
64/// fabricated success — when CUDA is unavailable or no device is present.
65fn build_context() -> GraphResult<std::sync::Arc<oxicuda_driver::Context>> {
66    oxicuda_driver::init()
67        .map_err(|e| GraphError::ComputationError(format!("CUDA unavailable: {e}")))?;
68    let count = oxicuda_driver::device::Device::count()
69        .map_err(|e| GraphError::ComputationError(format!("device count: {e}")))?;
70    if count <= 0 {
71        return Err(GraphError::ComputationError(
72            "no NVIDIA CUDA device available".into(),
73        ));
74    }
75    let dev = oxicuda_driver::device::Device::get(0).map_err(cuda_err)?;
76    Ok(std::sync::Arc::new(
77        oxicuda_driver::Context::new(&dev).map_err(cuda_err)?,
78    ))
79}
80
81/// Computes the sparse matrix-vector product `y = A · x` on the GPU, where `A`
82/// is a graph adjacency matrix in CSR (Compressed Sparse Row) form.
83///
84/// This is the GPU companion to the CPU [`crate::compressed::CsrGraph::spmv`].
85/// `A` is `n_rows × n_cols`; for a graph adjacency it is square with
86/// `n_rows == n_cols == num_nodes`. The CSR arrays follow oxicuda-sparse's
87/// `i32` index convention:
88///
89/// * `row_offsets` — length `n_rows + 1`, `row_offsets[0] == 0`,
90///   `row_offsets[n_rows] == nnz`, non-decreasing. (This is
91///   `CsrGraph::row_ptr()` cast from `usize` to `i32`.)
92/// * `col_indices` — length `nnz`, each in `0..n_cols`. (This is
93///   `CsrGraph::col_indices()` cast from `usize` to `i32`.)
94/// * `values` — length `nnz`, the edge weights (`CsrGraph::values()`).
95/// * `x` — dense input vector of length `n_cols`.
96///
97/// Returns `y` of length `n_rows`. The multiply is routed through
98/// `oxicuda_sparse::ops::spmv` with `alpha = 1.0`, `beta = 0.0` and
99/// [`SpMVAlgo::Adaptive`] (which picks a scalar- or vector-per-row kernel from
100/// the matrix's average nnz/row).
101///
102/// `y = A · x` is the fundamental graph-linear-algebra primitive: PageRank is
103/// iterated SpMV, and a single BFS / SSSP frontier-expansion step is an SpMV
104/// over a boolean / min-plus semiring respectively — natural future extensions.
105///
106/// # Errors
107///
108/// Returns [`ComputationError`](GraphError::ComputationError) — never a
109/// fabricated success — when CUDA is unavailable, when no NVIDIA device is
110/// present, or when oxicuda-sparse reports a failure. Shape inconsistencies are
111/// reported as [`InvalidGraph`](GraphError::InvalidGraph) *before* any device
112/// work, so they are detectable without a GPU.
113pub fn cuda_spmv_csr(
114    row_offsets: &[i32],
115    col_indices: &[i32],
116    values: &[f64],
117    n_rows: usize,
118    n_cols: usize,
119    x: &[f64],
120) -> GraphResult<Vec<f64>> {
121    // ---- Host-side validation (runs without a GPU) ----
122    if n_rows == 0 {
123        // A 0×n matrix maps any x to an empty y.
124        return Ok(Vec::new());
125    }
126    if row_offsets.len() != n_rows + 1 {
127        return Err(GraphError::InvalidGraph(format!(
128            "cuda_spmv_csr: row_offsets length {} does not match n_rows + 1 = {}",
129            row_offsets.len(),
130            n_rows + 1
131        )));
132    }
133    if col_indices.len() != values.len() {
134        return Err(GraphError::InvalidGraph(format!(
135            "cuda_spmv_csr: col_indices length {} != values length {}",
136            col_indices.len(),
137            values.len()
138        )));
139    }
140    if x.len() != n_cols {
141        return Err(GraphError::InvalidGraph(format!(
142            "cuda_spmv_csr: x length {} != n_cols {}",
143            x.len(),
144            n_cols
145        )));
146    }
147    let nnz = values.len();
148    let last = *row_offsets.last().unwrap_or(&0);
149    if i64::from(last) != nnz as i64 {
150        return Err(GraphError::InvalidGraph(format!(
151            "cuda_spmv_csr: row_offsets last element {last} != nnz {nnz}"
152        )));
153    }
154
155    // An adjacency with no stored edges sends every node's value to 0.
156    if nnz == 0 {
157        return Ok(vec![0.0f64; n_rows]);
158    }
159
160    // ---- GPU path ----
161    let ctx = build_context()?;
162    let handle = SparseHandle::new(&ctx).map_err(sparse_err)?;
163
164    // Upload the CSR adjacency. CsrMatrix::from_host validates structure and
165    // uploads row_ptr / col_idx / values to device memory.
166    let csr = CsrMatrix::<f64>::from_host(
167        n_rows as u32,
168        n_cols as u32,
169        row_offsets,
170        col_indices,
171        values,
172    )
173    .map_err(sparse_err)?;
174
175    // Upload x, and a zero-initialised y. spmv computes y = alpha*A*x + beta*y
176    // and reads y even when beta = 0, so y must not be uninitialised / NaN.
177    let d_x = oxicuda_memory::DeviceBuffer::from_host(x).map_err(cuda_err)?;
178    let d_y = oxicuda_memory::DeviceBuffer::from_host(&vec![0.0f64; n_rows]).map_err(cuda_err)?;
179
180    // y = 1.0 * A * x + 0.0 * y
181    spmv::<f64>(
182        &handle,
183        SpMVAlgo::Adaptive,
184        1.0,
185        &csr,
186        d_x.as_device_ptr(),
187        0.0,
188        d_y.as_device_ptr(),
189    )
190    .map_err(sparse_err)?;
191
192    let mut y = vec![0.0f64; n_rows];
193    d_y.copy_to_host(&mut y).map_err(cuda_err)?;
194    Ok(y)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // ---- Always-run tests: host-side validation, no GPU required ----
202
203    #[test]
204    fn shape_mismatch_is_detected_without_gpu() {
205        // row_offsets length must be n_rows + 1 (= 4); here it is too short.
206        let res = cuda_spmv_csr(&[0, 1], &[0], &[1.0], 3, 3, &[1.0, 2.0, 3.0]);
207        assert!(res.is_err());
208    }
209
210    #[test]
211    fn x_length_mismatch_is_detected_without_gpu() {
212        // Consistent CSR (1 nnz) but x has length 2 while n_cols = 3.
213        let res = cuda_spmv_csr(&[0, 1, 1, 1], &[0], &[1.0], 3, 3, &[1.0, 2.0]);
214        assert!(res.is_err());
215    }
216
217    #[test]
218    fn empty_matrix_is_ok_without_gpu() {
219        let y = cuda_spmv_csr(&[], &[], &[], 0, 0, &[]).expect("empty spmv should be Ok");
220        assert!(y.is_empty());
221    }
222
223    #[test]
224    fn no_edges_yields_zero_vector_without_gpu() {
225        // 3 nodes, no edges: row_offsets all zero, nnz = 0 => y = [0, 0, 0].
226        let y = cuda_spmv_csr(&[0, 0, 0, 0], &[], &[], 3, 3, &[1.0, 2.0, 3.0])
227            .expect("no-edge spmv should be Ok");
228        assert_eq!(y, vec![0.0, 0.0, 0.0]);
229    }
230
231    // ---- Skip-on-no-GPU smoke tests: real device SpMV vs CPU reference ----
232
233    #[test]
234    fn cuda_spmv_path_graph_or_skip() {
235        if !cuda_is_available() {
236            eprintln!("skipping: no NVIDIA CUDA device");
237            assert!(!cuda_is_available());
238            return;
239        }
240        // Undirected 3-node path graph 0 - 1 - 2 with unit weights.
241        // Adjacency A (symmetric):
242        //   row 0: col 1
243        //   row 1: cols 0, 2
244        //   row 2: col 1
245        // CSR: row_ptr = [0, 1, 3, 4], col = [1, 0, 2, 1], val = [1, 1, 1, 1]
246        use crate::compressed::CsrGraph;
247        let row_ptr_us = vec![0usize, 1, 3, 4];
248        let col_us = vec![1usize, 0, 2, 1];
249        let vals = vec![1.0f64, 1.0, 1.0, 1.0];
250
251        let g = CsrGraph::from_raw(3, row_ptr_us.clone(), col_us.clone(), vals.clone(), false)
252            .expect("from_raw");
253        let x = vec![1.0f64, 2.0, 3.0];
254        let cpu = g.spmv(&x).expect("cpu spmv");
255        // Hand-check: y0 = x1 = 2; y1 = x0 + x2 = 4; y2 = x1 = 2.
256        assert_eq!(cpu, vec![2.0, 4.0, 2.0]);
257
258        let row_ptr_i: Vec<i32> = row_ptr_us.iter().map(|&v| v as i32).collect();
259        let col_i: Vec<i32> = col_us.iter().map(|&v| v as i32).collect();
260        let gpu = cuda_spmv_csr(&row_ptr_i, &col_i, &vals, 3, 3, &x).expect("gpu spmv");
261
262        let max_diff = gpu
263            .iter()
264            .zip(cpu.iter())
265            .map(|(gv, cv)| (gv - cv).abs())
266            .fold(0.0f64, f64::max);
267        assert!(max_diff < 1e-9, "max abs diff {max_diff} exceeds 1e-9");
268    }
269
270    // ---- Vector-kernel SpMV: directed asymmetric graph, avg nnz/row ≥ 4.0 ----
271    //
272    // `SpMVAlgo::Adaptive` selects the SCALAR kernel when avg_nnz_per_row < 4.0
273    // and the VECTOR (warp-per-row) kernel when avg_nnz_per_row >= 4.0
274    // (VECTOR_THRESHOLD = 4.0 in oxicuda-sparse/src/ops/spmv.rs).
275    //
276    // The existing `cuda_spmv_path_graph_or_skip` test uses a 3-node path graph
277    // (4 nnz / 3 rows = 1.33 avg) which routes to SCALAR.  This test uses an
278    // 8-node DIRECTED, ASYMMETRIC graph with 37 nnz (avg = 37/8 = 4.625 ≥ 4.0)
279    // so that `Adaptive` resolves to the VECTOR warp-shuffle-reduction kernel —
280    // the kernel that was fixed for f64.  The adjacency is intentionally
281    // non-symmetric (e.g. edge 0→1 has weight 0.5 while edge 1→0 has weight
282    // 0.7), so any silent row/col transposition in the CSR upload would produce
283    // wrong results.
284    //
285    // CSR layout (row → [col(weight), ...]):
286    //   row 0 → [1(0.5),  2(1.5),  3(2.0),  4(0.3),  5(1.1)]  (5 edges)
287    //   row 1 → [0(0.7),  3(1.2),  5(0.9),  6(2.3)]            (4 edges)
288    //   row 2 → [0(0.4),  1(1.8),  4(0.6),  5(2.1),  7(1.4)]  (5 edges)
289    //   row 3 → [0(0.8),  2(1.3),  6(0.2),  7(1.7)]            (4 edges)
290    //   row 4 → [1(0.5),  2(0.9),  3(1.6),  5(2.2),  6(0.7)]  (5 edges)
291    //   row 5 → [0(1.1),  3(0.4),  4(1.9),  7(0.6)]            (4 edges)
292    //   row 6 → [1(0.3),  2(1.5),  3(0.8),  4(2.4),  5(1.0)]  (5 edges)
293    //   row 7 → [0(0.6),  2(1.2),  4(0.8),  5(1.4),  6(0.9)]  (5 edges)
294    //   total: 37 nnz, avg = 37/8 = 4.625 → VECTOR kernel selected
295    #[test]
296    fn cuda_spmv_vector_kernel_directed_or_skip() {
297        if !cuda_is_available() {
298            eprintln!("skipping: no NVIDIA CUDA device");
299            assert!(!cuda_is_available());
300            return;
301        }
302
303        use crate::compressed::CsrGraph;
304
305        // 8-node directed graph — 37 nnz, avg nnz/row = 4.625 ≥ 4.0.
306        let n: usize = 8;
307        #[rustfmt::skip]
308        let row_ptr_us: Vec<usize> = vec![0, 5, 9, 14, 18, 23, 27, 32, 37];
309        #[rustfmt::skip]
310        let col_us: Vec<usize> = vec![
311            // row 0 (5 edges)
312            1, 2, 3, 4, 5,
313            // row 1 (4 edges)
314            0, 3, 5, 6,
315            // row 2 (5 edges)
316            0, 1, 4, 5, 7,
317            // row 3 (4 edges)
318            0, 2, 6, 7,
319            // row 4 (5 edges)
320            1, 2, 3, 5, 6,
321            // row 5 (4 edges)
322            0, 3, 4, 7,
323            // row 6 (5 edges)
324            1, 2, 3, 4, 5,
325            // row 7 (5 edges)
326            0, 2, 4, 5, 6,
327        ];
328        #[rustfmt::skip]
329        let vals: Vec<f64> = vec![
330            // row 0
331            0.5, 1.5, 2.0, 0.3, 1.1,
332            // row 1 — note: (1,0)=0.7 ≠ (0,1)=0.5 → asymmetric
333            0.7, 1.2, 0.9, 2.3,
334            // row 2
335            0.4, 1.8, 0.6, 2.1, 1.4,
336            // row 3
337            0.8, 1.3, 0.2, 1.7,
338            // row 4
339            0.5, 0.9, 1.6, 2.2, 0.7,
340            // row 5
341            1.1, 0.4, 1.9, 0.6,
342            // row 6
343            0.3, 1.5, 0.8, 2.4, 1.0,
344            // row 7
345            0.6, 1.2, 0.8, 1.4, 0.9,
346        ];
347
348        // Confirm avg nnz/row >= 4.0 so Adaptive selects the VECTOR kernel.
349        let nnz = vals.len();
350        let avg_nnz_per_row = nnz as f64 / n as f64;
351        assert!(
352            avg_nnz_per_row >= 4.0,
353            "avg nnz/row {avg_nnz_per_row:.3} must be >= 4.0 to select the VECTOR kernel"
354        );
355
356        // CPU reference SpMV.
357        let g = CsrGraph::from_raw(n, row_ptr_us.clone(), col_us.clone(), vals.clone(), true)
358            .expect("from_raw directed graph");
359        let x: Vec<f64> = (1..=n).map(|v| v as f64).collect(); // x = [1,2,3,4,5,6,7,8]
360        let cpu = g.spmv(&x).expect("cpu spmv");
361
362        // GPU SpMV via VECTOR kernel (avg nnz/row = 4.625 >= VECTOR_THRESHOLD = 4.0).
363        let row_ptr_i: Vec<i32> = row_ptr_us.iter().map(|&v| v as i32).collect();
364        let col_i: Vec<i32> = col_us.iter().map(|&v| v as i32).collect();
365        let gpu = cuda_spmv_csr(&row_ptr_i, &col_i, &vals, n, n, &x)
366            .expect("cuda_spmv_csr (vector kernel)");
367
368        assert_eq!(gpu.len(), n, "output length must match n_rows");
369
370        let max_diff = gpu
371            .iter()
372            .zip(cpu.iter())
373            .map(|(gv, cv)| (gv - cv).abs())
374            .fold(0.0f64, f64::max);
375        assert!(
376            max_diff < 1e-9,
377            "VECTOR SpMV max abs diff {max_diff:.3e} exceeds 1e-9 \
378             (avg_nnz_per_row={avg_nnz_per_row:.3}, nnz={nnz})"
379        );
380    }
381}