gam_gpu/solver.rs
1//! cuSOLVER-backed dense solver kernels for the GPU HAL.
2//!
3//! This module owns CUDA solver functionality that is shared by GPU linear
4//! algebra dispatch and higher-level solver code. CPU solves do not live behind
5//! these entry points: unavailable CUDA support is reported as an error.
6
7use ndarray::{Array2, ArrayView2};
8
9/// Outcome reported by [`iterative_refinement_cholesky_solve`].
10#[derive(Clone, Debug)]
11pub struct RefinementOutcome {
12 /// Solution vector `x` satisfying `A x ≈ b`.
13 pub solution: ndarray::Array1<f64>,
14 /// `‖r‖ / ‖b‖` where `r = b − A x` after the last refinement step
15 /// (or after the initial fp32 solve when no steps were taken).
16 pub relative_residual: f64,
17 /// Precision path used for the factorization.
18 pub used_fp32_factor: bool,
19 /// Number of refinement steps taken (0 means only the initial solve ran).
20 pub refinement_steps: usize,
21}
22
23#[cfg(target_os = "linux")]
24mod cuda {
25 use crate::driver::{from_col_major, to_col_major};
26 use cudarc::cublas::sys as cublas_sys;
27 use cudarc::cublas::{CudaBlas, Gemv, GemvConfig};
28 use cudarc::cusolver::{DnHandle, sys as cusolver_sys};
29 use cudarc::driver::{CudaContext, CudaSlice, DevicePtr, DevicePtrMut};
30 use faer::MatRef;
31 use gam_linalg::faer_ndarray::cholesky_factor_logdet;
32 use ndarray::{Array2, ArrayView2};
33
34 pub(super) fn cholesky_solve(
35 hessian: ArrayView2<'_, f64>,
36 rhs: ArrayView2<'_, f64>,
37 ) -> Result<(Array2<f64>, f64), String> {
38 let (_, stream) = context_and_stream()?;
39 let (p, p2) = hessian.dim();
40 if p == 0 || p != p2 || rhs.nrows() != p {
41 return Err("Cholesky solve dimension mismatch".to_string());
42 }
43 let nrhs = rhs.ncols();
44 let solver = DnHandle::new(stream.clone()).map_err(|e| format!("cusolver init: {e}"))?;
45 let h_col = to_col_major(&hessian);
46 let rhs_col = to_col_major(&rhs);
47 let mut h_dev = pinned_htod(&stream, &h_col)?;
48 let mut rhs_dev = pinned_htod(&stream, &rhs_col)?;
49 potrf_in_place(&solver, &stream, p, &mut h_dev)?;
50 potrs_in_place(&solver, &stream, p, nrhs, &h_dev, &mut rhs_dev)?;
51 let factor_col = stream
52 .clone_dtoh(&h_dev)
53 .map_err(|e| format!("download Cholesky factor: {e}"))?;
54 let out_col = stream
55 .clone_dtoh(&rhs_dev)
56 .map_err(|e| format!("download solution: {e}"))?;
57 let solved =
58 from_col_major(&out_col, p, nrhs).ok_or("solution layout conversion failed")?;
59 Ok((solved, cholesky_logdet_from_col_major(&factor_col, p)))
60 }
61
62 /// fp64 log-determinant of an SPD matrix via POTRF only.
63 ///
64 /// This is [`cholesky_solve`] stripped of the triangular solve (POTRS) and
65 /// the solution download/layout conversion: the log-determinant depends
66 /// solely on the Cholesky factor's diagonal, so when a caller already holds
67 /// the solution (e.g. from fp32 + iterative refinement) and needs *only* an
68 /// accurate fp64 logdet, doing a full solve here would burn an O(p²·nrhs)
69 /// POTRS plus a host round-trip on a solution that is immediately discarded.
70 pub(super) fn cholesky_logdet(hessian: ArrayView2<'_, f64>) -> Result<f64, String> {
71 let (_, stream) = context_and_stream()?;
72 let (p, p2) = hessian.dim();
73 if p == 0 || p != p2 {
74 return Err("Cholesky logdet dimension mismatch".to_string());
75 }
76 let solver = DnHandle::new(stream.clone()).map_err(|e| format!("cusolver init: {e}"))?;
77 let h_col = to_col_major(&hessian);
78 let mut h_dev = pinned_htod(&stream, &h_col)?;
79 potrf_in_place(&solver, &stream, p, &mut h_dev)?;
80 let factor_col = stream
81 .clone_dtoh(&h_dev)
82 .map_err(|e| format!("download Cholesky factor: {e}"))?;
83 Ok(cholesky_logdet_from_col_major(&factor_col, p))
84 }
85
86 pub(super) fn cholesky_lower_on_ordinal(
87 ordinal: usize,
88 hessian: ArrayView2<'_, f64>,
89 ) -> Result<Array2<f64>, String> {
90 let (_, stream) = context_and_stream_for(ordinal)?;
91 cholesky_lower_on_stream(hessian, &stream)
92 }
93
94 fn cholesky_lower_on_stream(
95 hessian: ArrayView2<'_, f64>,
96 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
97 ) -> Result<Array2<f64>, String> {
98 let (p, p2) = hessian.dim();
99 if p == 0 || p != p2 {
100 return Err("Cholesky factorization dimension mismatch".to_string());
101 }
102 let solver = DnHandle::new(stream.clone()).map_err(|e| format!("cusolver init: {e}"))?;
103 let h_col = to_col_major(&hessian);
104 let mut h_dev = pinned_htod(&stream, &h_col)?;
105 potrf_in_place(&solver, &stream, p, &mut h_dev)?;
106 let factor_col = stream
107 .clone_dtoh(&h_dev)
108 .map_err(|e| format!("download Cholesky factor: {e}"))?;
109 let mut lower =
110 from_col_major(&factor_col, p, p).ok_or("factor layout conversion failed")?;
111 for row in 0..p {
112 for col in (row + 1)..p {
113 lower[[row, col]] = 0.0;
114 }
115 }
116 Ok(lower)
117 }
118
119 // -----------------------------------------------------------------------
120 // Precision-generic Cholesky scaffold
121 //
122 // POTRF / POTRS host scaffolds are identical across single and double
123 // precision apart from the cuSOLVER symbol called and the device pointer
124 // type. `CholScalar` selects those per-precision pieces so the host-side
125 // allocation / info-handling / error-formatting logic lives once. The
126 // `Dpotr*` (f64) and `Spotr*` (f32) entry points below are thin wrappers
127 // over the generic helpers, preserving their public signatures byte for
128 // byte.
129 // -----------------------------------------------------------------------
130
131 /// cuSOLVER scalar abstraction: selects the precision-specific POTRF/POTRS
132 /// symbols and the precision tag used in deferred-info error messages.
133 ///
134 /// The FFI into cuSOLVER lives inside the trait methods' bodies (in `unsafe`
135 /// blocks), so the trait and its methods are safe to call: each impl wires
136 /// its method bodies to the cuSOLVER entry points whose pointer arguments
137 /// match `Self` (e.g. `cusolverDnDpotrf` for `f64`). Implementors must keep
138 /// that pairing consistent — the device pointer passed in is typed `*mut
139 /// Self` / `*const Self`, so a mismatched symbol would hand cuSOLVER a
140 /// wrongly-typed buffer.
141 pub(crate) trait CholScalar:
142 cudarc::driver::DeviceRepr + cudarc::driver::ValidAsZeroBits + Copy
143 {
144 /// cuSOLVER `*potrf_bufferSize`: `(handle, uplo, n, A, lda, *lwork)`.
145 ///
146 /// `a` is a live `n*n` column-major device buffer of type `Self`,
147 /// `lwork` is a host out-param. The unsafe FFI call is contained in the
148 /// method body.
149 fn potrf_buffer_size(
150 handle: cusolver_sys::cusolverDnHandle_t,
151 uplo: cusolver_sys::cublasFillMode_t,
152 n: i32,
153 a: *mut Self,
154 lda: i32,
155 lwork: *mut i32,
156 ) -> cusolver_sys::cusolverStatus_t;
157 /// cuSOLVER `*potrf`: `(handle, uplo, n, A, lda, work, lwork, info)`.
158 ///
159 /// Pointer args must reference live device buffers of the documented
160 /// shape; the unsafe FFI call is contained in the method body.
161 fn potrf(
162 handle: cusolver_sys::cusolverDnHandle_t,
163 uplo: cusolver_sys::cublasFillMode_t,
164 n: i32,
165 a: *mut Self,
166 lda: i32,
167 work: *mut Self,
168 lwork: i32,
169 info: *mut i32,
170 ) -> cusolver_sys::cusolverStatus_t;
171 /// cuSOLVER `*potrs`: `(handle, uplo, n, nrhs, A, lda, B, ldb, info)`.
172 ///
173 /// Pointer args must reference live device buffers of the documented
174 /// shape; the unsafe FFI call is contained in the method body.
175 fn potrs(
176 handle: cusolver_sys::cusolverDnHandle_t,
177 uplo: cusolver_sys::cublasFillMode_t,
178 n: i32,
179 nrhs: i32,
180 a: *const Self,
181 lda: i32,
182 b: *mut Self,
183 ldb: i32,
184 info: *mut i32,
185 ) -> cusolver_sys::cusolverStatus_t;
186 /// Symbol name fragment for error messages (e.g. `"Dpotrf"`).
187 const POTRF_NAME: &'static str;
188 const POTRS_NAME: &'static str;
189 /// Trailing clause appended to a POTRF "not SPD" error (e.g.
190 /// `" (matrix not SPD at f32)"`); empty for f64.
191 const POTRF_FAIL_SUFFIX: &'static str;
192 }
193
194 impl CholScalar for f64 {
195 fn potrf_buffer_size(
196 handle: cusolver_sys::cusolverDnHandle_t,
197 uplo: cusolver_sys::cublasFillMode_t,
198 n: i32,
199 a: *mut f64,
200 lda: i32,
201 lwork: *mut i32,
202 ) -> cusolver_sys::cusolverStatus_t {
203 // SAFETY: caller guarantees `a` is a live n*n column-major f64 device
204 // buffer and `lwork` is a valid host out-param; symbol matches f64.
205 unsafe { cusolver_sys::cusolverDnDpotrf_bufferSize(handle, uplo, n, a, lda, lwork) }
206 }
207 fn potrf(
208 handle: cusolver_sys::cusolverDnHandle_t,
209 uplo: cusolver_sys::cublasFillMode_t,
210 n: i32,
211 a: *mut f64,
212 lda: i32,
213 work: *mut f64,
214 lwork: i32,
215 info: *mut i32,
216 ) -> cusolver_sys::cusolverStatus_t {
217 // SAFETY: caller guarantees `a` is a live n*n column-major f64 buffer,
218 // `work` was sized by potrf_buffer_size, `info` is a 1-element i32
219 // device buffer; symbol matches f64.
220 unsafe { cusolver_sys::cusolverDnDpotrf(handle, uplo, n, a, lda, work, lwork, info) }
221 }
222 fn potrs(
223 handle: cusolver_sys::cusolverDnHandle_t,
224 uplo: cusolver_sys::cublasFillMode_t,
225 n: i32,
226 nrhs: i32,
227 a: *const f64,
228 lda: i32,
229 b: *mut f64,
230 ldb: i32,
231 info: *mut i32,
232 ) -> cusolver_sys::cusolverStatus_t {
233 // SAFETY: caller guarantees `a` is a live n*n f64 Cholesky factor,
234 // `b` is n*nrhs column-major f64, `info` is a 1-element i32 device
235 // buffer; symbol matches f64.
236 unsafe { cusolver_sys::cusolverDnDpotrs(handle, uplo, n, nrhs, a, lda, b, ldb, info) }
237 }
238 const POTRF_NAME: &'static str = "Dpotrf";
239 const POTRS_NAME: &'static str = "Dpotrs";
240 const POTRF_FAIL_SUFFIX: &'static str = "";
241 }
242
243 impl CholScalar for f32 {
244 fn potrf_buffer_size(
245 handle: cusolver_sys::cusolverDnHandle_t,
246 uplo: cusolver_sys::cublasFillMode_t,
247 n: i32,
248 a: *mut f32,
249 lda: i32,
250 lwork: *mut i32,
251 ) -> cusolver_sys::cusolverStatus_t {
252 // SAFETY: caller guarantees `a` is a live n*n column-major f32 device
253 // buffer and `lwork` is a valid host out-param; symbol matches f32.
254 unsafe { cusolver_sys::cusolverDnSpotrf_bufferSize(handle, uplo, n, a, lda, lwork) }
255 }
256 fn potrf(
257 handle: cusolver_sys::cusolverDnHandle_t,
258 uplo: cusolver_sys::cublasFillMode_t,
259 n: i32,
260 a: *mut f32,
261 lda: i32,
262 work: *mut f32,
263 lwork: i32,
264 info: *mut i32,
265 ) -> cusolver_sys::cusolverStatus_t {
266 // SAFETY: caller guarantees `a` is a live n*n column-major f32 buffer,
267 // `work` was sized by potrf_buffer_size, `info` is a 1-element i32
268 // device buffer; symbol matches f32.
269 unsafe { cusolver_sys::cusolverDnSpotrf(handle, uplo, n, a, lda, work, lwork, info) }
270 }
271 fn potrs(
272 handle: cusolver_sys::cusolverDnHandle_t,
273 uplo: cusolver_sys::cublasFillMode_t,
274 n: i32,
275 nrhs: i32,
276 a: *const f32,
277 lda: i32,
278 b: *mut f32,
279 ldb: i32,
280 info: *mut i32,
281 ) -> cusolver_sys::cusolverStatus_t {
282 // SAFETY: caller guarantees `a` is a live n*n f32 Cholesky factor,
283 // `b` is n*nrhs column-major f32, `info` is a 1-element i32 device
284 // buffer; symbol matches f32.
285 unsafe { cusolver_sys::cusolverDnSpotrs(handle, uplo, n, nrhs, a, lda, b, ldb, info) }
286 }
287 const POTRF_NAME: &'static str = "Spotrf";
288 const POTRS_NAME: &'static str = "Spotrs";
289 const POTRF_FAIL_SUFFIX: &'static str = " (matrix not SPD at f32)";
290 }
291
292 /// Query the cuSOLVER POTRF workspace size (element count) for a p×p
293 /// matrix at precision `T`. Allocates a temporary p×p dummy buffer for the
294 /// query.
295 fn potrf_bufsize_generic<T: CholScalar>(
296 solver: &DnHandle,
297 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
298 p: usize,
299 ) -> Result<usize, String> {
300 let p_i = to_i32(p)?;
301 let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
302 let mut lwork = 0_i32;
303 let mut dummy = stream
304 .alloc_zeros::<T>(p.checked_mul(p).ok_or("p² overflow in lwork query")?)
305 .map_err(|e| format!("cuda alloc dummy for lwork query: {e}"))?;
306 {
307 let (ptr, _rec) = dummy.device_ptr_mut(stream);
308 // dummy is a live p*p device buffer of type T, lwork is a host i32;
309 // the unsafe cuSOLVER FFI is contained in T::potrf_buffer_size.
310 let status =
311 T::potrf_buffer_size(solver.cu(), uplo, p_i, ptr as *mut T, p_i, &mut lwork);
312 check_cusolver(status, "cusolverDn*potrf_bufferSize")?;
313 }
314 usize::try_from(lwork).map_err(|_| "negative potrf lwork".to_string())
315 }
316
317 /// Factor a p×p SPD device buffer in-place (lower-triangular Cholesky) at
318 /// precision `T`, querying and allocating its own workspace. Returns `Err`
319 /// if the matrix is singular/indefinite at precision `T`.
320 ///
321 /// This is the single-matrix POTRF core shared across the GPU layer:
322 /// `solver.rs`'s `potrf_in_place`/`spotrf_in_place` and `linalg.rs`'s
323 /// `potrf_lower_in_place` all route through it (the latter mapping the
324 /// `Result` to its `Option` contract at the boundary). The batched POTRF
325 /// (`cusolverDnDpotrfBatched`) in `linalg.rs` is intentionally separate.
326 pub(crate) fn potrf_in_place_generic<T: CholScalar>(
327 solver: &DnHandle,
328 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
329 p: usize,
330 a: &mut CudaSlice<T>,
331 ) -> Result<(), String> {
332 let p_i = to_i32(p)?;
333 let lwork = potrf_bufsize_generic::<T>(solver, stream, p)?;
334 let lwork_i = i32::try_from(lwork).map_err(|_| "negative potrf workspace".to_string())?;
335 let mut workspace = stream
336 .alloc_zeros::<T>(lwork.max(1))
337 .map_err(|e| format!("cuda alloc potrf workspace: {e}"))?;
338 let mut info = stream
339 .alloc_zeros::<i32>(1)
340 .map_err(|e| format!("cuda alloc potrf info: {e}"))?;
341 let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
342 {
343 let (a_ptr, _a_rec) = a.device_ptr_mut(stream);
344 let (work_ptr, _work_rec) = workspace.device_ptr_mut(stream);
345 let (info_ptr, _info_rec) = info.device_ptr_mut(stream);
346 // a is p*p col-major T, workspace was sized by T::potrf_buffer_size,
347 // info is a 1-element i32 device buffer; the unsafe cuSOLVER FFI is
348 // contained in T::potrf.
349 let status = T::potrf(
350 solver.cu(),
351 uplo,
352 p_i,
353 a_ptr as *mut T,
354 p_i,
355 work_ptr as *mut T,
356 lwork_i,
357 info_ptr as *mut i32,
358 );
359 check_cusolver(status, "cusolverDn*potrf")?;
360 }
361 let info_host = stream
362 .clone_dtoh(&info)
363 .map_err(|e| format!("download potrf info: {e}"))?;
364 if info_host[0] == 0 {
365 Ok(())
366 } else {
367 Err(format!(
368 "cusolverDn{} returned info={}{}",
369 T::POTRF_NAME,
370 info_host[0],
371 T::POTRF_FAIL_SUFFIX
372 ))
373 }
374 }
375
376 /// Triangular solve using a pre-factored Cholesky lower-triangle at
377 /// precision `T`. Solves `A · x = rhs` in-place into `rhs` (column-major,
378 /// p × nrhs), allocating and downloading its own info scalar.
379 fn potrs_in_place_generic<T: CholScalar>(
380 solver: &DnHandle,
381 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
382 p: usize,
383 nrhs: usize,
384 factor: &CudaSlice<T>,
385 rhs: &mut CudaSlice<T>,
386 ) -> Result<(), String> {
387 let p_i = to_i32(p)?;
388 let nrhs_i = to_i32(nrhs)?;
389 let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
390 let mut info = stream
391 .alloc_zeros::<i32>(1)
392 .map_err(|e| format!("cuda alloc potrs info: {e}"))?;
393 {
394 let (f_ptr, _f_rec) = factor.device_ptr(stream);
395 let (r_ptr, _r_rec) = rhs.device_ptr_mut(stream);
396 let (info_ptr, _info_rec) = info.device_ptr_mut(stream);
397 // factor is a p*p lower-triangular T from potrf, rhs is p*nrhs
398 // col-major T, info is a 1-element i32 device buffer; leading dims
399 // match column-major p_i. The unsafe cuSOLVER FFI is contained in
400 // T::potrs.
401 let status = T::potrs(
402 solver.cu(),
403 uplo,
404 p_i,
405 nrhs_i,
406 f_ptr as *const T,
407 p_i,
408 r_ptr as *mut T,
409 p_i,
410 info_ptr as *mut i32,
411 );
412 check_cusolver(status, "cusolverDn*potrs")?;
413 }
414 let info_host = stream
415 .clone_dtoh(&info)
416 .map_err(|e| format!("download potrs info: {e}"))?;
417 if info_host[0] == 0 {
418 Ok(())
419 } else {
420 Err(format!(
421 "cusolverDn{} returned info={}",
422 T::POTRS_NAME,
423 info_host[0]
424 ))
425 }
426 }
427
428 // -----------------------------------------------------------------------
429 // fp32 entry points (thin wrappers over the precision-generic scaffold)
430 // -----------------------------------------------------------------------
431
432 /// Factor a p×p symmetric positive-definite f32 device buffer in-place
433 /// (lower-triangular Cholesky). Returns `Err` if the matrix is
434 /// singular/indefinite.
435 fn spotrf_in_place(
436 solver: &DnHandle,
437 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
438 p: usize,
439 a: &mut CudaSlice<f32>,
440 ) -> Result<(), String> {
441 potrf_in_place_generic::<f32>(solver, stream, p, a)
442 }
443
444 /// Triangular solve using a pre-factored fp32 Cholesky lower-triangle.
445 /// Solves `A · x = rhs` in-place into `rhs` (column-major, p × nrhs).
446 fn spotrs_in_place(
447 solver: &DnHandle,
448 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
449 p: usize,
450 nrhs: usize,
451 factor: &CudaSlice<f32>,
452 rhs: &mut CudaSlice<f32>,
453 ) -> Result<(), String> {
454 potrs_in_place_generic::<f32>(solver, stream, p, nrhs, factor, rhs)
455 }
456
457 // -----------------------------------------------------------------------
458 // fp64 DGEMV residual: r = b − A·x in double precision
459 // -----------------------------------------------------------------------
460
461 /// Compute `r = b − A·x` in fp64 where A is p×p and x, b, r are length p.
462 ///
463 /// Overwrites the output buffer `r_dev` with the residual. Uses
464 /// `cublasDgemv` (CUBLAS_OP_N): `r = 1·A·x + 0·0 = A·x`, then the host
465 /// subtracts from b. Because p is small here (the policy gates on p ≥ 64
466 /// and the Newton system is p×p), downloading the p-vector for the host
467 /// subtract is cheap relative to the GEMV.
468 fn residual_norm_and_vec(
469 blas: &CudaBlas,
470 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
471 p: usize,
472 a_dev: &CudaSlice<f64>,
473 x_dev: &CudaSlice<f64>,
474 b_host: &[f64],
475 ) -> Result<(Vec<f64>, f64), String> {
476 let p_i = to_i32(p)?;
477 // ax_dev = A · x
478 let mut ax_dev = stream
479 .alloc_zeros::<f64>(p)
480 .map_err(|e| format!("alloc ax: {e}"))?;
481 {
482 let cfg = GemvConfig::<f64> {
483 trans: cublas_sys::cublasOperation_t::CUBLAS_OP_N,
484 m: p_i,
485 n: p_i,
486 alpha: 1.0_f64,
487 lda: p_i,
488 incx: 1,
489 beta: 0.0_f64,
490 incy: 1,
491 };
492 // SAFETY: cuBLAS Dgemv; a_dev is p*p col-major f64, x_dev is
493 // length-p f64, ax_dev is length-p output; all on the same stream.
494 unsafe { blas.gemv(cfg, a_dev, x_dev, &mut ax_dev) }
495 .map_err(|e| format!("cublasDgemv for residual: {e}"))?;
496 }
497 let ax_host = stream
498 .clone_dtoh(&ax_dev)
499 .map_err(|e| format!("download A·x: {e}"))?;
500 // r = b − A·x (host subtract; p is small)
501 let r: Vec<f64> = b_host
502 .iter()
503 .zip(ax_host.iter())
504 .map(|(bi, axi)| bi - axi)
505 .collect();
506 let norm_r = r.iter().map(|v| v * v).sum::<f64>().sqrt();
507 Ok((r, norm_r))
508 }
509
510 // -----------------------------------------------------------------------
511 // Iterative refinement: fp32 factor → fp32 solve → fp64 residual loop
512 // -----------------------------------------------------------------------
513
514 /// Solve `A x = b` using an fp32 Cholesky factorization with up to
515 /// `max_steps` fp64-residual iterative refinement corrections.
516 ///
517 /// # Algorithm
518 ///
519 /// 1. Cast `A` (f64) to f32 on device. Factor in fp32 (POTRF).
520 /// 2. Cast `b` (f64) to f32. Solve `A x = b` in fp32 (POTRS). Lift `x`
521 /// to f64.
522 /// 3. Loop up to `max_steps`:
523 /// a. `r = b − A·x` accumulated in fp64 (cuBLAS Dgemv).
524 /// b. `‖r‖ / ‖b‖ ≤ tol` → converged, break.
525 /// c. Residual did not drop below previous step → bail, return `Err`.
526 /// d. Cast `r` to f32. Solve `A e = r` in fp32. `x += e` (f64).
527 /// 4. Return `(x, ‖r‖/‖b‖, refinement_steps)`.
528 ///
529 /// Returns `Err` when the fp32 POTRF fails (not SPD at f32) or when the
530 /// residual does not decrease monotonically (κ(A)·u_f32 ≥ 1 regime).
531 /// Callers should fall back to fp64 POTRF on `Err`.
532 pub(super) fn iterative_refinement_solve_impl(
533 hessian: ArrayView2<'_, f64>,
534 rhs: &[f64],
535 ) -> Result<super::RefinementOutcome, String> {
536 use crate::policy::GpuDispatchPolicy;
537 let (p, p2) = hessian.dim();
538 if p == 0 || p != p2 || rhs.len() != p {
539 return Err("iterative_refinement_solve: dimension mismatch".to_string());
540 }
541 let max_steps = GpuDispatchPolicy::REFINEMENT_MAX_STEPS;
542 let tol = GpuDispatchPolicy::REFINEMENT_TOL;
543
544 let (_, stream) = context_and_stream()?;
545 let solver = DnHandle::new(stream.clone()).map_err(|e| format!("cusolver init: {e}"))?;
546 let blas = CudaBlas::new(stream.clone()).map_err(|e| format!("cublas init: {e}"))?;
547
548 // Upload fp64 hessian for residual GEMV.
549 let h_col_f64 = to_col_major(&hessian);
550 let a_dev_f64 = pinned_htod(&stream, &h_col_f64)?;
551
552 // Cast A to f32 and upload.
553 let h_col_f32: Vec<f32> = h_col_f64.iter().map(|&v| v as f32).collect();
554 let mut a_dev_f32 =
555 pinned_htod(&stream, &h_col_f32).map_err(|e| format!("upload f32 A: {e}"))?;
556
557 // fp32 POTRF — returns Err if A is not SPD at f32 precision.
558 spotrf_in_place(&solver, &stream, p, &mut a_dev_f32)?;
559
560 // Cast b to f32 and upload; solve in fp32.
561 let b_f32: Vec<f32> = rhs.iter().map(|&v| v as f32).collect();
562 let mut x_dev_f32 =
563 pinned_htod(&stream, &b_f32).map_err(|e| format!("upload f32 rhs: {e}"))?;
564 spotrs_in_place(&solver, &stream, p, 1, &a_dev_f32, &mut x_dev_f32)?;
565
566 // Lift x to f64.
567 let x_f32 = stream
568 .clone_dtoh(&x_dev_f32)
569 .map_err(|e| format!("download f32 x: {e}"))?;
570 let mut x: Vec<f64> = x_f32.iter().map(|&v| v as f64).collect();
571
572 // Compute ‖b‖ for relative residual.
573 let norm_b = rhs.iter().map(|v| v * v).sum::<f64>().sqrt();
574 let norm_b_safe = if norm_b > 0.0 { norm_b } else { 1.0 };
575
576 let mut x_dev_f64 = pinned_htod(&stream, &x).map_err(|e| format!("upload f64 x: {e}"))?;
577 let (r0, norm_r0) = residual_norm_and_vec(&blas, &stream, p, &a_dev_f64, &x_dev_f64, rhs)?;
578 let mut rel_residual = norm_r0 / norm_b_safe;
579
580 // Early exit: already converged after initial solve.
581 if rel_residual <= tol {
582 return Ok(super::RefinementOutcome {
583 solution: ndarray::Array1::from_vec(x),
584 relative_residual: rel_residual,
585 used_fp32_factor: true,
586 refinement_steps: 0,
587 });
588 }
589
590 let mut r = r0;
591 let mut prev_norm_r = norm_r0;
592 let mut steps_taken = 0_usize;
593
594 for _ in 0..max_steps {
595 // Cast residual to f32, solve A e = r in fp32.
596 let r_f32: Vec<f32> = r.iter().map(|&v| v as f32).collect();
597 let mut e_dev_f32 =
598 pinned_htod(&stream, &r_f32).map_err(|e| format!("upload f32 residual: {e}"))?;
599 spotrs_in_place(&solver, &stream, p, 1, &a_dev_f32, &mut e_dev_f32)?;
600
601 // x += e in f64.
602 let e_f32 = stream
603 .clone_dtoh(&e_dev_f32)
604 .map_err(|e| format!("download f32 e: {e}"))?;
605 for (xi, ei) in x.iter_mut().zip(e_f32.iter()) {
606 *xi += *ei as f64;
607 }
608 steps_taken += 1;
609
610 // Reupload x_dev_f64 and compute new residual.
611 x_dev_f64 = pinned_htod(&stream, &x).map_err(|e| format!("upload refined x: {e}"))?;
612 let (r_new, norm_r_new) =
613 residual_norm_and_vec(&blas, &stream, p, &a_dev_f64, &x_dev_f64, rhs)?;
614 rel_residual = norm_r_new / norm_b_safe;
615
616 // Check monotone decrease. Non-monotone → κ(A)·u ≥ 1.
617 if norm_r_new >= prev_norm_r {
618 return Err(format!(
619 "iterative refinement: residual not decreasing ({norm_r_new:.3e} ≥ {prev_norm_r:.3e}); \
620 κ(A)·u_f32 ≥ 1, cannot refine"
621 ));
622 }
623 prev_norm_r = norm_r_new;
624 r = r_new;
625
626 if rel_residual <= tol {
627 break;
628 }
629 }
630
631 Ok(super::RefinementOutcome {
632 solution: ndarray::Array1::from_vec(x),
633 relative_residual: rel_residual,
634 used_fp32_factor: true,
635 refinement_steps: steps_taken,
636 })
637 }
638
639 /// Bind a specific device ordinal's cached context on the calling thread and
640 /// open a fresh stream on it. This is the per-ordinal entry point used by
641 /// multi-GPU fan-out (`crate::pool::scatter_batched` workers) so a
642 /// Cholesky / TRSM can target the device the worker thread owns. The
643 /// primary-device convenience wrapper [`context_and_stream`] calls this with
644 /// the probe-selected ordinal.
645 pub(crate) fn context_and_stream_for(
646 ordinal: usize,
647 ) -> Result<
648 (
649 std::sync::Arc<CudaContext>,
650 std::sync::Arc<cudarc::driver::CudaStream>,
651 ),
652 String,
653 > {
654 let ctx = super::super::device_runtime::cuda_context_for(ordinal)
655 .ok_or_else(|| format!("cuda context for ordinal {ordinal} unavailable"))?;
656 ctx.bind_to_thread()
657 .map_err(|e| format!("cuda context bind_to_thread: {e}"))?;
658 let stream = ctx.new_stream().map_err(|e| format!("cuda stream: {e}"))?;
659 Ok((ctx, stream))
660 }
661
662 pub fn context_and_stream() -> Result<
663 (
664 std::sync::Arc<CudaContext>,
665 std::sync::Arc<cudarc::driver::CudaStream>,
666 ),
667 String,
668 > {
669 // Route through the runtime's cached primary context for the selected
670 // device so every CUDA client in the process (calibration, session,
671 // cuSolver) shares one CUcontext per ordinal. Falling back to
672 // `CudaContext::new(0)` here would fragment driver state across
673 // distinct contexts, defeat memory-pool sharing, and pin work to
674 // ordinal 0 even when the runtime probe chose a different device.
675 let runtime = super::super::device_runtime::GpuRuntime::require()
676 .map_err(|error| format!("cuda runtime unavailable: {error}"))?;
677 context_and_stream_for(runtime.selected_device().ordinal)
678 }
679
680 pub fn pinned_htod<T: cudarc::driver::DeviceRepr + cudarc::driver::ValidAsZeroBits + Copy>(
681 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
682 src: &[T],
683 ) -> Result<CudaSlice<T>, String> {
684 // Originally this routine round-tripped the upload through a
685 // `CU_MEMHOSTALLOC_WRITECOMBINED` pinned staging buffer
686 // (`ctx.alloc_pinned`) to enable async DMA. In cudarc 0.19 the
687 // `PinnedHostSlice` returned from `alloc_pinned` carries an event that
688 // its `Drop` impl unconditionally `event.synchronize()`s before freeing
689 // the host mapping — see cudarc-0.19.7 `core.rs::PinnedHostSlice::drop`.
690 // Because the staging buffer goes out of scope at the end of this
691 // function, the host thread blocks here until the H2D copy completes,
692 // immediately defeating the "async" of pinned DMA. The net cost is two
693 // extra driver calls per upload (`cuMemHostAlloc_WC` + `cuMemFreeHost`)
694 // plus a forced stream synchronization, and the workspace ends up
695 // strictly slower than a plain pageable H2D — the driver already
696 // stages pageable copies internally via its own pinned pool, and that
697 // path does not block the issuing host thread for unrelated stream
698 // work. Issue a direct async H2D from the pageable buffer instead.
699 stream.clone_htod(src).map_err(|e| format!("cuda H2D: {e}"))
700 }
701
702 pub fn potrf_in_place(
703 solver: &DnHandle,
704 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
705 p: usize,
706 h: &mut CudaSlice<f64>,
707 ) -> Result<(), String> {
708 potrf_in_place_generic::<f64>(solver, stream, p, h)
709 }
710
711 pub fn potrs_in_place(
712 solver: &DnHandle,
713 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
714 p: usize,
715 nrhs: usize,
716 h: &CudaSlice<f64>,
717 rhs: &mut CudaSlice<f64>,
718 ) -> Result<(), String> {
719 potrs_in_place_generic::<f64>(solver, stream, p, nrhs, h, rhs)
720 }
721
722 /// Query the cuSOLVER POTRF workspace size for a p×p matrix.
723 ///
724 /// Called once at workspace construction to size the persistent workspace
725 /// buffer. Returns the number of f64 elements required.
726 pub fn potrf_query_lwork(
727 solver: &DnHandle,
728 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
729 p: usize,
730 ) -> Result<usize, String> {
731 potrf_bufsize_generic::<f64>(solver, stream, p)
732 }
733
734 /// POTRF factorization using pre-allocated workspace and info buffers.
735 ///
736 /// Does not allocate, does not download `info`. The caller is responsible
737 /// for calling [`check_deferred_potrf_info`] at end-of-fit to confirm no
738 /// factorization failed.
739 ///
740 /// `workspace` must have been allocated with at least `lwork` elements
741 /// (as reported by [`potrf_query_lwork`] at workspace construction).
742 /// `info_dev` is a 1-element device i32 buffer; after a failed
743 /// factorization it holds a positive integer but stays device-resident.
744 pub fn potrf_in_place_reuse(
745 solver: &DnHandle,
746 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
747 p: usize,
748 lwork: i32,
749 h: &mut CudaSlice<f64>,
750 workspace: &mut CudaSlice<f64>,
751 info_dev: &mut CudaSlice<i32>,
752 ) -> Result<(), String> {
753 let p_i = to_i32(p)?;
754 let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
755 {
756 let (h_ptr, _h_record) = h.device_ptr_mut(stream);
757 let (work_ptr, _work_record) = workspace.device_ptr_mut(stream);
758 let (info_ptr, _info_record) = info_dev.device_ptr_mut(stream);
759 // SAFETY: cuSOLVER potrf; h is p*p col-major, workspace was sized
760 // by potrf_query_lwork, info_dev is a pre-allocated 1-element i32
761 // device buffer. All buffers are live on the same stream.
762 let status = unsafe {
763 cusolver_sys::cusolverDnDpotrf(
764 solver.cu(),
765 uplo,
766 p_i,
767 h_ptr as *mut f64,
768 p_i,
769 work_ptr as *mut f64,
770 lwork,
771 info_ptr as *mut i32,
772 )
773 };
774 check_cusolver(status, "cusolverDnDpotrf")?;
775 }
776 Ok(())
777 }
778
779 /// POTRS triangular solve using a pre-allocated info buffer.
780 ///
781 /// Does not allocate, does not download `info`. The caller is responsible
782 /// for calling [`check_deferred_potrs_info`] at end-of-fit.
783 pub fn potrs_in_place_reuse(
784 solver: &DnHandle,
785 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
786 p: usize,
787 nrhs: usize,
788 h: &CudaSlice<f64>,
789 rhs: &mut CudaSlice<f64>,
790 info_dev: &mut CudaSlice<i32>,
791 ) -> Result<(), String> {
792 let p_i = to_i32(p)?;
793 let nrhs_i = to_i32(nrhs)?;
794 let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
795 {
796 let (h_ptr, _h_record) = h.device_ptr(stream);
797 let (rhs_ptr, _rhs_record) = rhs.device_ptr_mut(stream);
798 let (info_ptr, _info_record) = info_dev.device_ptr_mut(stream);
799 // SAFETY: cuSOLVER potrs; h is a p*p Cholesky factor, rhs is p*nrhs,
800 // info_dev is a pre-allocated 1-element i32 device buffer.
801 let status = unsafe {
802 cusolver_sys::cusolverDnDpotrs(
803 solver.cu(),
804 uplo,
805 p_i,
806 nrhs_i,
807 h_ptr as *const f64,
808 p_i,
809 rhs_ptr as *mut f64,
810 p_i,
811 info_ptr as *mut i32,
812 )
813 };
814 check_cusolver(status, "cusolverDnDpotrs")?;
815 }
816 Ok(())
817 }
818
819 /// Download the POTRF deferred info scalar and return an error if non-zero.
820 ///
821 /// Called once at end-of-fit (or whenever the convergence loop exits) to
822 /// surface any factorization failure that was deferred device-side by
823 /// [`potrf_in_place_reuse`].
824 pub fn check_deferred_potrf_info(
825 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
826 info_dev: &CudaSlice<i32>,
827 ) -> Result<(), String> {
828 let info_host = stream
829 .clone_dtoh(info_dev)
830 .map_err(|e| format!("download deferred potrf info: {e}"))?;
831 if info_host[0] == 0 {
832 Ok(())
833 } else {
834 Err(format!(
835 "cusolverDnDpotrf returned info={} (detected at end-of-fit)",
836 info_host[0]
837 ))
838 }
839 }
840
841 /// Download the POTRS deferred info scalar and return an error if non-zero.
842 ///
843 /// Mirrors [`check_deferred_potrf_info`] for the triangular-solve step.
844 pub fn check_deferred_potrs_info(
845 stream: &std::sync::Arc<cudarc::driver::CudaStream>,
846 info_dev: &CudaSlice<i32>,
847 ) -> Result<(), String> {
848 let info_host = stream
849 .clone_dtoh(info_dev)
850 .map_err(|e| format!("download deferred potrs info: {e}"))?;
851 if info_host[0] == 0 {
852 Ok(())
853 } else {
854 Err(format!(
855 "cusolverDnDpotrs returned info={} (detected at end-of-fit)",
856 info_host[0]
857 ))
858 }
859 }
860
861 pub fn cholesky_logdet_from_col_major(factor: &[f64], p: usize) -> f64 {
862 let factor = MatRef::from_column_major_slice(factor, p, p);
863 cholesky_factor_logdet(factor)
864 }
865
866 fn check_cusolver(
867 status: cusolver_sys::cusolverStatus_t,
868 label: &'static str,
869 ) -> Result<(), String> {
870 if status == cusolver_sys::cusolverStatus_t::CUSOLVER_STATUS_SUCCESS {
871 Ok(())
872 } else {
873 Err(format!("{label} failed with {status:?}"))
874 }
875 }
876
877 fn to_i32(value: usize) -> Result<i32, String> {
878 i32::try_from(value).map_err(|_| format!("CUDA dimension {value} exceeds i32"))
879 }
880}
881
882// These solver entry points are consumed by sibling crates (`gam-solve`'s
883// pirls/reml GPU paths, `gam-models`, ...) via `gam_gpu::solver::*`, so they
884// are part of gam-gpu's public surface. `potrf_in_place_generic` is the
885// only one with no cross-crate consumer; it stays crate-private and is
886// reached internally through `crate::solver::potrf_in_place_generic`.
887#[cfg(target_os = "linux")]
888pub(crate) use cuda::potrf_in_place_generic;
889#[cfg(target_os = "linux")]
890pub use cuda::{
891 check_deferred_potrf_info, check_deferred_potrs_info, cholesky_logdet_from_col_major,
892 context_and_stream, pinned_htod, potrf_in_place, potrf_in_place_reuse, potrf_query_lwork,
893 potrs_in_place, potrs_in_place_reuse,
894};
895
896/// Solve `A x = b` with fp32 Cholesky factorization + fp64-residual iterative
897/// refinement, automatically falling back to fp64 when the policy rejects the
898/// attempt or when the fp32 path fails / diverges.
899///
900/// The `p` threshold and maximum step count come from `GpuDispatchPolicy`
901/// constants — there is no user-facing knob. The decision path is:
902///
903/// 1. `policy.iterative_refinement_should_attempt(p)` → `false` or
904/// multi-column RHS: skip to the fp64 Cholesky path.
905/// 2. Attempt fp32 POTRF + up to `REFINEMENT_MAX_STEPS` residual-correction
906/// steps. Falls back to fp64 on:
907/// - fp32 POTRF info ≠ 0 (A is not SPD at f32 precision),
908/// - non-monotone residual (κ(A)·u_fp32 ≥ 1 regime).
909/// 3. On fp32 success the logdet is computed from the fp64 Cholesky factor —
910/// BUT only when `need_logdet` is true. The fp64 POTRF is an O(p³)
911/// factorization that fully negates the mixed-precision speedup (the whole
912/// point is to do the expensive factor in fp32), so a caller that only needs
913/// the *solution* (e.g. the PIRLS Newton direction solve, which discards the
914/// logdet) passes `need_logdet = false` and the redundant fp64 POTRF is
915/// skipped entirely — the returned logdet is `NaN` in that case. The solution
916/// is always full-fp64-accurate via the residual refinement regardless.
917///
918/// Returns `(solution, logdet, Some(RefinementOutcome))` when the fp32 path
919/// succeeded, or `(solution, logdet, None)` on the fp64 fallback. When
920/// `need_logdet` is false and the fp32 path succeeds, the logdet field is `NaN`.
921pub fn iterative_refinement_cholesky_solve(
922 hessian: ArrayView2<'_, f64>,
923 rhs: ArrayView2<'_, f64>,
924 need_logdet: bool,
925) -> Result<(Array2<f64>, f64, Option<RefinementOutcome>), String> {
926 #[cfg(not(target_os = "linux"))]
927 {
928 let (rows, cols) = hessian.dim();
929 return Err(format!(
930 "CUDA support not compiled; hessian={rows}x{cols}, rhs={}x{}, need_logdet={need_logdet}",
931 rhs.nrows(),
932 rhs.ncols()
933 ));
934 }
935
936 #[cfg(target_os = "linux")]
937 {
938 let runtime = super::device_runtime::GpuRuntime::require().map_err(|error| {
939 let (rows, cols) = hessian.dim();
940 format!(
941 "CUDA runtime unavailable; hessian={rows}x{cols}, rhs={}x{}: {error}",
942 rhs.nrows(),
943 rhs.ncols()
944 )
945 })?;
946 let p = hessian.nrows();
947
948 // Attempt fp32 + refinement only for single-column RHS with p large
949 // enough that the fp64 GEMV residual cost is amortised.
950 if rhs.ncols() == 1 && runtime.policy.iterative_refinement_should_attempt(p) {
951 let rhs_col = rhs.column(0);
952 let rhs_slice: Vec<f64> = rhs_col.iter().copied().collect();
953 if let Ok(outcome) = cuda::iterative_refinement_solve_impl(hessian, &rhs_slice) {
954 // fp32 + refinement succeeded; the refined solution is full
955 // fp64 accuracy. The logdet, however, needs the fp64 Cholesky
956 // factor (the fp32 diagonal is only fp32-accurate, and the
957 // logdet feeds the REML criterion / EDF). Run the fp64 POTRF
958 // ONLY when the caller actually consumes the logdet: otherwise
959 // that O(p³) factorization is pure overhead that cancels the
960 // mixed-precision win (the expensive factor would then run in
961 // BOTH precisions). A solution-only caller (PIRLS Newton
962 // direction, which discards the logdet) gets the genuine
963 // fp32-factor speedup; logdet is reported as NaN.
964 let mut sol = Array2::<f64>::zeros((p, 1));
965 sol.column_mut(0).assign(&outcome.solution);
966 if !need_logdet {
967 return Ok((sol, f64::NAN, Some(outcome)));
968 }
969 if let Ok(logdet) = cuda::cholesky_logdet(hessian) {
970 return Ok((sol, logdet, Some(outcome)));
971 }
972 // fp64 logdet failed (theoretically impossible for SPD A);
973 // fall through to plain fp64 path.
974 }
975 // fp32 path failed (not SPD at f32, or residual non-monotone) →
976 // fall through to fp64.
977 }
978
979 let (sol, logdet) = cuda::cholesky_solve(hessian, rhs)?;
980 Ok((sol, logdet, None))
981 }
982}
983
984pub fn cholesky_solve_gpu(
985 hessian: ArrayView2<'_, f64>,
986 rhs: ArrayView2<'_, f64>,
987) -> Result<(Array2<f64>, f64), String> {
988 // Route through iterative refinement. The function falls back to fp64
989 // internally, so callers always get a valid result; the refinement
990 // outcome metadata is intentionally not surfaced by this thin wrapper.
991 // This wrapper returns the logdet, so it must request it (`need_logdet`).
992 let result = iterative_refinement_cholesky_solve(hessian, rhs, /*need_logdet=*/ true)?;
993 Ok((result.0, result.1))
994}
995/// Solution-only mixed-precision solve: like [`cholesky_solve_gpu`] but skips
996/// the redundant fp64 POTRF when the fp32 + refinement path succeeds, since the
997/// caller does not consume the log-determinant. This is the path that delivers
998/// the full mixed-precision speedup (expensive O(p³) factor stays fp32) for the
999/// PIRLS Newton direction solve, where the logdet is discarded. The solution is
1000/// full fp64 accuracy via iterative refinement.
1001pub fn cholesky_solve_only_gpu(
1002 hessian: ArrayView2<'_, f64>,
1003 rhs: ArrayView2<'_, f64>,
1004) -> Result<Array2<f64>, String> {
1005 let result = iterative_refinement_cholesky_solve(hessian, rhs, /*need_logdet=*/ false)?;
1006 Ok(result.0)
1007}
1008
1009#[cfg(target_os = "linux")]
1010pub(crate) fn cholesky_lower_on_ordinal_gpu(
1011 ordinal: usize,
1012 hessian: ArrayView2<'_, f64>,
1013) -> Result<Array2<f64>, String> {
1014 cuda::cholesky_lower_on_ordinal(ordinal, hessian)
1015}