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`
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::RowMajor`] `MatrixDesc` (`n`×1) and
93/// the output as a [`Layout::RowMajor`] `MatrixDescMut` (`n`×1) — a
94/// single-column matrix is byte-identical under either layout tag, and
95/// `oxicuda-blas`'s GEMM dispatcher requires RowMajor on every operand. Under
96/// [`Transpose::NoTrans`] (`alpha = 1.0`, `beta = 0.0`) this yields `M = n`,
97/// `K = n`, `N = 1`.
98pub fn cuda_hessian_vector_product(
99 h: &ArrayView2<f64>,
100 v: &ArrayView1<f64>,
101) -> OptimizeResult<Array1<f64>> {
102 let n = h.nrows();
103 if !h.is_square() {
104 return Err(OptimizeError::ValueError(format!(
105 "cuda_hessian_vector_product: Hessian must be square, got {n}x{}",
106 h.ncols()
107 )));
108 }
109 if v.len() != n {
110 return Err(OptimizeError::ValueError(format!(
111 "cuda_hessian_vector_product: vector length {} does not match Hessian dimension {n}",
112 v.len()
113 )));
114 }
115 if n == 0 {
116 return Ok(Array1::zeros(0));
117 }
118
119 // Contiguous row-major copy so `.as_slice()` is `Some` and the device upload
120 // matches the `Layout::RowMajor` descriptor below.
121 let h_std = h.as_standard_layout();
122 let h_slice = h_std.as_slice().ok_or_else(|| {
123 OptimizeError::ComputationError("cuda_hessian_vector_product: H not contiguous".into())
124 })?;
125 let v_vec: Vec<f64> = v.iter().copied().collect();
126
127 let ctx = build_context()?;
128 let handle = oxicuda_blas::BlasHandle::new(&ctx).map_err(blas_err)?;
129
130 let d_h = oxicuda_memory::DeviceBuffer::from_host(h_slice).map_err(cuda_err)?;
131 let d_v = oxicuda_memory::DeviceBuffer::from_host(&v_vec).map_err(cuda_err)?;
132 let mut d_c = oxicuda_memory::DeviceBuffer::<f64>::alloc(n).map_err(cuda_err)?;
133
134 // H as RowMajor (n×n), v as a RowMajor column (n×1), output RowMajor (n×1).
135 // A single-column matrix has one possible packed layout regardless of the
136 // tag, but oxicuda-blas's GEMM dispatcher hard-rejects any non-RowMajor
137 // descriptor, so all three must be tagged RowMajor.
138 let a_desc =
139 MatrixDesc::from_buffer(&d_h, n as u32, n as u32, Layout::RowMajor).map_err(blas_err)?;
140 let b_desc = MatrixDesc::from_buffer(&d_v, n as u32, 1, Layout::RowMajor).map_err(blas_err)?;
141 let mut c_desc =
142 MatrixDescMut::from_buffer(&mut d_c, n as u32, 1, Layout::RowMajor).map_err(blas_err)?;
143
144 oxicuda_blas::level3::gemm_api::gemm::<f64>(
145 &handle,
146 Transpose::NoTrans,
147 Transpose::NoTrans,
148 1.0,
149 &a_desc,
150 &b_desc,
151 0.0,
152 &mut c_desc,
153 )
154 .map_err(blas_err)?;
155
156 // The GEMM runs on the BLAS handle's `CU_STREAM_NON_BLOCKING` stream, but
157 // `copy_to_host` issues its `cuMemcpyDtoH_v2` on the legacy default stream,
158 // which a non-blocking stream does NOT implicitly synchronise with. Without
159 // this wait the copy races the kernel and reads the (uninitialised / zero)
160 // output before the GEMM finishes — the longer the kernel runs (large n),
161 // the more often it reads all-zeros. Synchronise the compute stream first.
162 handle.stream().synchronize().map_err(cuda_err)?;
163
164 let mut hv = vec![0.0f64; n];
165 d_c.copy_to_host(&mut hv).map_err(cuda_err)?;
166 Ok(Array1::from_vec(hv))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use scirs2_core::ndarray::Array2;
173
174 fn mat(rows: usize, cols: usize, data: Vec<f64>) -> Array2<f64> {
175 Array2::from_shape_vec((rows, cols), data).expect("valid test matrix shape")
176 }
177
178 #[test]
179 fn cuda_hessian_vector_product_or_skip() {
180 if !cuda_is_available() {
181 eprintln!("skipping: no NVIDIA CUDA device");
182 assert!(!cuda_is_available());
183 return;
184 }
185 // Symmetric 3x3 Hessian and a vector; verify H·v against a CPU dot.
186 let h = mat(3, 3, vec![4.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0]);
187 let v = Array1::from_vec(vec![1.0, 2.0, 3.0]);
188 let hv = cuda_hessian_vector_product(&h.view(), &v.view())
189 .expect("cuda_hessian_vector_product failed");
190 let expected = h.dot(&v);
191 let max_diff = hv
192 .iter()
193 .zip(expected.iter())
194 .map(|(g, e)| (g - e).abs())
195 .fold(0.0f64, f64::max);
196 assert!(max_diff < 1e-9, "max abs diff {max_diff} exceeds 1e-9");
197 }
198
199 #[test]
200 fn cuda_hessian_vector_product_nonsymmetric_or_skip() {
201 if !cuda_is_available() {
202 eprintln!("skipping: no NVIDIA CUDA device");
203 assert!(!cuda_is_available());
204 return;
205 }
206 // Non-symmetric 4×4 matrix — catches any row/col-major transpose bug that
207 // a symmetric Hessian (H = Hᵀ) would silently hide because swapping rows
208 // and columns of a symmetric matrix yields the same result.
209 let h = mat(
210 4,
211 4,
212 vec![
213 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,
214 16.0,
215 ],
216 );
217 let v = Array1::from_vec(vec![1.0, -1.0, 2.0, -2.0]);
218 let hv = cuda_hessian_vector_product(&h.view(), &v.view())
219 .expect("cuda_hessian_vector_product nonsymmetric failed");
220 let expected = h.dot(&v);
221 let max_diff = hv
222 .iter()
223 .zip(expected.iter())
224 .map(|(g, e)| (g - e).abs())
225 .fold(0.0f64, f64::max);
226 assert!(
227 max_diff < 1e-9,
228 "nonsymmetric 4x4 HVP max abs diff {max_diff} exceeds 1e-9"
229 );
230 }
231
232 #[test]
233 fn cuda_hessian_vector_product_shape_mismatch_errors() {
234 // Runs without a GPU: validation rejects before any device call.
235 // Non-square Hessian.
236 let nonsquare = mat(2, 3, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
237 let v = Array1::from_vec(vec![1.0, 2.0, 3.0]);
238 assert!(cuda_hessian_vector_product(&nonsquare.view(), &v.view()).is_err());
239
240 // Square Hessian but mismatched vector length.
241 let square = mat(2, 2, vec![1.0, 0.0, 0.0, 1.0]);
242 let v_bad = Array1::from_vec(vec![1.0, 2.0, 3.0]);
243 assert!(cuda_hessian_vector_product(&square.view(), &v_bad.view()).is_err());
244 }
245}