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}