Skip to main content

scirs2_optimize/
gpu_cuda.rs

1#![cfg(feature = "cuda")]
2//! Optional, off-by-default, NVIDIA-only CUDA acceleration for the
3//! Hessian-vector products at the heart of the Newton-CG inner loop, calling
4//! the pure-Rust `oxicuda-*` library crates directly.
5//!
6//! This module is ADDITIVE and entirely feature-gated behind the `cuda` feature
7//! (off by default). It does not replace or modify the existing `gpu` subsystem
8//! (`src/gpu/`) or the wgpu Newton-CG / CG / L-BFGS paths
9//! (`src/unconstrained/{newton_gpu,cg_gpu,lbfgs_gpu}.rs`). Every public function
10//! is `f64`-native — there is no silent `f32` downcast at the boundary, which is
11//! the advantage of the oxicuda CUDA path over the f32 wgpu path.
12//!
13//! Each entry point degrades safely when no NVIDIA device is present:
14//! [`cuda_is_available`] never panics, and the compute functions return an
15//! [`crate::error::OptimizeError::ComputationError`] rather than aborting.
16//!
17//! # Library-call pattern
18//!
19//! The inner CG loop of the Newton-CG subsystem performs, per iteration, a
20//! Hessian-vector product `H · p` (a dense matrix×vector multiply, `O(n²)`)
21//! that dominates the per-iteration cost for large `n`. This module hands that
22//! product off to the pre-built, battle-tested `oxicuda-blas` GEMM rather than
23//! authoring any bespoke kernels, mirroring the proven matrix×vector layout of
24//! the Phase-1 interpolate reference
25//! (`scirs2-interpolate/src/gpu_cuda.rs::cuda_eval_gemm`).
26//!
27//! # Scoping note
28//!
29//! This is a standalone additive function, deliberately NOT wired into
30//! `minimize_newton_cg` / `newton.rs` — exactly like the Phase-1 crates kept
31//! their `cuda_*` functions standalone.
32
33use crate::error::{OptimizeError, OptimizeResult};
34use oxicuda_blas::types::{Layout, MatrixDesc, MatrixDescMut, Transpose};
35use scirs2_core::ndarray::{Array1, ArrayView1, ArrayView2};
36
37/// Returns `true` iff the CUDA driver initializes and at least one NVIDIA device
38/// is visible. Never panics.
39///
40/// On a non-NVIDIA platform such as macOS the CUDA driver fails to initialise
41/// (or reports zero devices) and this returns `false`, allowing callers to fall
42/// back to the CPU path.
43pub fn cuda_is_available() -> bool {
44    oxicuda_driver::init().is_ok()
45        && oxicuda_driver::device::Device::count()
46            .map(|c| c > 0)
47            .unwrap_or(false)
48}
49
50/// Maps an `oxicuda-blas` error onto the crate's honest `ComputationError`.
51fn blas_err(e: oxicuda_blas::error::BlasError) -> OptimizeError {
52    OptimizeError::ComputationError(format!("oxicuda-blas: {e}"))
53}
54
55/// Maps an `oxicuda` CUDA driver/memory error onto `ComputationError`.
56fn cuda_err(e: oxicuda_driver::CudaError) -> OptimizeError {
57    OptimizeError::ComputationError(format!("oxicuda CUDA driver: {e}"))
58}
59
60/// Initialises the CUDA driver, selects device 0, and builds a shared context
61/// wrapped in an `Arc` as required by the oxicuda BLAS handle constructor.
62///
63/// Returns a [`ComputationError`](OptimizeError::ComputationError) — never a
64/// fabricated success — when CUDA is unavailable or no device is present.
65fn build_context() -> OptimizeResult<std::sync::Arc<oxicuda_driver::Context>> {
66    oxicuda_driver::init()
67        .map_err(|e| OptimizeError::ComputationError(format!("CUDA unavailable: {e}")))?;
68    let count = oxicuda_driver::device::Device::count()
69        .map_err(|e| OptimizeError::ComputationError(format!("device count: {e}")))?;
70    if count <= 0 {
71        return Err(OptimizeError::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/// Compute the Hessian-vector product `H · v` on the GPU in `f64`.
82///
83/// This is the dominant per-iteration cost of the Newton-CG inner loop. `H` is
84/// the `n`×`n` Hessian and `v` is a length-`n` vector; the returned vector has
85/// length `n`.
86///
87/// Layout correctness (cannot be runtime-checked on a non-NVIDIA host): ndarray
88/// is row-major. We materialize a contiguous row-major copy of `H` via
89/// [`as_standard_layout`](scirs2_core::ndarray::ArrayBase::as_standard_layout)
90/// and describe it as a [`Layout::RowMajor`] `MatrixDesc` (`n`×`n`). Mirroring
91/// the proven interpolate `cuda_eval_gemm` matrix×vector layout, `v` is
92/// described as a column-vector [`Layout::ColMajor`] `MatrixDesc` (`n`×1) and
93/// the output as a [`Layout::ColMajor`] `MatrixDescMut` (`n`×1). Under
94/// [`Transpose::NoTrans`] (`alpha = 1.0`, `beta = 0.0`) this yields `M = n`,
95/// `K = n`, `N = 1`.
96pub fn cuda_hessian_vector_product(
97    h: &ArrayView2<f64>,
98    v: &ArrayView1<f64>,
99) -> OptimizeResult<Array1<f64>> {
100    let n = h.nrows();
101    if !h.is_square() {
102        return Err(OptimizeError::ValueError(format!(
103            "cuda_hessian_vector_product: Hessian must be square, got {n}x{}",
104            h.ncols()
105        )));
106    }
107    if v.len() != n {
108        return Err(OptimizeError::ValueError(format!(
109            "cuda_hessian_vector_product: vector length {} does not match Hessian dimension {n}",
110            v.len()
111        )));
112    }
113    if n == 0 {
114        return Ok(Array1::zeros(0));
115    }
116
117    // Contiguous row-major copy so `.as_slice()` is `Some` and the device upload
118    // matches the `Layout::RowMajor` descriptor below.
119    let h_std = h.as_standard_layout();
120    let h_slice = h_std.as_slice().ok_or_else(|| {
121        OptimizeError::ComputationError("cuda_hessian_vector_product: H not contiguous".into())
122    })?;
123    let v_vec: Vec<f64> = v.iter().copied().collect();
124
125    let ctx = build_context()?;
126    let handle = oxicuda_blas::BlasHandle::new(&ctx).map_err(blas_err)?;
127
128    let d_h = oxicuda_memory::DeviceBuffer::from_host(h_slice).map_err(cuda_err)?;
129    let d_v = oxicuda_memory::DeviceBuffer::from_host(&v_vec).map_err(cuda_err)?;
130    let mut d_c = oxicuda_memory::DeviceBuffer::<f64>::alloc(n).map_err(cuda_err)?;
131
132    // H as RowMajor (n×n), v as a ColMajor column (n×1), output ColMajor (n×1) —
133    // the exact operand layout of interpolate's `cuda_eval_gemm`.
134    let a_desc =
135        MatrixDesc::from_buffer(&d_h, n as u32, n as u32, Layout::RowMajor).map_err(blas_err)?;
136    let b_desc = MatrixDesc::from_buffer(&d_v, n as u32, 1, Layout::ColMajor).map_err(blas_err)?;
137    let mut c_desc =
138        MatrixDescMut::from_buffer(&mut d_c, n as u32, 1, Layout::ColMajor).map_err(blas_err)?;
139
140    oxicuda_blas::level3::gemm_api::gemm::<f64>(
141        &handle,
142        Transpose::NoTrans,
143        Transpose::NoTrans,
144        1.0,
145        &a_desc,
146        &b_desc,
147        0.0,
148        &mut c_desc,
149    )
150    .map_err(blas_err)?;
151
152    let mut hv = vec![0.0f64; n];
153    d_c.copy_to_host(&mut hv).map_err(cuda_err)?;
154    Ok(Array1::from_vec(hv))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use scirs2_core::ndarray::Array2;
161
162    fn mat(rows: usize, cols: usize, data: Vec<f64>) -> Array2<f64> {
163        Array2::from_shape_vec((rows, cols), data).expect("valid test matrix shape")
164    }
165
166    #[test]
167    fn cuda_hessian_vector_product_or_skip() {
168        if !cuda_is_available() {
169            eprintln!("skipping: no NVIDIA CUDA device");
170            assert!(!cuda_is_available());
171            return;
172        }
173        // Symmetric 3x3 Hessian and a vector; verify H·v against a CPU dot.
174        let h = mat(3, 3, vec![4.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0]);
175        let v = Array1::from_vec(vec![1.0, 2.0, 3.0]);
176        let hv = cuda_hessian_vector_product(&h.view(), &v.view())
177            .expect("cuda_hessian_vector_product failed");
178        let expected = h.dot(&v);
179        let max_diff = hv
180            .iter()
181            .zip(expected.iter())
182            .map(|(g, e)| (g - e).abs())
183            .fold(0.0f64, f64::max);
184        assert!(max_diff < 1e-9, "max abs diff {max_diff} exceeds 1e-9");
185    }
186
187    #[test]
188    fn cuda_hessian_vector_product_nonsymmetric_or_skip() {
189        if !cuda_is_available() {
190            eprintln!("skipping: no NVIDIA CUDA device");
191            assert!(!cuda_is_available());
192            return;
193        }
194        // Non-symmetric 4×4 matrix — catches any row/col-major transpose bug that
195        // a symmetric Hessian (H = Hᵀ) would silently hide because swapping rows
196        // and columns of a symmetric matrix yields the same result.
197        let h = mat(
198            4,
199            4,
200            vec![
201                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
202                16.0,
203            ],
204        );
205        let v = Array1::from_vec(vec![1.0, -1.0, 2.0, -2.0]);
206        let hv = cuda_hessian_vector_product(&h.view(), &v.view())
207            .expect("cuda_hessian_vector_product nonsymmetric failed");
208        let expected = h.dot(&v);
209        let max_diff = hv
210            .iter()
211            .zip(expected.iter())
212            .map(|(g, e)| (g - e).abs())
213            .fold(0.0f64, f64::max);
214        assert!(
215            max_diff < 1e-9,
216            "nonsymmetric 4x4 HVP max abs diff {max_diff} exceeds 1e-9"
217        );
218    }
219
220    #[test]
221    fn cuda_hessian_vector_product_shape_mismatch_errors() {
222        // Runs without a GPU: validation rejects before any device call.
223        // Non-square Hessian.
224        let nonsquare = mat(2, 3, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
225        let v = Array1::from_vec(vec![1.0, 2.0, 3.0]);
226        assert!(cuda_hessian_vector_product(&nonsquare.view(), &v.view()).is_err());
227
228        // Square Hessian but mismatched vector length.
229        let square = mat(2, 2, vec![1.0, 0.0, 0.0, 1.0]);
230        let v_bad = Array1::from_vec(vec![1.0, 2.0, 3.0]);
231        assert!(cuda_hessian_vector_product(&square.view(), &v_bad.view()).is_err());
232    }
233}