Skip to main content

gam_gpu/
blas.rs

1//! Device BLAS surface for the cudarc-backed dense kernels.
2//!
3//! The public surface here is the lowest level of the GPU dispatch stack: it
4//! takes ndarray views, copies them to a device buffer, calls a cuBLAS / kernel
5//! routine, and returns the host result. The cudarc-backed implementations
6//! always compile (cudarc dynamically loads `libcuda` at runtime via the
7//! `fallback-dynamic-loading` feature), and dispatch is gated at runtime on
8//! `super::device_runtime::GpuRuntime::resolve()` — typed hardware absence
9//! advertises `CudaUnavailable`, while probe faults remain errors.
10//!
11//! The implementations route through `super::device_runtime::cuda_context_for` and
12//! the cudarc 0.19 cuBLAS API. Any transient backend failure (OOM, launch
13//! error, …) is converted to `None` so the auto-dispatch shim in
14//! `super::linalg` falls back to the CPU fast path without disturbing
15//! numerics.
16
17#[cfg(target_os = "linux")]
18mod cuda_impl {
19    use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, Axis};
20
21    use crate::driver::{array_from_row_major, from_col_major, to_col_major, to_i32, to_row_major};
22
23    use super::super::device_runtime::GpuRuntime;
24    use cudarc::cublas::sys::{
25        cublasDiagType_t, cublasFillMode_t, cublasOperation_t, cublasSideMode_t, cublasStatus_t,
26    };
27    use cudarc::cublas::{CudaBlas, Gemm, GemmConfig, Gemv, GemvConfig, StridedBatchedConfig};
28    use cudarc::cusolver::{DnHandle, sys as cusolver_sys};
29    use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, DevicePtrMut};
30    use std::sync::Arc;
31
32    /// Create a fresh stream + cuBLAS handle bound to a specific device
33    /// ordinal. This is the per-ordinal entry point used by multi-GPU fan-out
34    /// (`super::super::pool::scatter_batched` workers): the worker thread has
35    /// already bound that ordinal's context, and the stream/handle created here
36    /// target the same device. The single-device helper below is the
37    /// primary-ordinal specialization.
38    #[inline]
39    pub(crate) fn stream_and_blas_for(ordinal: usize) -> Option<(Arc<CudaStream>, CudaBlas)> {
40        let stream = super::super::device_runtime::cuda_context_for(ordinal)?
41            .new_stream()
42            .ok()?;
43        let blas = CudaBlas::new(stream.clone()).ok()?;
44        Some((stream, blas))
45    }
46
47    #[inline]
48    fn stream_and_blas(runtime: &GpuRuntime) -> Option<(Arc<CudaStream>, CudaBlas)> {
49        stream_and_blas_for(runtime.device.ordinal)
50    }
51
52    #[inline]
53    fn vector_values(v: ArrayView1<'_, f64>) -> Vec<f64> {
54        v.iter().copied().collect()
55    }
56
57    #[inline]
58    fn to_col_major_batch(batch: ArrayView3<'_, f64>) -> Vec<f64> {
59        let (batch_len, rows, cols) = batch.dim();
60        let mut out = Vec::with_capacity(batch_len.saturating_mul(rows).saturating_mul(cols));
61        for matrix in batch.axis_iter(Axis(0)) {
62            out.extend(to_col_major(&matrix).iter().copied());
63        }
64        out
65    }
66
67    #[inline]
68    fn from_col_major_batch(
69        data: &[f64],
70        batch: usize,
71        rows: usize,
72        cols: usize,
73    ) -> Option<Array3<f64>> {
74        if data.len() != batch.checked_mul(rows)?.checked_mul(cols)? {
75            return None;
76        }
77        let mut out = Array3::<f64>::zeros((batch, rows, cols));
78        let matrix_len = rows.checked_mul(cols)?;
79        for batch_idx in 0..batch {
80            let base = batch_idx.checked_mul(matrix_len)?;
81            for col in 0..cols {
82                for row in 0..rows {
83                    out[[batch_idx, row, col]] = data[base + col * rows + row];
84                }
85            }
86        }
87        Some(out)
88    }
89
90    #[inline]
91    fn row_scale_device(
92        blas: &CudaBlas,
93        stream: &Arc<CudaStream>,
94        matrix_dev: &CudaSlice<f64>,
95        weights_dev: &CudaSlice<f64>,
96        scaled_dev: &mut CudaSlice<f64>,
97        rows: usize,
98        cols: usize,
99    ) -> Option<()> {
100        let rows_i = to_i32(rows)?;
101        let cols_i = to_i32(cols)?;
102        let handle = *blas.handle();
103        let (matrix_ptr, _matrix_record) = matrix_dev.device_ptr(stream);
104        let (weights_ptr, _weights_record) = weights_dev.device_ptr(stream);
105        let (scaled_ptr, _scaled_record) = scaled_dev.device_ptr_mut(stream);
106        // SAFETY: all device slices are on this stream/context. `matrix_dev`
107        // and `scaled_dev` are rows×cols column-major matrices with lda/ldc
108        // equal to rows; `weights_dev` has one contiguous value per row.
109        let status = unsafe {
110            cudarc::cublas::sys::cublasDdgmm(
111                handle,
112                cublasSideMode_t::CUBLAS_SIDE_LEFT,
113                rows_i,
114                cols_i,
115                matrix_ptr as *const f64,
116                rows_i,
117                weights_ptr as *const f64,
118                1,
119                scaled_ptr as *mut f64,
120                rows_i,
121            )
122        };
123        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
124            Some(())
125        } else {
126            None
127        }
128    }
129
130    #[inline]
131    fn weighted_crossprod(
132        runtime: &GpuRuntime,
133        left: ArrayView2<'_, f64>,
134        weights: ArrayView1<'_, f64>,
135        right: ArrayView2<'_, f64>,
136    ) -> Option<Array2<f64>> {
137        weighted_crossprod_for(runtime.device.ordinal, left, weights, right)
138    }
139
140    #[inline]
141    fn weighted_crossprod_for(
142        ordinal: usize,
143        left: ArrayView2<'_, f64>,
144        weights: ArrayView1<'_, f64>,
145        right: ArrayView2<'_, f64>,
146    ) -> Option<Array2<f64>> {
147        let (rows, left_cols) = left.dim();
148        let (right_rows, right_cols) = right.dim();
149        if rows == 0
150            || left_cols == 0
151            || right_cols == 0
152            || rows != right_rows
153            || rows != weights.len()
154        {
155            return None;
156        }
157
158        let (stream, blas) = stream_and_blas_for(ordinal)?;
159        // #1412: the symmetric Gram `Xᵀ·diag(w)·X` (xt_diag_x) passes the SAME
160        // array as `left` and `right`. Detect that (identical data pointer +
161        // shape) and stage `X` ONCE instead of column-majoring and H2D-uploading
162        // two byte-identical n×p copies — halving the dominant H2D for the Gram.
163        // The GEMM operands are unchanged (`left_dev` doubles as the row-scale
164        // source), so the result is bit-identical to the two-upload path.
165        let same_operand = std::ptr::eq(left.as_ptr(), right.as_ptr())
166            && left.dim() == right.dim()
167            && left.strides() == right.strides();
168        let left_col = to_col_major(&left);
169        let weights_host = vector_values(weights);
170        let left_dev = stream.clone_htod(&*left_col).ok()?;
171        // Symmetric Gram: `right` IS `left`, so row-scale directly from the
172        // single resident `left_dev` and never upload a second n×p copy. The
173        // asymmetric path uploads `right` as before.
174        let right_dev = if same_operand {
175            None
176        } else {
177            let right_col = to_col_major(&right);
178            Some(stream.clone_htod(&*right_col).ok()?)
179        };
180        let weights_dev = stream.clone_htod(&weights_host).ok()?;
181        let mut weighted_right_dev = stream
182            .alloc_zeros::<f64>(rows.checked_mul(right_cols)?)
183            .ok()?;
184        row_scale_device(
185            &blas,
186            &stream,
187            right_dev.as_ref().unwrap_or(&left_dev),
188            &weights_dev,
189            &mut weighted_right_dev,
190            rows,
191            right_cols,
192        )?;
193
194        let mut out_dev = stream
195            .alloc_zeros::<f64>(left_cols.checked_mul(right_cols)?)
196            .ok()?;
197        let cfg = GemmConfig::<f64> {
198            transa: cublasOperation_t::CUBLAS_OP_T,
199            transb: cublasOperation_t::CUBLAS_OP_N,
200            m: to_i32(left_cols)?,
201            n: to_i32(right_cols)?,
202            k: to_i32(rows)?,
203            alpha: 1.0,
204            lda: to_i32(rows)?,
205            ldb: to_i32(rows)?,
206            beta: 0.0,
207            ldc: to_i32(left_cols)?,
208        };
209        // SAFETY: cfg computes leftᵀ (left_cols×rows) times weighted_right
210        // (rows×right_cols) into a left_cols×right_cols column-major output.
211        unsafe { blas.gemm(cfg, &left_dev, &weighted_right_dev, &mut out_dev) }.ok()?;
212        let out_col = stream.clone_dtoh(&out_dev).ok()?;
213        from_col_major(&out_col, left_cols, right_cols)
214    }
215
216    /// #1017 Phase 3: a device-resident design matrix `X` whose `n×p` values are
217    /// uploaded to the device ONCE and reused across many `Xᵀ·diag(w)·X` Gram
218    /// evaluations.
219    ///
220    /// The per-call [`xt_diag_x_cuda`] path re-uploads the full `n×p` `X` (and a
221    /// second copy as the `right` operand) on EVERY call. For the SAE / IRLS
222    /// inner loop — where `X` is frozen across weight updates and the Gram is
223    /// rebuilt once per Newton/PIRLS step — that H2D staging dominates the wall
224    /// clock (measured #1412: the `XtWX` GEMM is ~98% of the pipeline at <20% GPU
225    /// utilisation, i.e. the device is starved by the per-call upload, not the
226    /// arithmetic). Uploading `X` once and crossing only the `n`-vector `w` (and
227    /// the `p×p` result) per call removes that ping-pong: the resident `X` is
228    /// `n·p` doubles vs the per-call `w` of `n` doubles, so the amortised
229    /// transfer per Gram drops by a factor of `p`.
230    pub(crate) struct ResidentWeightedGram {
231        stream: Arc<CudaStream>,
232        blas: CudaBlas,
233        x_dev: CudaSlice<f64>,
234        rows: usize,
235        cols: usize,
236    }
237
238    impl ResidentWeightedGram {
239        /// Upload `x` (`n×p`) to `ordinal` once, column-major, and keep it
240        /// resident. Returns `None` on a degenerate shape or any device failure
241        /// (the caller falls back to the per-call CPU/GPU path).
242        pub(crate) fn new(ordinal: usize, x: ArrayView2<'_, f64>) -> Option<Self> {
243            let (rows, cols) = x.dim();
244            if rows == 0 || cols == 0 {
245                return None;
246            }
247            let (stream, blas) = stream_and_blas_for(ordinal)?;
248            let x_col = to_col_major(&x);
249            let x_dev = stream.clone_htod(&*x_col).ok()?;
250            Some(Self {
251                stream,
252                blas,
253                x_dev,
254                rows,
255                cols,
256            })
257        }
258
259        #[inline]
260        pub(crate) fn dims(&self) -> (usize, usize) {
261            (self.rows, self.cols)
262        }
263
264        /// Compute `Xᵀ·diag(w)·X` reusing the resident `X`. Only `w` (`n`
265        /// doubles) crosses H2D and only the `p×p` Gram crosses D2H. The
266        /// arithmetic is bit-identical to [`xt_diag_x_cuda`] on the same device
267        /// (same `cublasDdgmm` row-scale + same `gemm` reduction order).
268        pub(crate) fn gram(&self, w: ArrayView1<'_, f64>) -> Option<Array2<f64>> {
269            if w.len() != self.rows {
270                return None;
271            }
272            let weights_host = vector_values(w);
273            let weights_dev = self.stream.clone_htod(&weights_host).ok()?;
274            let mut weighted_dev = self
275                .stream
276                .alloc_zeros::<f64>(self.rows.checked_mul(self.cols)?)
277                .ok()?;
278            row_scale_device(
279                &self.blas,
280                &self.stream,
281                &self.x_dev,
282                &weights_dev,
283                &mut weighted_dev,
284                self.rows,
285                self.cols,
286            )?;
287            let mut out_dev = self
288                .stream
289                .alloc_zeros::<f64>(self.cols.checked_mul(self.cols)?)
290                .ok()?;
291            let cfg = GemmConfig::<f64> {
292                transa: cublasOperation_t::CUBLAS_OP_T,
293                transb: cublasOperation_t::CUBLAS_OP_N,
294                m: to_i32(self.cols)?,
295                n: to_i32(self.cols)?,
296                k: to_i32(self.rows)?,
297                alpha: 1.0,
298                lda: to_i32(self.rows)?,
299                ldb: to_i32(self.rows)?,
300                beta: 0.0,
301                ldc: to_i32(self.cols)?,
302            };
303            // SAFETY: `x_dev` is the resident n×p column-major design; cfg forms
304            // Xᵀ (p×n) · weighted (n×p) → a p×p column-major Gram.
305            unsafe {
306                self.blas
307                    .gemm(cfg, &self.x_dev, &weighted_dev, &mut out_dev)
308            }
309            .ok()?;
310            let out_col = self.stream.clone_dtoh(&out_dev).ok()?;
311            from_col_major(&out_col, self.cols, self.cols)
312        }
313
314        /// Compute the resident weighted Gram `G = Xᵀ·diag(w)·X + ridge·I`,
315        /// factor it (cuSOLVER POTRF), and solve `G·β = rhs` — keeping `G`, its
316        /// Cholesky factor, and the RHS all DEVICE-RESIDENT. Only `w` (`n`),
317        /// `rhs` (`p`), and the result `β` (`p`) cross the PCIe boundary; the
318        /// `p×p` Gram is NEVER downloaded.
319        ///
320        /// This is the #1017 Phase-3 ceiling fix for the normal-equations solve:
321        /// the per-call [`gram`] still pays a `p×p` D2H (134 MB at p=4096 — the
322        /// next bottleneck once `X` is resident), whereas the SAE/IRLS inner step
323        /// only needs the `p`-vector `β = (XᵀWX+λ)⁻¹ XᵀWz`. Chaining
324        /// row-scale→GEMM→POTRF→TRSM on-device and returning only `β` removes the
325        /// Gram transfer entirely.
326        ///
327        /// `ridge` (e.g. the penalty diagonal `λ` or a Tikhonov floor) is seeded
328        /// as `ridge·I` on the device and the Gram is GEMM-accumulated onto it
329        /// (`beta = 1`), so the diagonal bump never costs a Gram round-trip.
330        /// Returns `None` on shape mismatch, a non-PD factorisation, or any
331        /// device failure (the caller falls back to the CPU solve).
332        pub(crate) fn solve_psd_normal_equations(
333            &self,
334            w: ArrayView1<'_, f64>,
335            rhs: ArrayView1<'_, f64>,
336            ridge: f64,
337        ) -> Option<Array1<f64>> {
338            if w.len() != self.rows || rhs.len() != self.cols {
339                return None;
340            }
341            let p = self.cols;
342
343            // weighted = diag(w) · X  (resident X row-scaled).
344            let weights_dev = self.stream.clone_htod(&vector_values(w)).ok()?;
345            let mut weighted_dev = self
346                .stream
347                .alloc_zeros::<f64>(self.rows.checked_mul(p)?)
348                .ok()?;
349            row_scale_device(
350                &self.blas,
351                &self.stream,
352                &self.x_dev,
353                &weights_dev,
354                &mut weighted_dev,
355                self.rows,
356                p,
357            )?;
358
359            // Pre-seed G with `ridge·I` on the device, then GEMM-accumulate
360            // `XᵀW X` onto it with `beta = 1.0`. The Gram is formed and stays
361            // device-resident: `ridge·I` is a one-time H2D upload (the only way
362            // to set a diagonal without an NVRTC kernel), and crucially the p×p
363            // Gram is NEVER read back — only `β` returns. `ridge·I` upload is
364            // bandwidth-trivial vs the avoided per-solve Gram download.
365            let mut ridge_init = vec![0.0_f64; p.checked_mul(p)?];
366            for i in 0..p {
367                ridge_init[i * p + i] = ridge;
368            }
369            let mut g_dev = self.stream.clone_htod(&ridge_init).ok()?;
370            let cfg = GemmConfig::<f64> {
371                transa: cublasOperation_t::CUBLAS_OP_T,
372                transb: cublasOperation_t::CUBLAS_OP_N,
373                m: to_i32(p)?,
374                n: to_i32(p)?,
375                k: to_i32(self.rows)?,
376                alpha: 1.0,
377                lda: to_i32(self.rows)?,
378                ldb: to_i32(self.rows)?,
379                // Accumulate onto the resident ridge·I seed.
380                beta: 1.0,
381                ldc: to_i32(p)?,
382            };
383            // SAFETY: resident n×p X and the n×p weighted buffer form a p×p Gram;
384            // beta=1 accumulates Xᵀ(WX) onto the resident ridge·I in g_dev.
385            unsafe { self.blas.gemm(cfg, &self.x_dev, &weighted_dev, &mut g_dev) }.ok()?;
386
387            // POTRF(G) → lower factor L, resident in g_dev.
388            let solver = DnHandle::new(self.stream.clone()).ok()?;
389            let info = potrf_single_dev(&solver, &self.stream, p, &mut g_dev)?;
390            if info != 0 {
391                // Not positive-definite at pivot `info`; caller falls back.
392                return None;
393            }
394
395            // Solve L Lᵀ β = rhs via two triangular solves, β resident in rhs_dev.
396            let mut rhs_dev = self.stream.clone_htod(&vector_values(rhs)).ok()?;
397            trsm_single_vec(&self.blas, &self.stream, p, &g_dev, &mut rhs_dev, false)?; // L y = rhs
398            trsm_single_vec(&self.blas, &self.stream, p, &g_dev, &mut rhs_dev, true)?; // Lᵀ β = y
399
400            // Download ONLY the p-vector solution.
401            let beta_host = self.stream.clone_dtoh(&rhs_dev).ok()?;
402            Some(Array1::from_vec(beta_host))
403        }
404    }
405
406    /// Single cuSOLVER `DPOTRF` (lower) of a resident `p×p` column-major matrix,
407    /// factored in place. Returns the cuSOLVER `info` (0 = success, k>0 = the
408    /// leading minor of order k is not PD). Mirrors the arrow-Schur frame POTRF.
409    fn potrf_single_dev(
410        solver: &DnHandle,
411        stream: &Arc<CudaStream>,
412        p: usize,
413        matrix: &mut CudaSlice<f64>,
414    ) -> Option<i32> {
415        let p_i = to_i32(p)?;
416        let uplo = cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER;
417        let mut lwork = 0_i32;
418        {
419            let (mat_ptr, _rec) = matrix.device_ptr_mut(stream);
420            // SAFETY: buffer-size query against a live p×p column-major matrix.
421            let status = unsafe {
422                cusolver_sys::cusolverDnDpotrf_bufferSize(
423                    solver.cu(),
424                    uplo,
425                    p_i,
426                    mat_ptr as *mut f64,
427                    p_i,
428                    &mut lwork,
429                )
430            };
431            if status != cusolver_sys::cusolverStatus_t::CUSOLVER_STATUS_SUCCESS {
432                return None;
433            }
434        }
435        let mut workspace = stream.alloc_zeros::<f64>(lwork.max(1) as usize).ok()?;
436        let mut info_dev = stream.alloc_zeros::<i32>(1).ok()?;
437        {
438            let (mat_ptr, _rec) = matrix.device_ptr_mut(stream);
439            let (work_ptr, _wrec) = workspace.device_ptr_mut(stream);
440            let (info_ptr, _irec) = info_dev.device_ptr_mut(stream);
441            // SAFETY: all buffers live on this stream; matrix is p×p column-major.
442            let status = unsafe {
443                cusolver_sys::cusolverDnDpotrf(
444                    solver.cu(),
445                    uplo,
446                    p_i,
447                    mat_ptr as *mut f64,
448                    p_i,
449                    work_ptr as *mut f64,
450                    lwork,
451                    info_ptr as *mut i32,
452                )
453            };
454            if status != cusolver_sys::cusolverStatus_t::CUSOLVER_STATUS_SUCCESS {
455                return None;
456            }
457        }
458        let info_host = stream.clone_dtoh(&info_dev).ok()?;
459        info_host.first().copied()
460    }
461
462    /// Triangular solve `op(L)·x = b` for a single `p`-vector RHS against a
463    /// resident lower Cholesky factor `L` (`p×p` column-major), in place over
464    /// `rhs`. `transposed` selects `Lᵀ` (the second back-substitution).
465    fn trsm_single_vec(
466        blas: &CudaBlas,
467        stream: &Arc<CudaStream>,
468        p: usize,
469        l: &CudaSlice<f64>,
470        rhs: &mut CudaSlice<f64>,
471        transposed: bool,
472    ) -> Option<()> {
473        let alpha = 1.0_f64;
474        let p_i = to_i32(p)?;
475        let handle = *blas.handle();
476        let (l_ptr, _l_rec) = l.device_ptr(stream);
477        let (rhs_ptr, _rhs_rec) = rhs.device_ptr_mut(stream);
478        // SAFETY: p×p lower factor and a single p-vector RHS, both resident.
479        let status = unsafe {
480            cudarc::cublas::sys::cublasDtrsm_v2(
481                handle,
482                cublasSideMode_t::CUBLAS_SIDE_LEFT,
483                cublasFillMode_t::CUBLAS_FILL_MODE_LOWER,
484                if transposed {
485                    cublasOperation_t::CUBLAS_OP_T
486                } else {
487                    cublasOperation_t::CUBLAS_OP_N
488                },
489                cublasDiagType_t::CUBLAS_DIAG_NON_UNIT,
490                p_i,
491                1,
492                &alpha,
493                l_ptr as *const f64,
494                p_i,
495                rhs_ptr as *mut f64,
496                p_i,
497            )
498        };
499        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
500            Some(())
501        } else {
502            None
503        }
504    }
505
506    #[inline]
507    fn assign_block(
508        out: &mut Array2<f64>,
509        row_offset: usize,
510        col_offset: usize,
511        block: &Array2<f64>,
512    ) {
513        let (rows, cols) = block.dim();
514        for col in 0..cols {
515            for row in 0..rows {
516                out[[row_offset + row, col_offset + col]] = block[[row, col]];
517            }
518        }
519    }
520
521    #[inline]
522    fn mirror_upper_to_lower(out: &mut Array2<f64>) {
523        let n = out.nrows();
524        for row in 0..n {
525            for col in 0..row {
526                out[[row, col]] = out[[col, row]];
527            }
528        }
529    }
530
531    #[inline]
532    pub(crate) fn gemm_cuda(
533        runtime: &GpuRuntime,
534        a: ArrayView2<'_, f64>,
535        b: ArrayView2<'_, f64>,
536        trans_a: bool,
537        trans_b: bool,
538    ) -> Option<Array2<f64>> {
539        gemm_on_ordinal_cuda(runtime.device.ordinal, a, b, trans_a, trans_b)
540    }
541
542    /// Dense GEMM (optionally transposing either operand) on a specific device
543    /// ordinal. The ordinal's context is expected to be bound on the calling
544    /// thread (pool-tiled callers via `super::super::pool::scatter_batched`, or
545    /// the single-device dispatcher through [`gemm_cuda`]). Semantics are
546    /// identical to [`gemm_cuda`]; only the target device differs.
547    #[inline]
548    pub(crate) fn gemm_on_ordinal_cuda(
549        ordinal: usize,
550        a: ArrayView2<'_, f64>,
551        b: ArrayView2<'_, f64>,
552        trans_a: bool,
553        trans_b: bool,
554    ) -> Option<Array2<f64>> {
555        let (a_rows, a_cols) = a.dim();
556        let (b_rows, b_cols) = b.dim();
557        let (m, k_a) = if trans_a {
558            (a_cols, a_rows)
559        } else {
560            (a_rows, a_cols)
561        };
562        let (k_b, n) = if trans_b {
563            (b_cols, b_rows)
564        } else {
565            (b_rows, b_cols)
566        };
567        if m == 0 || n == 0 || k_a == 0 || k_a != k_b {
568            return None;
569        }
570        let (stream, blas) = stream_and_blas_for(ordinal)?;
571        // Host-transpose-free path. The row-major output buffer of
572        // `C = op(A)·op(B)` (shape m×n) is bit-identical to the column-major
573        // buffer of `Cᵀ = op(B)ᵀ·op(A)ᵀ` (shape n×m). A row-major buffer,
574        // reinterpreted column-major, is already the transpose of its logical
575        // matrix — so uploading `a`/`b` row-major (a borrow when C-contiguous)
576        // gives cuBLAS `Aᵀ`/`Bᵀ` for free, and downloading straight into a
577        // row-major `Array2` skips the result permutation too. This removes the
578        // two O(rows·cols) scalar `to_col_major`/`from_col_major` passes that
579        // dominated tall-skinny GEMMs (e.g. the 200000×200 Wahba design
580        // reduction) and made the device path slower than the host SIMD GEMM.
581        //
582        // Uploading the row-major buffer of an `(r×c)` array and declaring it
583        // column-major with leading dim `c` hands cuBLAS exactly that array's
584        // transpose (a `(c×r)` col-major matrix). So with
585        //   X = b's row-major buffer  → col-major X = bᵀ  (rows = b_cols),
586        //   Y = a's row-major buffer  → col-major Y = aᵀ  (rows = a_cols),
587        // cuBLAS's `out = opX(X)·opY(Y)` yields `Cᵀ` when
588        //   opX = trans_b ? T : N,   opY = trans_a ? T : N,
589        //   (M,N,K) = (n, m, k),   lda = b_cols, ldb = a_cols, ldc = n.
590        // The column-major `Cᵀ` buffer (n rows) is bit-identical to the
591        // row-major `C` buffer (m×n), so the download wraps with no permute.
592        let b_rm = to_row_major(&b);
593        let a_rm = to_row_major(&a);
594        let x_dev = stream.clone_htod(&*b_rm).ok()?;
595        let y_dev = stream.clone_htod(&*a_rm).ok()?;
596        let mut out_dev = stream.alloc_zeros::<f64>(m.checked_mul(n)?).ok()?;
597        let cfg = GemmConfig::<f64> {
598            transa: if trans_b {
599                cublasOperation_t::CUBLAS_OP_T
600            } else {
601                cublasOperation_t::CUBLAS_OP_N
602            },
603            transb: if trans_a {
604                cublasOperation_t::CUBLAS_OP_T
605            } else {
606                cublasOperation_t::CUBLAS_OP_N
607            },
608            m: to_i32(n)?,
609            n: to_i32(m)?,
610            k: to_i32(k_a)?,
611            alpha: 1.0,
612            // Leading dim of each physically-stored col-major operand =
613            // its row count: B̌ has `b_cols` rows, Ǎ has `a_cols` rows.
614            lda: to_i32(b_cols)?,
615            ldb: to_i32(a_cols)?,
616            beta: 0.0,
617            ldc: to_i32(n)?,
618        };
619        // SAFETY: dims validated above; buffers carry exactly the row counts
620        // declared as leading dimensions.
621        unsafe { blas.gemm(cfg, &x_dev, &y_dev, &mut out_dev) }.ok()?;
622        // `out_dev` is `Cᵀ` column-major == `C` row-major: wrap with no permute.
623        let out_rm = stream.clone_dtoh(&out_dev).ok()?;
624        array_from_row_major(out_rm, m, n)
625    }
626
627    /// Broadcast-B batched GEMM on a specific device ordinal. The caller
628    /// (`super::super::pool::scatter_batched` worker, or the single-device
629    /// dispatcher) supplies the ordinal whose context is already bound on this
630    /// thread; the stream/handle are created on that same device.
631    #[inline]
632    pub(crate) fn gemm_broadcast_b_batched_cuda(
633        ordinal: usize,
634        a: ArrayView3<'_, f64>,
635        b: ArrayView2<'_, f64>,
636    ) -> Option<Array3<f64>> {
637        let (batch, m, k) = a.dim();
638        let (b_rows, n) = b.dim();
639        if batch == 0 || m == 0 || n == 0 || k == 0 || b_rows != k {
640            return None;
641        }
642        let (stream, blas) = stream_and_blas_for(ordinal)?;
643        let a_col = to_col_major_batch(a);
644        let b_col = to_col_major(&b);
645        let a_dev = stream.clone_htod(&a_col).ok()?;
646        let b_dev = stream.clone_htod(&*b_col).ok()?;
647        let mut out_dev = stream
648            .alloc_zeros::<f64>(batch.checked_mul(m)?.checked_mul(n)?)
649            .ok()?;
650        let cfg = StridedBatchedConfig::<f64> {
651            gemm: GemmConfig::<f64> {
652                transa: cublasOperation_t::CUBLAS_OP_N,
653                transb: cublasOperation_t::CUBLAS_OP_N,
654                m: to_i32(m)?,
655                n: to_i32(n)?,
656                k: to_i32(k)?,
657                alpha: 1.0,
658                lda: to_i32(m)?,
659                ldb: to_i32(k)?,
660                beta: 0.0,
661                ldc: to_i32(m)?,
662            },
663            batch_size: to_i32(batch)?,
664            stride_a: i64::try_from(m.checked_mul(k)?).ok()?,
665            stride_b: 0,
666            stride_c: i64::try_from(m.checked_mul(n)?).ok()?,
667        };
668        // SAFETY: `a_dev` is a stack of batch column-major m×k matrices,
669        // `b_dev` is one shared column-major k×n matrix with zero batch stride,
670        // and `out_dev` is a stack of batch column-major m×n outputs.
671        unsafe { blas.gemm_strided_batched(cfg, &a_dev, &b_dev, &mut out_dev) }.ok()?;
672        let out_col = stream.clone_dtoh(&out_dev).ok()?;
673        from_col_major_batch(&out_col, batch, m, n)
674    }
675
676    /// A·Bᵀ strided-batched GEMM on a specific device ordinal. As with the
677    /// broadcast variant, the ordinal's context is expected to be bound on the
678    /// calling thread (multi-GPU worker or single-device dispatcher).
679    #[inline]
680    pub(crate) fn gemm_abt_strided_batched_cuda(
681        ordinal: usize,
682        a: ArrayView3<'_, f64>,
683        b: ArrayView3<'_, f64>,
684    ) -> Option<Array3<f64>> {
685        let (batch, m, k) = a.dim();
686        let (batch_b, n, k_b) = b.dim();
687        if batch == 0 || m == 0 || n == 0 || k == 0 || batch != batch_b || k != k_b {
688            return None;
689        }
690        let (stream, blas) = stream_and_blas_for(ordinal)?;
691        let a_col = to_col_major_batch(a);
692        let b_col = to_col_major_batch(b);
693        let a_dev = stream.clone_htod(&a_col).ok()?;
694        let b_dev = stream.clone_htod(&b_col).ok()?;
695        let mut out_dev = stream
696            .alloc_zeros::<f64>(batch.checked_mul(m)?.checked_mul(n)?)
697            .ok()?;
698        let cfg = StridedBatchedConfig::<f64> {
699            gemm: GemmConfig::<f64> {
700                transa: cublasOperation_t::CUBLAS_OP_N,
701                transb: cublasOperation_t::CUBLAS_OP_T,
702                m: to_i32(m)?,
703                n: to_i32(n)?,
704                k: to_i32(k)?,
705                alpha: 1.0,
706                lda: to_i32(m)?,
707                ldb: to_i32(n)?,
708                beta: 0.0,
709                ldc: to_i32(m)?,
710            },
711            batch_size: to_i32(batch)?,
712            stride_a: i64::try_from(m.checked_mul(k)?).ok()?,
713            stride_b: i64::try_from(n.checked_mul(k)?).ok()?,
714            stride_c: i64::try_from(m.checked_mul(n)?).ok()?,
715        };
716        // SAFETY: each batch item is column-major. The B batch stores n×k
717        // matrices and cuBLAS transposes each to k×n before multiplication.
718        unsafe { blas.gemm_strided_batched(cfg, &a_dev, &b_dev, &mut out_dev) }.ok()?;
719        let out_col = stream.clone_dtoh(&out_dev).ok()?;
720        from_col_major_batch(&out_col, batch, m, n)
721    }
722
723    #[inline]
724    pub(crate) fn gemv_cuda(
725        runtime: &GpuRuntime,
726        a: ArrayView2<'_, f64>,
727        v: ArrayView1<'_, f64>,
728        trans_a: bool,
729    ) -> Option<Array1<f64>> {
730        let (rows, cols) = a.dim();
731        let out_len = if trans_a { cols } else { rows };
732        let needed = if trans_a { rows } else { cols };
733        if out_len == 0 || needed == 0 || v.len() != needed {
734            return None;
735        }
736        let (stream, blas) = stream_and_blas(runtime)?;
737        let a_col = to_col_major(&a);
738        let a_dev = stream.clone_htod(&*a_col).ok()?;
739        let v_host = vector_values(v);
740        let v_dev = stream.clone_htod(&v_host).ok()?;
741        let mut out_dev = stream.alloc_zeros::<f64>(out_len).ok()?;
742        let cfg = GemvConfig::<f64> {
743            trans: if trans_a {
744                cublasOperation_t::CUBLAS_OP_T
745            } else {
746                cublasOperation_t::CUBLAS_OP_N
747            },
748            m: to_i32(rows)?,
749            n: to_i32(cols)?,
750            alpha: 1.0,
751            lda: to_i32(rows)?,
752            incx: 1,
753            beta: 0.0,
754            incy: 1,
755        };
756        // SAFETY: dimensions and vector length match the cuBLAS GEMV contract.
757        unsafe { blas.gemv(cfg, &a_dev, &v_dev, &mut out_dev) }.ok()?;
758        Some(Array1::from_vec(stream.clone_dtoh(&out_dev).ok()?))
759    }
760
761    #[inline]
762    pub fn xt_diag_x_cuda(
763        runtime: &GpuRuntime,
764        x: ArrayView2<'_, f64>,
765        w: ArrayView1<'_, f64>,
766    ) -> Option<Array2<f64>> {
767        let (rows, cols) = x.dim();
768        if rows == 0 || cols == 0 || rows != w.len() {
769            return None;
770        }
771        weighted_crossprod(runtime, x, w, x)
772    }
773
774    #[inline]
775    pub(crate) fn xt_diag_x_on_ordinal_cuda(
776        ordinal: usize,
777        x: ArrayView2<'_, f64>,
778        w: ArrayView1<'_, f64>,
779    ) -> Option<Array2<f64>> {
780        let (rows, cols) = x.dim();
781        if rows == 0 || cols == 0 || rows != w.len() {
782            return None;
783        }
784        weighted_crossprod_for(ordinal, x, w, x)
785    }
786
787    #[inline]
788    pub fn xt_diag_y_cuda(
789        runtime: &GpuRuntime,
790        x: ArrayView2<'_, f64>,
791        w: ArrayView1<'_, f64>,
792        y: ArrayView2<'_, f64>,
793    ) -> Option<Array2<f64>> {
794        weighted_crossprod(runtime, x, w, y)
795    }
796
797    #[inline]
798    pub(crate) fn joint_hessian_2x2_cuda(
799        runtime: &GpuRuntime,
800        x_a: ArrayView2<'_, f64>,
801        x_b: ArrayView2<'_, f64>,
802        w_aa: ArrayView1<'_, f64>,
803        w_ab: ArrayView1<'_, f64>,
804        w_bb: ArrayView1<'_, f64>,
805    ) -> Option<Array2<f64>> {
806        let (rows, pa) = x_a.dim();
807        let (rows_b, pb) = x_b.dim();
808        let total = pa.checked_add(pb)?;
809        if rows == 0
810            || total == 0
811            || rows != rows_b
812            || rows != w_aa.len()
813            || rows != w_ab.len()
814            || rows != w_bb.len()
815        {
816            return None;
817        }
818
819        let mut out = Array2::<f64>::zeros((total, total));
820        if pa > 0 {
821            let aa = weighted_crossprod(runtime, x_a, w_aa, x_a)?;
822            assign_block(&mut out, 0, 0, &aa);
823        }
824        if pa > 0 && pb > 0 {
825            let ab = weighted_crossprod(runtime, x_a, w_ab, x_b)?;
826            assign_block(&mut out, 0, pa, &ab);
827        }
828        if pb > 0 {
829            let bb = weighted_crossprod(runtime, x_b, w_bb, x_b)?;
830            assign_block(&mut out, pa, pa, &bb);
831        }
832        mirror_upper_to_lower(&mut out);
833        Some(out)
834    }
835
836    #[inline]
837    pub(crate) fn trsm_cuda(
838        runtime: &GpuRuntime,
839        triangular: ArrayView2<'_, f64>,
840        rhs: ArrayView2<'_, f64>,
841        upper: bool,
842    ) -> Option<Array2<f64>> {
843        let (n, n2) = triangular.dim();
844        if n == 0 || n != n2 || rhs.nrows() != n {
845            return None;
846        }
847        let nrhs = rhs.ncols();
848        let (stream, blas) = stream_and_blas(runtime)?;
849        let tri_col = to_col_major(&triangular);
850        let rhs_col = to_col_major(&rhs);
851        let tri_dev = stream.clone_htod(&*tri_col).ok()?;
852        let mut rhs_dev = stream.clone_htod(&*rhs_col).ok()?;
853        let alpha = 1.0_f64;
854        let handle = *blas.handle();
855        {
856            let (tri_ptr, _tri_record) = tri_dev.device_ptr(&stream);
857            let (rhs_ptr, _rhs_record) = rhs_dev.device_ptr_mut(&stream);
858            // SAFETY: triangular is n×n and rhs is n×nrhs in column-major device
859            // buffers. cublasDtrsm overwrites rhs with A^{-1} rhs.
860            let status = unsafe {
861                cudarc::cublas::sys::cublasDtrsm_v2(
862                    handle,
863                    cublasSideMode_t::CUBLAS_SIDE_LEFT,
864                    if upper {
865                        cublasFillMode_t::CUBLAS_FILL_MODE_UPPER
866                    } else {
867                        cublasFillMode_t::CUBLAS_FILL_MODE_LOWER
868                    },
869                    cublasOperation_t::CUBLAS_OP_N,
870                    cublasDiagType_t::CUBLAS_DIAG_NON_UNIT,
871                    to_i32(n)?,
872                    to_i32(nrhs)?,
873                    &alpha,
874                    tri_ptr as *const f64,
875                    to_i32(n)?,
876                    rhs_ptr as *mut f64,
877                    to_i32(n)?,
878                )
879            };
880            if status != cublasStatus_t::CUBLAS_STATUS_SUCCESS {
881                return None;
882            }
883        };
884        let out_col = stream.clone_dtoh(&rhs_dev).ok()?;
885        from_col_major(&out_col, n, nrhs)
886    }
887}
888
889#[cfg(target_os = "linux")]
890pub(crate) use cuda_impl::{
891    ResidentWeightedGram, gemm_abt_strided_batched_cuda, gemm_broadcast_b_batched_cuda, gemm_cuda,
892    gemm_on_ordinal_cuda, gemv_cuda, joint_hessian_2x2_cuda, trsm_cuda, xt_diag_x_on_ordinal_cuda,
893};
894// Cross-crate cuBLAS entry points (gam-models BMS Hessian paths call these
895// directly): the #1521 carve promoted the sibling solver/GEMM entry points to
896// `pub` but left these two `pub(crate)`, so they were invisible to their
897// out-of-crate callers (E0603) on the linux cuda build that the workspace
898// `cargo check` config does not exercise. Promote to match the rest of the
899// cross-crate cuBLAS surface.
900#[cfg(target_os = "linux")]
901pub use cuda_impl::{xt_diag_x_cuda, xt_diag_y_cuda};