Skip to main content

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}