Skip to main content

gam_gpu/
linalg_dispatch.rs

1//! Automatic GPU dispatch shim for dense linear algebra hot kernels.
2//!
3//! Every `try_*` entry point in this module is invoked unconditionally from
4//! `gam_linalg::faer_ndarray` before the CPU fast-path runs. The decision to send
5//! the kernel to a device is fully automatic and never requires a user-facing
6//! flag — it depends only on:
7//!
8//!   1. Lossless runtime resolution returning an available device.
9//!   2. The kernel being large enough to amortize launch/PCIe overhead, per
10//!      the thresholds in `policy::GpuDispatchPolicy`.
11//!   3. cudarc successfully dynamically loading `libcuda` at process startup
12//!      via its `fallback-dynamic-loading` feature. When the loader fails
13//!      (no driver, no toolkit installed), Auto receives typed absence and
14//!      every `try_*` returns `None` so the caller falls through to the
15//!      existing faer CPU kernel. Probe faults and Required absence fail
16//!      loudly instead of being reclassified as an optional decline.
17//!
18//! The wiring lives here so `solver/pirls.rs` and the family Hessian
19//! assemblers can stay backend-agnostic: they call `gam_linalg::faer_ndarray::fast_*`
20//! and get GPU acceleration automatically whenever it is profitable.
21
22use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3};
23
24use super::device_runtime::GpuRuntime;
25use super::policy::GpuDispatchPolicy;
26use super::GpuPolicy;
27
28pub struct CudaGemmDispatch;
29
30impl gam_linalg::gpu_hook::GpuGemmDispatch for CudaGemmDispatch {
31    fn try_fast_atb(&self, a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Option<Array2<f64>> {
32        try_fast_atb(a, b)
33    }
34
35    fn try_fast_ab(&self, a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Option<Array2<f64>> {
36        try_fast_ab(a, b)
37    }
38
39    fn try_fast_av(&self, a: ArrayView2<'_, f64>, v: ArrayView1<'_, f64>) -> Option<Array1<f64>> {
40        try_fast_av(a, v)
41    }
42
43    fn try_fast_atv(&self, a: ArrayView2<'_, f64>, v: ArrayView1<'_, f64>) -> Option<Array1<f64>> {
44        try_fast_atv(a, v)
45    }
46
47    fn try_fast_xt_diag_x(
48        &self,
49        x: ArrayView2<'_, f64>,
50        w: ArrayView1<'_, f64>,
51    ) -> Option<Array2<f64>> {
52        try_fast_xt_diag_x(x, w)
53    }
54
55    fn try_fast_xt_diag_y(
56        &self,
57        x: ArrayView2<'_, f64>,
58        w: ArrayView1<'_, f64>,
59        y: ArrayView2<'_, f64>,
60    ) -> Option<Array2<f64>> {
61        try_fast_xt_diag_y(x, w, y)
62    }
63
64    fn try_fast_joint_hessian_2x2(
65        &self,
66        x_a: ArrayView2<'_, f64>,
67        x_b: ArrayView2<'_, f64>,
68        w_aa: ArrayView1<'_, f64>,
69        w_ab: ArrayView1<'_, f64>,
70        w_bb: ArrayView1<'_, f64>,
71    ) -> Option<Array2<f64>> {
72        try_fast_joint_hessian_2x2(x_a, x_b, w_aa, w_ab, w_bb)
73    }
74
75    fn device_count(&self) -> usize {
76        let policy = super::global_policy();
77        runtime_for_dispatch(policy).map_or(0, GpuRuntime::device_count)
78    }
79
80    fn try_fast_ab_broadcast_b_batched(
81        &self,
82        a3: ArrayView3<'_, f64>,
83        b: ArrayView2<'_, f64>,
84    ) -> Option<Array3<f64>> {
85        try_fast_ab_broadcast_b_batched(a3, b)
86    }
87}
88
89/// Discriminator used by [`route_through_gpu`] to apply the right
90/// size threshold from [`super::policy::GpuDispatchPolicy`].
91#[derive(Clone, Copy, Debug)]
92pub enum DispatchOp {
93    /// Generic matrix-matrix product with the given output dims and reduction depth.
94    Gemm { m: usize, n: usize, k: usize },
95    /// Batch of independent matrix-matrix products.
96    BatchedGemm {
97        batch: usize,
98        m: usize,
99        n: usize,
100        k: usize,
101    },
102    /// Dense Cholesky factorization.
103    Potrf { p: usize, batch: usize },
104    /// Batched small-dense Cholesky factorization where each block has the
105    /// same small width `p` (≲ 32) but the batch is large. Routed through
106    /// `cusolverDnDpotrfBatched` and kept device-resident for downstream
107    /// triangular solves (Arrow-Schur, Stage-3 PIRLS).
108    SmallDenseBatchedPotrf { p: usize, batch: usize },
109    /// Triangular matrix solve.
110    Trsm { m: usize, n: usize },
111    /// Matrix-vector (or matrix · single-column) product.
112    Gemv { m: usize, k: usize },
113    /// `Xᵀ · diag(w) · X` reduction with n rows and p columns.
114    XtDiagX { n: usize, p: usize },
115    /// `Xᵀ · diag(w) · Y` reduction; px and q are the design and response widths.
116    XtDiagY { n: usize, px: usize, q: usize },
117    /// 2×2 joint Hessian block with two design widths.
118    JointHessian2x2 { n: usize, pa: usize, pb: usize },
119}
120
121/// Resolve the runtime at the infallible `gam-linalg` optimization-hook
122/// boundary. Typed Auto/Off absence declines the optional acceleration;
123/// faults and Required absence cannot be represented by the hook's `Option`
124/// return type, so they fail loudly rather than silently executing a CPU path.
125#[inline]
126fn runtime_for_dispatch(policy: GpuPolicy) -> Option<&'static GpuRuntime> {
127    GpuRuntime::resolve(policy).unwrap_or_else(|error| {
128        // SAFETY: the gam-linalg optimization hook is an infallible `Option`
129        // boundary. `None` means "run on the CPU", so returning it for a probe
130        // fault or Required-device absence would silently change semantics.
131        panic!(
132            "GPU runtime resolution failed under policy '{}': {error}",
133            policy
134        )
135    })
136}
137
138/// Preserve the semantic difference between an optional Auto acceleration and
139/// a user-mandated device execution at this module's legacy `Option` boundary.
140/// `gam-linalg` uses `None` to continue on the CPU, so returning it under
141/// `gpu=required` would silently violate the configured execution contract.
142#[inline]
143#[track_caller]
144fn decline_gpu<T>(operation: &'static str, reason: &'static str) -> Option<T> {
145    if super::global_policy() == GpuPolicy::Required {
146        // SAFETY: this legacy `Option` hook has no typed error channel and
147        // `None` explicitly authorizes CPU execution, which Required forbids.
148        panic!("gpu=required operation '{operation}' cannot execute on the GPU: {reason}");
149    }
150    None
151}
152
153/// Invalid inputs are caller faults, not evidence that an optional accelerator
154/// is absent or unprofitable. They must fail independently of GPU policy so an
155/// `Option` hook cannot hide them by continuing in a different implementation.
156#[inline]
157#[track_caller]
158fn invalid_gpu_request(operation: &'static str, reason: &'static str) -> ! {
159    // SAFETY: invalid dimensions are caller-contract violations, not an
160    // optional accelerator decline that may be represented by `None`.
161    panic!("GPU operation '{operation}' received invalid input: {reason}");
162}
163
164/// Policy-explicit variant of [`decline_gpu`] for the `_with_policy` entry
165/// points on platforms without the CUDA backend: the CALLER's policy — not the
166/// process-global one — owns the Required contract at that boundary, so a
167/// caller-passed `Required` must fail loudly here instead of silently falling
168/// back to CPU while `decline_gpu` consults an unrelated global. Only compiled
169/// off-Linux because every Linux path consumes the policy through
170/// `route_through_gpu_with_policy` before any decline.
171#[cfg(not(target_os = "linux"))]
172#[inline]
173#[track_caller]
174fn decline_gpu_with_policy<T>(
175    operation: &'static str,
176    reason: &'static str,
177    gpu_policy: GpuPolicy,
178) -> Option<T> {
179    if gpu_policy == GpuPolicy::Required {
180        // SAFETY: this legacy `Option` hook has no typed error channel and
181        // `None` explicitly authorizes CPU execution, which Required forbids.
182        panic!("gpu=required operation '{operation}' cannot execute on the GPU: {reason}");
183    }
184    None
185}
186
187/// A malformed device result is an execution fault, never an Auto decline.
188#[cfg(target_os = "linux")]
189#[inline]
190#[track_caller]
191fn invalid_gpu_result(operation: &'static str, reason: &'static str) -> ! {
192    // SAFETY: a malformed result after device execution cannot be represented
193    // by this hook's `Option` without authorizing a misleading CPU retry.
194    panic!("GPU operation '{operation}' produced invalid output: {reason}");
195}
196
197/// Complete a CUDA attempt after policy admission. Backend `Option` APIs use
198/// `None` for an execution failure, but absence and profitability were already
199/// settled before the attempt. A post-admission `None` is therefore a fault
200/// under every policy and must not be laundered into a CPU fallback.
201#[cfg(target_os = "linux")]
202#[inline]
203#[track_caller]
204fn complete_gpu_attempt<T>(operation: &'static str, result: Option<T>) -> T {
205    match result {
206        Some(value) => value,
207        // SAFETY: runtime availability and policy admission already succeeded;
208        // backend `None` is an execution fault, while this hook's `None` means
209        // an ordinary pre-admission decline and would silently retry on CPU.
210        None => panic!(
211            "GPU operation '{operation}' failed after admission under policy '{}'",
212            super::global_policy()
213        ),
214    }
215}
216
217impl DispatchOp {
218    /// Conservative flop estimate used for the generic `gemm_min_flops` gate.
219    #[inline]
220    pub const fn flops(self) -> u128 {
221        match self {
222            Self::Gemm { m, n, k } => 2u128 * (m as u128) * (n as u128) * (k as u128),
223            Self::BatchedGemm { batch, m, n, k } => {
224                2u128 * (batch as u128) * (m as u128) * (n as u128) * (k as u128)
225            }
226            Self::Gemv { m, k } => 2u128 * (m as u128) * (k as u128),
227            Self::Potrf { p, batch } => (batch as u128) * (p as u128).pow(3) / 3,
228            Self::SmallDenseBatchedPotrf { p, batch } => (batch as u128) * (p as u128).pow(3) / 3,
229            Self::Trsm { m, n } => (m as u128) * (m as u128) * (n as u128),
230            Self::XtDiagX { n, p } => 2u128 * (n as u128) * (p as u128) * (p as u128),
231            Self::XtDiagY { n, px, q } => 2u128 * (n as u128) * (px as u128) * (q as u128),
232            Self::JointHessian2x2 { n, pa, pb } => {
233                let total = (pa as u128) + (pb as u128);
234                2u128 * (n as u128) * total * total
235            }
236        }
237    }
238
239    /// True when SOME reachable Auto dispatch policy could admit this op.
240    ///
241    /// Pre-probe Auto size gate (the CUDA startup-tax ordering fix): evaluated with
242    /// the MOST PERMISSIVE values any production policy can carry — the
243    /// calibration crossover floors ([`GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS`],
244    /// [`GpuDispatchPolicy::MIN_CALIBRATABLE_POTRF_P`]) for the calibrated
245    /// fields, and the [`GpuDispatchPolicy::default`] values for the
246    /// small-dense-batched-POTRF fields, which `calibration::calibrate_device`
247    /// never adjusts. A `false` here means EVERY reachable policy's
248    /// [`route_through_gpu`] admission would also refuse, so the caller may
249    /// return to the CPU path WITHOUT resolving GPU availability — i.e.
250    /// without triggering the device probe and its per-GPU
251    /// `cuDevicePrimaryCtxRetain` context creation. A `true` decides nothing:
252    /// the probed runtime's real policy still gates the op exactly as before.
253    #[must_use]
254    pub fn admissible_under_any_policy(self) -> bool {
255        let seed = GpuDispatchPolicy::default();
256        let min_gemm = GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS;
257        match self {
258            Self::Gemm { m, n, k } => self.flops() >= min_gemm && m.min(n).min(k) > 0,
259            Self::BatchedGemm { batch, m, n, k } => {
260                self.flops() >= min_gemm && batch > 1 && m.min(n).min(k) > 0
261            }
262            Self::Gemv { m, k } => self.flops() >= min_gemm && m > 0 && k > 0,
263            Self::Potrf { p, batch } => {
264                p > 0
265                    && batch > 0
266                    && (p >= GpuDispatchPolicy::MIN_CALIBRATABLE_POTRF_P
267                        || (batch > 1 && self.flops() >= min_gemm))
268            }
269            // These two policy fields are never calibrated, so the default seed
270            // values ARE the runtime values and this arm is exact, not a bound.
271            Self::SmallDenseBatchedPotrf { p, batch } => {
272                p > 0
273                    && p <= seed.small_dense_batched_potrf_max_p
274                    && batch >= seed.small_dense_batched_potrf_min_batch
275            }
276            Self::Trsm { m, n } => self.flops() >= min_gemm && m > 0 && n > 0,
277            // XtDiagX/XtDiagY admit on `dense_reduction_flops_min` =
278            // min(xtwx_flops_min, gemm_min_flops); the smallest reachable value
279            // of that min is MIN_CALIBRATABLE_GEMM_FLOPS (the xtwx crossover
280            // cannot calibrate below its smallest measurement, which exceeds it).
281            Self::XtDiagX { n, p } => n > 0 && p > 0 && self.flops() >= min_gemm,
282            Self::XtDiagY { n, px, q } => n > 0 && px > 0 && q > 0 && self.flops() >= min_gemm,
283            Self::JointHessian2x2 { n, pa, pb } => {
284                n > 0 && (pa > 0 || pb > 0) && self.flops() >= min_gemm
285            }
286        }
287    }
288}
289
290/// Returns `Some(runtime)` when a device is available and policy admits the
291/// operation. Auto applies calibrated profitability thresholds; Required
292/// deliberately bypasses those thresholds because a CPU continuation would
293/// violate the requested execution policy.
294#[inline]
295#[must_use]
296pub fn route_through_gpu(op: DispatchOp) -> Option<&'static GpuRuntime> {
297    route_through_gpu_with_policy(op, super::global_policy())
298}
299
300/// Per-request counterpart of [`route_through_gpu`]. This is the device seam
301/// for solvers whose policy is part of an immutable fit request rather than the
302/// legacy process-wide configuration.
303#[inline]
304#[must_use]
305pub fn route_through_gpu_with_policy(
306    op: DispatchOp,
307    selected_policy: GpuPolicy,
308) -> Option<&'static GpuRuntime> {
309    // Size gate BEFORE the device probe (startup-tax ordering fix): an op no
310    // reachable Auto policy could admit must not resolve availability — the first
311    // call creates a CUDA primary context on every GPU. Ops that
312    // clear this most-permissive bound fall through to the probed runtime's
313    // real policy admission below, bit-for-bit as before. Required bypasses
314    // this profitability-only gate and resolves the mandatory runtime.
315    if selected_policy != GpuPolicy::Required && !op.admissible_under_any_policy() {
316        return None;
317    }
318    let runtime = runtime_for_dispatch(selected_policy)?;
319    if selected_policy == GpuPolicy::Required {
320        return Some(runtime);
321    }
322    let policy = &runtime.policy;
323    let admit = match op {
324        DispatchOp::Gemm { m, n, k } => {
325            op.flops() >= (policy.gemm_min_flops as u128) && m.min(n).min(k) > 0
326        }
327        DispatchOp::BatchedGemm { batch, m, n, k } => {
328            op.flops() >= (policy.gemm_min_flops as u128) && batch > 1 && m.min(n).min(k) > 0
329        }
330        DispatchOp::Gemv { m, k } => {
331            op.flops() >= (policy.gemm_min_flops as u128) && m > 0 && k > 0
332        }
333        DispatchOp::Potrf { p, batch } => {
334            p > 0
335                && batch > 0
336                && (p >= policy.potrf_min_p
337                    || (batch > 1 && op.flops() >= policy.gemm_min_flops as u128))
338        }
339        DispatchOp::SmallDenseBatchedPotrf { p, batch } => {
340            p > 0
341                && p <= policy.small_dense_batched_potrf_max_p
342                && batch >= policy.small_dense_batched_potrf_min_batch
343        }
344        DispatchOp::Trsm { m, n } => {
345            op.flops() >= (policy.gemm_min_flops as u128) && m > 0 && n > 0
346        }
347        DispatchOp::XtDiagX { n, p } => policy.xtwx_target_is_gpu(n, p, true),
348        DispatchOp::XtDiagY { n, px, q } => policy.xtwy_target_is_gpu(n, px, q, true),
349        DispatchOp::JointHessian2x2 { n, pa, pb } => {
350            n > 0 && (pa > 0 || pb > 0) && op.flops() >= policy.gemm_min_flops as u128
351        }
352    };
353    if admit { Some(runtime) } else { None }
354}
355
356/// Minimum batch size before a batched kernel is worth splitting across more
357/// than one device. Below this the per-tile launch + extra H2D/D2H staging on a
358/// second device costs more than the GEMM time it saves, so a small batch stays
359/// on the single primary device. This is a fixed, conservatively-large constant
360/// (magic-by-default; no flag) — multi-GPU only kicks in for genuinely large
361/// batches such as large-scale Arrow-Schur / Stage-3 blocks.
362#[cfg(target_os = "linux")]
363const MULTI_GPU_BATCH_FLOOR: usize = 64;
364
365/// True when the pool has >1 usable device and `batch` is large enough that
366/// splitting the batch dimension across devices is worthwhile.
367#[cfg(target_os = "linux")]
368#[inline]
369fn should_split_batch(batch: usize) -> bool {
370    let policy = super::global_policy();
371    runtime_for_dispatch(policy).is_some_and(|rt| rt.device_count() > 1)
372        && batch >= MULTI_GPU_BATCH_FLOOR
373}
374
375#[inline]
376#[must_use]
377pub fn try_fast_ab_broadcast_b_batched(
378    a: ArrayView3<'_, f64>,
379    b: ArrayView2<'_, f64>,
380) -> Option<Array3<f64>> {
381    let (batch, m, k) = a.dim();
382    let (bk, n) = b.dim();
383    if k != bk {
384        invalid_gpu_request("batched A·B", "the reduction dimensions differ");
385    }
386    if batch == 0 || m == 0 || n == 0 || k == 0 {
387        return decline_gpu(
388            "batched A·B",
389            "the workload has an empty dimension",
390        );
391    }
392    #[cfg(not(target_os = "linux"))]
393    {
394        return decline_gpu("batched A·B", "the CUDA backend is not compiled on this platform");
395    }
396    #[cfg(target_os = "linux")]
397    {
398        let runtime = route_through_gpu(DispatchOp::BatchedGemm { batch, m, n, k })?;
399        if should_split_batch(batch) {
400            if let Some(out) = scatter_broadcast_b_batched(runtime, a, b, m, n) {
401                return Some(out);
402            }
403            // A multi-GPU tile failed; fall through to the single-device path so
404            // the whole batch is still produced on the primary device.
405        }
406        Some(complete_gpu_attempt(
407            "batched A·B",
408            cuda_backend::gemm_broadcast_b_batched(runtime.device.ordinal, a, b),
409        ))
410    }
411}
412
413/// Multi-GPU broadcast-B batched GEMM: split the batch dimension across all
414/// devices via [`scatter_batched`], running one cuBLAS strided-batched GEMM per
415/// device tile (each on its own bound ordinal). `b` is shared (broadcast) across
416/// every tile. Returns `None` if any tile fails so the caller falls back to the
417/// single-device path.
418#[cfg(target_os = "linux")]
419fn scatter_broadcast_b_batched(
420    runtime: &GpuRuntime,
421    a: ArrayView3<'_, f64>,
422    b: ArrayView2<'_, f64>,
423    m: usize,
424    n: usize,
425) -> Option<Array3<f64>> {
426    let batch = a.dim().0;
427    // One slot per batch item; the slot carries its own input matrix so the
428    // per-tile closure is range-agnostic and owns disjoint memory.
429    let mut items: Vec<(Array2<f64>, Option<Array2<f64>>)> = (0..batch)
430        .map(|i| (a.index_axis(ndarray::Axis(0), i).to_owned(), None))
431        .collect();
432    super::pool::scatter_batched(runtime, &mut items, |ordinal, tile| {
433        let tile_batch = tile.len();
434        if tile_batch == 0 {
435            return Some(());
436        }
437        let k = b.dim().0;
438        let mut a_tile = Array3::<f64>::zeros((tile_batch, m, k));
439        for (idx, (a_i, _)) in tile.iter().enumerate() {
440            a_tile.index_axis_mut(ndarray::Axis(0), idx).assign(a_i);
441        }
442        let out = cuda_backend::gemm_broadcast_b_batched(ordinal, a_tile.view(), b)?;
443        for (idx, (_, slot)) in tile.iter_mut().enumerate() {
444            *slot = Some(out.index_axis(ndarray::Axis(0), idx).to_owned());
445        }
446        Some(())
447    })?;
448    stitch_batched(items, m, n)
449}
450
451#[inline]
452#[must_use]
453pub fn try_fast_abt_strided_batched(
454    a: ArrayView3<'_, f64>,
455    b: ArrayView3<'_, f64>,
456) -> Option<Array3<f64>> {
457    try_fast_abt_strided_batched_with_policy(a, b, super::global_policy())
458}
459
460#[inline]
461#[must_use]
462pub fn try_fast_abt_strided_batched_with_policy(
463    a: ArrayView3<'_, f64>,
464    b: ArrayView3<'_, f64>,
465    gpu_policy: GpuPolicy,
466) -> Option<Array3<f64>> {
467    let (batch, m, k) = a.dim();
468    let (batch_b, n, k_b) = b.dim();
469    if batch != batch_b || k != k_b {
470        invalid_gpu_request("batched A·Bᵀ", "the batch or reduction dimensions differ");
471    }
472    if batch == 0 || m == 0 || n == 0 || k == 0 {
473        return decline_gpu(
474            "batched A·Bᵀ",
475            "the workload has an empty dimension",
476        );
477    }
478    #[cfg(not(target_os = "linux"))]
479    {
480        return decline_gpu_with_policy(
481            "batched A·Bᵀ",
482            "the CUDA backend is not compiled on this platform",
483            gpu_policy,
484        );
485    }
486    #[cfg(target_os = "linux")]
487    {
488        let runtime =
489            route_through_gpu_with_policy(DispatchOp::BatchedGemm { batch, m, n, k }, gpu_policy)?;
490        if should_split_batch(batch) {
491            if let Some(out) = scatter_abt_strided_batched(runtime, a, b, m, n) {
492                return Some(out);
493            }
494        }
495        Some(complete_gpu_attempt(
496            "batched A·Bᵀ",
497            cuda_backend::gemm_abt_strided_batched(runtime.device.ordinal, a, b),
498        ))
499    }
500}
501
502/// Multi-GPU A·Bᵀ strided-batched GEMM: split the batch dimension across all
503/// devices, running one strided-batched GEMM per device tile. Both `a` and `b`
504/// are batched (one matrix per batch item), so each slot carries its own
505/// `(a_i, b_i)` pair. Returns `None` on any tile failure.
506#[cfg(target_os = "linux")]
507fn scatter_abt_strided_batched(
508    runtime: &GpuRuntime,
509    a: ArrayView3<'_, f64>,
510    b: ArrayView3<'_, f64>,
511    m: usize,
512    n: usize,
513) -> Option<Array3<f64>> {
514    let batch = a.dim().0;
515    let mut items: Vec<(Array2<f64>, Array2<f64>, Option<Array2<f64>>)> = (0..batch)
516        .map(|i| {
517            (
518                a.index_axis(ndarray::Axis(0), i).to_owned(),
519                b.index_axis(ndarray::Axis(0), i).to_owned(),
520                None,
521            )
522        })
523        .collect();
524    super::pool::scatter_batched(runtime, &mut items, |ordinal, tile| {
525        let tile_batch = tile.len();
526        if tile_batch == 0 {
527            return Some(());
528        }
529        let k = tile[0].0.dim().1;
530        let mut a_tile = Array3::<f64>::zeros((tile_batch, m, k));
531        let mut b_tile = Array3::<f64>::zeros((tile_batch, n, k));
532        for (idx, (a_i, b_i, _)) in tile.iter().enumerate() {
533            a_tile.index_axis_mut(ndarray::Axis(0), idx).assign(a_i);
534            b_tile.index_axis_mut(ndarray::Axis(0), idx).assign(b_i);
535        }
536        let out = cuda_backend::gemm_abt_strided_batched(ordinal, a_tile.view(), b_tile.view())?;
537        for (idx, (_, _, slot)) in tile.iter_mut().enumerate() {
538            *slot = Some(out.index_axis(ndarray::Axis(0), idx).to_owned());
539        }
540        Some(())
541    })?;
542    let slots: Vec<((), Option<Array2<f64>>)> =
543        items.into_iter().map(|(_, _, slot)| ((), slot)).collect();
544    stitch_batched(slots, m, n)
545}
546
547/// Reassemble per-batch output slots (filled by the device tiles) into a single
548/// `batch × m × n` array. Returns `None` if any slot is still empty (a tile
549/// silently skipped its item), which forces the single-device fallback.
550#[cfg(target_os = "linux")]
551fn stitch_batched<L>(
552    items: Vec<(L, Option<Array2<f64>>)>,
553    m: usize,
554    n: usize,
555) -> Option<Array3<f64>> {
556    let batch = items.len();
557    let mut out = Array3::<f64>::zeros((batch, m, n));
558    for (idx, (_, slot)) in items.into_iter().enumerate() {
559        let block = slot?;
560        if block.dim() != (m, n) {
561            return None;
562        }
563        out.index_axis_mut(ndarray::Axis(0), idx).assign(&block);
564    }
565    Some(out)
566}
567
568// ---------------------------------------------------------------------------
569// Dispatch entry points. Each takes views to keep the call site allocation-
570// free and returns Some(result) iff the GPU actually produced one. Under Auto,
571// the CPU fast path resumes only on a pre-admission None (typed absence or an
572// unprofitable workload). Every post-admission failure is fatal at this legacy
573// Option boundary; Required additionally makes pre-admission declines fatal.
574//
575// CUDA kernels are compiled into the runtime through cudarc's dynamic loader.
576// Auto admits only profitable workloads. Required bypasses profitability gates
577// and fails if no CUDA runtime path is available. An admitted backend failure
578// is an execution fault under every policy and never falls through to the CPU.
579// ---------------------------------------------------------------------------
580
581#[inline]
582#[must_use]
583pub fn try_fast_ab(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Option<Array2<f64>> {
584    let (m, k) = a.dim();
585    let (kb, n) = b.dim();
586    if k != kb {
587        invalid_gpu_request("A·B", "the reduction dimensions differ");
588    }
589    if m == 0 || n == 0 || k == 0 {
590        return decline_gpu("A·B", "the workload has an empty dimension");
591    }
592    // Record every dispatch attempt — including ones that fall back to CPU
593    // because either the runtime is unavailable or the workload is below
594    // policy threshold. The diagnostics snapshot is what downstream telemetry
595    // uses to attribute CPU vs GPU time, so it must reflect *attempts*, not
596    // just successful device launches.
597    let runtime = route_through_gpu(DispatchOp::Gemm { m, n, k });
598    let used_gpu = runtime.is_some();
599    super::profile::record(super::profile::KernelStat {
600        name: "try_fast_ab",
601        n: m,
602        p: n,
603        k,
604        flops_est: (DispatchOp::Gemm { m, n, k }.flops().min(usize::MAX as u128)) as usize,
605        gpu_ms: if used_gpu { Some(0.0) } else { None },
606        ..Default::default()
607    });
608    #[cfg(not(target_os = "linux"))]
609    {
610        decline_gpu("A·B", "the CUDA backend is not compiled on this platform")
611    }
612    #[cfg(target_os = "linux")]
613    {
614        let runtime = runtime?;
615        Some(complete_gpu_attempt(
616            "A·B",
617            cuda_backend::gemm(runtime, a, b, false, false),
618        ))
619    }
620}
621
622#[inline]
623#[must_use]
624pub fn try_fast_atb(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Option<Array2<f64>> {
625    let (n_a, p) = a.dim();
626    let (n_b, q) = b.dim();
627    if n_a != n_b {
628        invalid_gpu_request("Aᵀ·B", "the row dimensions differ");
629    }
630    if n_a == 0 || p == 0 || q == 0 {
631        return decline_gpu("Aᵀ·B", "the workload has an empty dimension");
632    }
633    #[cfg(not(target_os = "linux"))]
634    {
635        return decline_gpu("Aᵀ·B", "the CUDA backend is not compiled on this platform");
636    }
637    #[cfg(target_os = "linux")]
638    {
639        let runtime = route_through_gpu(DispatchOp::Gemm { m: p, n: q, k: n_a })?;
640        Some(complete_gpu_attempt(
641            "Aᵀ·B",
642            cuda_backend::gemm(runtime, a, b, true, false),
643        ))
644    }
645}
646
647/// `Aᵀ·B` on a specific device ordinal, for pool-tiled callers that already own
648/// the ordinal (the worker thread has bound that ordinal's context). Semantics
649/// are identical to [`try_fast_atb`] — `a` is `m×k`, `b` is `m×n`, output is the
650/// `k×n` product `aᵀ·b` — but the kernel is pinned to `ordinal` instead of the
651/// probe-selected primary device. Auto returns `None` only when CUDA is absent
652/// or the shape is below policy threshold, so the caller can run its CPU path.
653/// A post-admission backend failure is fatal under every policy; Required also
654/// makes pre-admission absence fatal. f64 only.
655#[inline]
656#[must_use]
657pub fn try_fast_atb_on_ordinal(
658    ordinal: usize,
659    a: ArrayView2<'_, f64>,
660    b: ArrayView2<'_, f64>,
661) -> Option<Array2<f64>> {
662    let (n_a, p) = a.dim();
663    let (n_b, q) = b.dim();
664    if n_a != n_b {
665        invalid_gpu_request("ordinal-pinned Aᵀ·B", "the row dimensions differ");
666    }
667    if n_a == 0 || p == 0 || q == 0 {
668        return decline_gpu(
669            "ordinal-pinned Aᵀ·B",
670            "the workload has an empty dimension",
671        );
672    }
673    #[cfg(not(target_os = "linux"))]
674    {
675        // No CUDA off Linux, so the per-ordinal fast path is unavailable. Read
676        // `ordinal` once (the cross-platform signature must carry it for the
677        // Linux branch below) and decline so the caller runs its CPU AtB. Unlike
678        // `a`/`b` — already consumed by `.dim()` above — `ordinal` is otherwise
679        // untouched on this target, and `warnings = "deny"` rejects a dead bind.
680        log::trace!(
681            "try_fast_atb_on_ordinal: CUDA unavailable off Linux; declining ordinal {ordinal}"
682        );
683        return decline_gpu(
684            "ordinal-pinned Aᵀ·B",
685            "the CUDA backend is not compiled on this platform",
686        );
687    }
688    #[cfg(target_os = "linux")]
689    {
690        // The size/policy gate is identical to `try_fast_atb`; only the target
691        // device differs. We still consult `route_through_gpu` so a below-floor
692        // shape declines to the caller's CPU path rather than paying PCIe cost.
693        //
694        // Arrow-Schur's `tile_schur_partial` reaches this gate after stacking
695        // its per-row factors into one transpose tile GEMM:
696        // `(total_d x k)^T * (total_d x k)`.
697        // At the SAE shape n=2000, p=2048, M=12, K=8, that is
698        // 2*(n*M)*p^2 = 201_326_592_000 flops for one stacked tile, or
699        // 1_610_612_736_000 flops across K=8 batches, so admission must be
700        // keyed on work rather than the observation row count.
701        route_through_gpu(DispatchOp::Gemm { m: p, n: q, k: n_a })?;
702        Some(complete_gpu_attempt(
703            "ordinal-pinned Aᵀ·B",
704            cuda_backend::gemm_on_ordinal(ordinal, a, b, true, false),
705        ))
706    }
707}
708
709#[inline]
710#[must_use]
711pub fn try_fast_av(a: ArrayView2<'_, f64>, v: ArrayView1<'_, f64>) -> Option<Array1<f64>> {
712    let (m, k) = a.dim();
713    if k != v.len() {
714        invalid_gpu_request("A·v", "the matrix width and vector length differ");
715    }
716    if m == 0 || k == 0 {
717        return decline_gpu("A·v", "the workload has an empty dimension");
718    }
719    #[cfg(not(target_os = "linux"))]
720    {
721        return decline_gpu("A·v", "the CUDA backend is not compiled on this platform");
722    }
723    #[cfg(target_os = "linux")]
724    {
725        let runtime = route_through_gpu(DispatchOp::Gemv { m, k })?;
726        Some(complete_gpu_attempt(
727            "A·v",
728            cuda_backend::gemv(runtime, a, v, false),
729        ))
730    }
731}
732
733#[inline]
734#[must_use]
735pub fn try_fast_atv(a: ArrayView2<'_, f64>, v: ArrayView1<'_, f64>) -> Option<Array1<f64>> {
736    let (n, p) = a.dim();
737    if n != v.len() {
738        invalid_gpu_request("Aᵀ·v", "the matrix height and vector length differ");
739    }
740    if n == 0 || p == 0 {
741        return decline_gpu("Aᵀ·v", "the workload has an empty dimension");
742    }
743    #[cfg(not(target_os = "linux"))]
744    {
745        return decline_gpu("Aᵀ·v", "the CUDA backend is not compiled on this platform");
746    }
747    #[cfg(target_os = "linux")]
748    {
749        let runtime = route_through_gpu(DispatchOp::Gemv { m: p, k: n })?;
750        Some(complete_gpu_attempt(
751            "Aᵀ·v",
752            cuda_backend::gemv(runtime, a, v, true),
753        ))
754    }
755}
756
757#[inline]
758#[must_use]
759pub fn try_fast_xt_diag_x(x: ArrayView2<'_, f64>, w: ArrayView1<'_, f64>) -> Option<Array2<f64>> {
760    let (n, p) = x.dim();
761    if n != w.len() {
762        invalid_gpu_request("Xᵀ·diag(w)·X", "the row and weight counts differ");
763    }
764    if n == 0 || p == 0 {
765        return decline_gpu("Xᵀ·diag(w)·X", "the workload has an empty dimension");
766    }
767    #[cfg(not(target_os = "linux"))]
768    {
769        return decline_gpu(
770            "Xᵀ·diag(w)·X",
771            "the CUDA backend is not compiled on this platform",
772        );
773    }
774    #[cfg(target_os = "linux")]
775    {
776        let runtime = route_through_gpu(DispatchOp::XtDiagX { n, p })?;
777        Some(complete_gpu_attempt(
778            "Xᵀ·diag(w)·X",
779            cuda_backend::xt_diag_x(runtime, x, w),
780        ))
781    }
782}
783
784/// #1017 Phase 3: a device-resident design matrix for repeated `Xᵀ·diag(w)·X`
785/// Gram evaluations that uploads `X` to the device ONCE.
786///
787/// The per-call [`try_fast_xt_diag_x`] re-uploads the full `n×p` `X` on every
788/// call. The SAE / IRLS inner loop holds `X` fixed and rebuilds the Gram once
789/// per Newton/PIRLS weight update, so the repeated H2D of `X` is pure waste —
790/// measured on an A100 (#1412) it makes the `XtWX` GEMM ~98% of the pipeline at
791/// <20% device utilisation (the device is starved by staging, not arithmetic).
792/// This handle uploads `X` once at construction; each [`Self::gram`] crosses
793/// only the `n`-vector `w` H2D and the `p×p` Gram D2H, so the per-Gram transfer
794/// shrinks by a factor of `p`.
795///
796/// Admission keys on the same work-based [`DispatchOp::XtDiagX`] gate as the
797/// per-call path (so it engages exactly when the Gram is GPU-profitable) and the
798/// numerics are bit-identical to [`try_fast_xt_diag_x`] on the same device
799/// (same `cublasDdgmm` row-scale + `gemm` reduction order). On a non-CUDA host
800/// or a below-threshold shape, Auto makes [`Self::try_new`] return `None` and
801/// the caller keeps its CPU/per-call path; Required fails instead. Once
802/// admitted, upload or execution failures are fatal under every policy —
803/// residency never changes the result, only where (and how often) `X` is staged.
804pub struct ResidentDesignGram {
805    #[cfg(target_os = "linux")]
806    inner: super::blas::ResidentWeightedGram,
807    #[cfg(not(target_os = "linux"))]
808    _never: std::convert::Infallible,
809}
810
811impl ResidentDesignGram {
812    /// Upload `x` (`n×p`) to the device once. Auto returns `None` when CUDA is
813    /// unavailable or the shape is below the GPU Gram threshold; Required
814    /// fails loudly. An admitted upload failure is fatal under every policy.
815    #[must_use]
816    pub fn try_new(x: ArrayView2<'_, f64>) -> Option<Self> {
817        let (n, p) = x.dim();
818        if n == 0 || p == 0 {
819            return decline_gpu("resident weighted Gram upload", "the design matrix is empty");
820        }
821        #[cfg(not(target_os = "linux"))]
822        {
823            decline_gpu(
824                "resident weighted Gram upload",
825                "the CUDA backend is not compiled on this platform",
826            )
827        }
828        #[cfg(target_os = "linux")]
829        {
830            let runtime = route_through_gpu(DispatchOp::XtDiagX { n, p })?;
831            let inner = complete_gpu_attempt(
832                "resident weighted Gram upload",
833                super::blas::ResidentWeightedGram::new(runtime.device.ordinal, x),
834            );
835            Some(Self { inner })
836        }
837    }
838
839    /// Compute `Xᵀ·diag(w)·X` reusing the resident `X`. `w` must have one entry
840    /// per design row. Shape mismatches and device failures after construction
841    /// are fatal rather than being converted into a CPU continuation.
842    #[must_use]
843    pub fn gram(&self, w: ArrayView1<'_, f64>) -> Option<Array2<f64>> {
844        #[cfg(not(target_os = "linux"))]
845        {
846            // SAFETY: off CUDA, `try_new` never constructs `Self`, so this
847            // method is statically unreachable. Returning a benign `None` would
848            // silently launder that impossibility into a "GPU declined"
849            // sentinel, so fail loudly. The `w.len()` use also consumes the
850            // parameter on this target.
851            panic!(
852                "ResidentDesignGram cannot be constructed off CUDA (w.len()={})",
853                w.len()
854            )
855        }
856        #[cfg(target_os = "linux")]
857        {
858            Some(complete_gpu_attempt(
859                "resident Xᵀ·diag(w)·X",
860                self.inner.gram(w),
861            ))
862        }
863    }
864
865    /// Solve the penalized normal equations `(Xᵀ·diag(w)·X + ridge·I)·β = rhs`
866    /// with the Gram, its Cholesky factor, and the RHS all kept DEVICE-RESIDENT —
867    /// only `w` (`n`), `rhs` (`p`), and the solution `β` (`p`) cross the bus.
868    ///
869    /// This is the #1017 Phase-3 fix for the next ceiling after [`Self::gram`]:
870    /// the bare Gram still pays a `p×p` D2H (134 MB at p=4096), but the SAE/IRLS
871    /// inner step only needs `β`, so chaining row-scale→GEMM→POTRF→TRSM on-device
872    /// and returning only the `p`-vector removes that transfer entirely. A
873    /// shape mismatch, non-PD Gram, or device failure after construction is
874    /// fatal rather than being converted into a CPU continuation. The numerics
875    /// match a host `Cholesky((XᵀWX+ridge·I))` solve up to IEEE-754 reduction
876    /// order.
877    #[must_use]
878    pub fn solve_normal_equations(
879        &self,
880        w: ArrayView1<'_, f64>,
881        rhs: ArrayView1<'_, f64>,
882        ridge: f64,
883    ) -> Option<Array1<f64>> {
884        #[cfg(not(target_os = "linux"))]
885        {
886            // SAFETY: statically unreachable off CUDA (see `gram`); fail loudly.
887            panic!(
888                "ResidentDesignGram cannot be constructed off CUDA (w.len()={}, rhs.len()={}, ridge={ridge})",
889                w.len(),
890                rhs.len()
891            )
892        }
893        #[cfg(target_os = "linux")]
894        {
895            Some(complete_gpu_attempt(
896                "resident normal-equations solve",
897                self.inner.solve_psd_normal_equations(w, rhs, ridge),
898            ))
899        }
900    }
901
902    /// `(n, p)` of the resident design.
903    #[must_use]
904    pub fn dims(&self) -> (usize, usize) {
905        #[cfg(not(target_os = "linux"))]
906        {
907            // SAFETY: statically unreachable off CUDA (see `gram`) — no `Self`
908            // is ever constructed on this target; fail loudly rather than
909            // return a benign sentinel.
910            panic!("ResidentDesignGram cannot be constructed off CUDA")
911        }
912        #[cfg(target_os = "linux")]
913        {
914            self.inner.dims()
915        }
916    }
917}
918
919/// Number of row-chunks to carve per device for the spectral leverage stream
920/// so [`super::pool::balanced_partition`] can keep every GPU busy. With fewer
921/// chunks than devices the pool would idle the surplus devices; oversubscribing
922/// modestly amortizes the per-tile launch without bloating staging memory.
923/// Magic-by-default; no flag.
924#[cfg(target_os = "linux")]
925const LEVERAGE_CHUNKS_PER_DEVICE: usize = 4;
926
927/// Byte-balanced row-chunk width for the spectral leverage stream, mirroring
928/// the CPU `byte_balanced_row_chunk` sizing (≈8 MiB live blocks) so a single
929/// tile's `(chunk × p)` row slice plus `(chunk × rank)` GEMM output stay within
930/// the per-device staging budget.
931#[cfg(target_os = "linux")]
932#[inline]
933fn leverage_chunk_rows(cols: usize, n_rows: usize) -> usize {
934    // Imported, not transcribed (#2704): this was a byte-identical copy of
935    // `gam-solve`'s `byte_balanced_row_chunk`, differing only in the final
936    // `.min(n_rows.max(1))`. That tail difference is left alone here — it is
937    // a behaviour question, not a duplication one.
938    const TARGET_BYTES: usize = gam_runtime::resource::LIBRARY_ROW_CHUNK_TARGET_BYTES;
939    const MIN_CHUNK_ROWS: usize = 512;
940    let bytes_per_row = cols.max(1) * std::mem::size_of::<f64>();
941    (TARGET_BYTES / bytes_per_row)
942        .max(MIN_CHUNK_ROWS)
943        .min(n_rows.max(1))
944}
945
946/// GPU-offloaded spectral leverage diagonal `h[i] = ‖(X G)_{i,:}‖²`.
947///
948/// `G` is the `(p × rank)` spectral factor with `G_ε(H) = G Gᵀ`; the per-row
949/// leverage is the squared norm of the i-th row of `X G`. This is the dominant
950/// n-dependent cost of every REML outer evaluation at large scale (issue
951/// #922), and historically ran only on the CPU while the device pool idled.
952///
953/// The row dimension is split into byte-balanced chunks scattered across the
954/// whole device pool via [`super::pool::scatter_batched`] — the same
955/// whole-solve row-block granularity as Arrow-Schur — and each tile runs one
956/// cuBLAS GEMM `X_chunk · G` on its bound ordinal before reducing row-wise
957/// sum-of-squares. The arithmetic is identical f64 to the CPU faer path (modulo
958/// IEEE-754 reduction order). On no device or a below-threshold shape, Auto
959/// returns `None` and the caller runs its deterministic CPU stream; Required
960/// fails at the dispatch boundary. A tile failure after admission is fatal
961/// under every policy.
962#[inline]
963#[must_use]
964pub fn try_fast_spectral_leverage_diagonal(
965    x: &gam_linalg::matrix::DesignMatrix,
966    g: ArrayView2<'_, f64>,
967) -> Option<Array1<f64>> {
968    let n = x.nrows();
969    let p = x.ncols();
970    let rank = g.ncols();
971    if g.nrows() != p {
972        invalid_gpu_request(
973            "spectral leverage diagonal",
974            "the design width and spectral-factor height differ",
975        );
976    }
977    if n == 0 || p == 0 || rank == 0 {
978        return decline_gpu(
979            "spectral leverage diagonal",
980            "the workload has an empty dimension",
981        );
982    }
983    #[cfg(not(target_os = "linux"))]
984    {
985        return decline_gpu(
986            "spectral leverage diagonal",
987            "the CUDA backend is not compiled on this platform",
988        );
989    }
990    #[cfg(target_os = "linux")]
991    {
992        // n·p² gate is shared with the X^T diag(w) X reduction — the leverage
993        // diagonal is the same O(n·p·rank)-class dense pass over the design.
994        let runtime = route_through_gpu(DispatchOp::XtDiagX { n, p })?;
995        let device_count = runtime.device_count().max(1);
996        let byte_chunk = leverage_chunk_rows(p + rank, n);
997        let target_chunks = device_count
998            .saturating_mul(LEVERAGE_CHUNKS_PER_DEVICE)
999            .max(1);
1000        let chunk_rows = byte_chunk.min(n.div_ceil(target_chunks).max(1)).max(1);
1001
1002        // One slot per row-chunk; the slot carries its row range and receives
1003        // its own output buffer so each tile owns disjoint memory.
1004        let mut tiles: Vec<(std::ops::Range<usize>, Option<Array1<f64>>)> = Vec::new();
1005        let mut start = 0usize;
1006        while start < n {
1007            let end = (start + chunk_rows).min(n);
1008            tiles.push((start..end, None));
1009            start = end;
1010        }
1011
1012        complete_gpu_attempt(
1013            "spectral leverage diagonal scatter",
1014            super::pool::scatter_batched(runtime, &mut tiles, |ordinal, tile| {
1015                for (range, slot) in tile.iter_mut() {
1016                    let rows = x.try_row_chunk(range.clone()).ok()?;
1017                    let xg =
1018                        cuda_backend::gemm_on_ordinal(ordinal, rows.view(), g, false, false)?;
1019                    let mut out = Array1::<f64>::zeros(range.end - range.start);
1020                    for (local, row) in xg.outer_iter().enumerate() {
1021                        out[local] = row.iter().map(|&v| v * v).sum();
1022                    }
1023                    *slot = Some(out);
1024                }
1025                Some(())
1026            }),
1027        );
1028
1029        let mut h = Array1::<f64>::zeros(n);
1030        for (range, slot) in tiles {
1031            let vals = complete_gpu_attempt("spectral leverage diagonal stitch", slot);
1032            if vals.len() != range.end - range.start {
1033                invalid_gpu_result(
1034                    "spectral leverage diagonal stitch",
1035                    "a device tile produced an invalid row count",
1036                );
1037            }
1038            h.slice_mut(ndarray::s![range]).assign(&vals);
1039        }
1040        Some(h)
1041    }
1042}
1043
1044#[inline]
1045#[must_use]
1046pub fn try_fast_xt_diag_y(
1047    x: ArrayView2<'_, f64>,
1048    w: ArrayView1<'_, f64>,
1049    y: ArrayView2<'_, f64>,
1050) -> Option<Array2<f64>> {
1051    let (n, px) = x.dim();
1052    let (n_y, q) = y.dim();
1053    if n != n_y || n != w.len() {
1054        invalid_gpu_request("Xᵀ·diag(w)·Y", "the row or weight counts differ");
1055    }
1056    if n == 0 || px == 0 || q == 0 {
1057        return decline_gpu(
1058            "Xᵀ·diag(w)·Y",
1059            "the workload has an empty dimension",
1060        );
1061    }
1062    #[cfg(not(target_os = "linux"))]
1063    {
1064        return decline_gpu(
1065            "Xᵀ·diag(w)·Y",
1066            "the CUDA backend is not compiled on this platform",
1067        );
1068    }
1069    #[cfg(target_os = "linux")]
1070    {
1071        let runtime = route_through_gpu(DispatchOp::XtDiagY { n, px, q })?;
1072        Some(complete_gpu_attempt(
1073            "Xᵀ·diag(w)·Y",
1074            cuda_backend::xt_diag_y(runtime, x, w, y),
1075        ))
1076    }
1077}
1078
1079#[inline]
1080#[must_use]
1081pub fn try_fast_joint_hessian_2x2(
1082    x_a: ArrayView2<'_, f64>,
1083    x_b: ArrayView2<'_, f64>,
1084    w_aa: ArrayView1<'_, f64>,
1085    w_ab: ArrayView1<'_, f64>,
1086    w_bb: ArrayView1<'_, f64>,
1087) -> Option<Array2<f64>> {
1088    let (n, pa) = x_a.dim();
1089    let (n_b, pb) = x_b.dim();
1090    if n != n_b || n != w_aa.len() || n != w_ab.len() || n != w_bb.len() {
1091        invalid_gpu_request("joint 2×2 Hessian", "the row or weight counts differ");
1092    }
1093    if n == 0 || (pa == 0 && pb == 0) {
1094        return decline_gpu(
1095            "joint 2×2 Hessian",
1096            "the workload has an empty dimension",
1097        );
1098    }
1099    #[cfg(not(target_os = "linux"))]
1100    {
1101        return decline_gpu(
1102            "joint 2×2 Hessian",
1103            "the CUDA backend is not compiled on this platform",
1104        );
1105    }
1106    #[cfg(target_os = "linux")]
1107    {
1108        let runtime = route_through_gpu(DispatchOp::JointHessian2x2 { n, pa, pb })?;
1109        Some(complete_gpu_attempt(
1110            "joint 2×2 Hessian",
1111            cuda_backend::joint_hessian_2x2(runtime, x_a, x_b, w_aa, w_ab, w_bb),
1112        ))
1113    }
1114}
1115
1116#[inline]
1117#[must_use]
1118pub fn try_cholesky_lower_inplace(a: &mut Array2<f64>) -> Option<()> {
1119    let p = a.nrows();
1120    if p != a.ncols() {
1121        invalid_gpu_request("Cholesky factorization", "the input matrix is non-square");
1122    }
1123    if p == 0 {
1124        return decline_gpu("Cholesky factorization", "the workload has an empty dimension");
1125    }
1126    #[cfg(not(target_os = "linux"))]
1127    {
1128        return decline_gpu(
1129            "Cholesky factorization",
1130            "the CUDA backend is not compiled on this platform",
1131        );
1132    }
1133    #[cfg(target_os = "linux")]
1134    {
1135        let runtime = route_through_gpu(DispatchOp::Potrf { p, batch: 1 })?;
1136        let lower = complete_gpu_attempt(
1137            "Cholesky factorization",
1138            cuda_backend::cholesky_lower(runtime, a.view()),
1139        );
1140        *a = lower;
1141        Some(())
1142    }
1143}
1144
1145#[inline]
1146#[must_use]
1147pub fn try_cholesky_batched_lower_inplace(matrices: &mut [Array2<f64>]) -> Option<()> {
1148    try_cholesky_batched_lower_inplace_with_policy(matrices, super::global_policy())
1149}
1150
1151#[inline]
1152#[must_use]
1153pub fn try_cholesky_batched_lower_inplace_with_policy(
1154    matrices: &mut [Array2<f64>],
1155    gpu_policy: GpuPolicy,
1156) -> Option<()> {
1157    let first = match matrices.first() {
1158        Some(first) => first,
1159        None => return decline_gpu("batched Cholesky factorization", "the batch is empty"),
1160    };
1161    let p = first.nrows();
1162    if first.ncols() != p || matrices.iter().any(|matrix| matrix.dim() != (p, p)) {
1163        invalid_gpu_request(
1164            "batched Cholesky factorization",
1165            "an input matrix is non-square or has a different shape",
1166        );
1167    }
1168    if p == 0 {
1169        return decline_gpu(
1170            "batched Cholesky factorization",
1171            "the workload has an empty dimension",
1172        );
1173    }
1174    #[cfg(not(target_os = "linux"))]
1175    {
1176        return decline_gpu_with_policy(
1177            "batched Cholesky factorization",
1178            "the CUDA backend is not compiled on this platform",
1179            gpu_policy,
1180        );
1181    }
1182    #[cfg(target_os = "linux")]
1183    {
1184        let batch = matrices.len();
1185        let runtime = route_through_gpu_with_policy(
1186            DispatchOp::SmallDenseBatchedPotrf { p, batch },
1187            gpu_policy,
1188        )
1189        .or_else(|| route_through_gpu_with_policy(DispatchOp::Potrf { p, batch }, gpu_policy))?;
1190        if should_split_batch(batch) {
1191            // `matrices` is already the per-item slice, so the batch dimension
1192            // tiles directly onto `scatter_batched`: each device factors its own
1193            // contiguous block of matrices in place. On any tile failure the
1194            // whole batch is re-run on the primary device for determinism (the
1195            // factored tiles are overwritten by the single-device pass).
1196            let split = super::pool::scatter_batched(runtime, matrices, |ordinal, tile| {
1197                cuda_backend::cholesky_batched_lower(ordinal, tile)
1198            });
1199            if split.is_some() {
1200                return Some(());
1201            }
1202        }
1203        Some(complete_gpu_attempt(
1204            "batched Cholesky factorization",
1205            cuda_backend::cholesky_batched_lower(runtime.device.ordinal, matrices),
1206        ))
1207    }
1208}
1209
1210#[inline]
1211#[must_use]
1212pub fn try_solve_lower_triangular_matrix(
1213    lower: ArrayView2<'_, f64>,
1214    rhs: ArrayView2<'_, f64>,
1215) -> Option<Array2<f64>> {
1216    let (m, n) = rhs.dim();
1217    if lower.dim() != (m, m) {
1218        invalid_gpu_request(
1219            "lower-triangular solve",
1220            "the triangular matrix shape does not match the right-hand side",
1221        );
1222    }
1223    if m == 0 || n == 0 {
1224        return decline_gpu(
1225            "lower-triangular solve",
1226            "the workload has an empty dimension",
1227        );
1228    }
1229    #[cfg(not(target_os = "linux"))]
1230    {
1231        return decline_gpu(
1232            "lower-triangular solve",
1233            "the CUDA backend is not compiled on this platform",
1234        );
1235    }
1236    #[cfg(target_os = "linux")]
1237    {
1238        let runtime = route_through_gpu(DispatchOp::Trsm { m, n })?;
1239        Some(complete_gpu_attempt(
1240            "lower-triangular solve",
1241            cuda_backend::trsm(runtime, lower, rhs, false),
1242        ))
1243    }
1244}
1245
1246#[inline]
1247#[must_use]
1248pub fn try_solve_upper_triangular_matrix(
1249    upper: ArrayView2<'_, f64>,
1250    rhs: ArrayView2<'_, f64>,
1251) -> Option<Array2<f64>> {
1252    let (m, n) = rhs.dim();
1253    if upper.dim() != (m, m) {
1254        invalid_gpu_request(
1255            "upper-triangular solve",
1256            "the triangular matrix shape does not match the right-hand side",
1257        );
1258    }
1259    if m == 0 || n == 0 {
1260        return decline_gpu(
1261            "upper-triangular solve",
1262            "the workload has an empty dimension",
1263        );
1264    }
1265    #[cfg(not(target_os = "linux"))]
1266    {
1267        return decline_gpu(
1268            "upper-triangular solve",
1269            "the CUDA backend is not compiled on this platform",
1270        );
1271    }
1272    #[cfg(target_os = "linux")]
1273    {
1274        let runtime = route_through_gpu(DispatchOp::Trsm { m, n })?;
1275        Some(complete_gpu_attempt(
1276            "upper-triangular solve",
1277            cuda_backend::trsm(runtime, upper, rhs, true),
1278        ))
1279    }
1280}
1281
1282#[cfg(test)]
1283mod pre_probe_gate_tests {
1284    //! Pins the CUDA startup-tax ordering fix at the dispatch chokepoint: an op
1285    //! no reachable policy could admit must be refused by `route_through_gpu`
1286    //! WITHOUT resolving GPU availability — i.e. without triggering the
1287    //! device probe and its per-GPU `cuDevicePrimaryCtxRetain` context
1288    //! creation. Observable on any host (CUDA or not) through the process-wide
1289    //! `resolution_call_count` counter; nextest gives each test its own process.
1290    use super::{DispatchOp, GpuDispatchPolicy, route_through_gpu};
1291    use crate::device_runtime::GpuRuntime;
1292
1293    #[test]
1294    fn cpu_sized_ops_are_refused_before_the_device_probe() {
1295        let tiny_ops = [
1296            DispatchOp::Gemm { m: 8, n: 8, k: 8 },
1297            DispatchOp::BatchedGemm {
1298                batch: 4,
1299                m: 8,
1300                n: 8,
1301                k: 8,
1302            },
1303            DispatchOp::Gemv { m: 64, k: 64 },
1304            DispatchOp::Potrf { p: 24, batch: 1 },
1305            DispatchOp::Trsm { m: 16, n: 16 },
1306            DispatchOp::XtDiagX { n: 700, p: 12 },
1307            DispatchOp::XtDiagY {
1308                n: 700,
1309                px: 12,
1310                q: 4,
1311            },
1312            DispatchOp::JointHessian2x2 {
1313                n: 700,
1314                pa: 8,
1315                pb: 8,
1316            },
1317        ];
1318        let before = GpuRuntime::resolution_call_count();
1319        for op in tiny_ops {
1320            assert!(
1321                !op.admissible_under_any_policy(),
1322                "fixture op must be inadmissible under every policy: {op:?}"
1323            );
1324            assert!(
1325                route_through_gpu(op).is_none(),
1326                "inadmissible op must not route: {op:?}"
1327            );
1328        }
1329        assert_eq!(
1330            GpuRuntime::resolution_call_count(),
1331            before,
1332            "route_through_gpu must refuse CPU-sized ops BEFORE runtime resolution, \
1333             so no CUDA context is ever created for them"
1334        );
1335    }
1336
1337    #[test]
1338    fn admissible_ops_fall_through_to_the_probed_runtime() {
1339        // An op above every floor must consult the runtime (identical behaviour
1340        // to the pre-fix path for genuinely GPU-sized work).
1341        let big = DispatchOp::Gemm {
1342            m: 2_048,
1343            n: 2_048,
1344            k: 2_048,
1345        };
1346        assert!(big.admissible_under_any_policy());
1347        let before = GpuRuntime::resolution_call_count();
1348        let routed = route_through_gpu(big);
1349        assert!(
1350            routed.is_none_or(|runtime| !runtime.devices.is_empty()),
1351            "a routed operation must receive a runtime with at least one usable device"
1352        );
1353        assert!(
1354            GpuRuntime::resolution_call_count() > before,
1355            "an admissible op must fall through to runtime resolution"
1356        );
1357    }
1358
1359    #[test]
1360    fn admissibility_bound_never_tightens_the_real_admission() {
1361        // For every op the pre-probe bound admits AT LEAST what the seed policy
1362        // and the most permissive calibrated policy admit: check against both
1363        // the default seed and a synthetic policy floored at the calibration
1364        // minima. (If the real admission passed, the pre-gate must have too —
1365        // otherwise the ordering fix would change GPU-sized behaviour.)
1366        let floor_policy = GpuDispatchPolicy {
1367            gemm_min_flops: usize::try_from(GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS)
1368                .expect("fits usize"),
1369            potrf_min_p: GpuDispatchPolicy::MIN_CALIBRATABLE_POTRF_P,
1370            xtwx_flops_min: 4_194_304, // smallest xtwx calibration measurement
1371            ..GpuDispatchPolicy::default()
1372        };
1373        let policies = [GpuDispatchPolicy::default(), floor_policy];
1374        let ops = [
1375            DispatchOp::Gemm {
1376                m: 64,
1377                n: 64,
1378                k: 64,
1379            },
1380            DispatchOp::Gemm {
1381                m: 63,
1382                n: 64,
1383                k: 64,
1384            },
1385            DispatchOp::BatchedGemm {
1386                batch: 8,
1387                m: 64,
1388                n: 64,
1389                k: 8,
1390            },
1391            DispatchOp::Gemv { m: 512, k: 512 },
1392            DispatchOp::Potrf { p: 64, batch: 1 },
1393            DispatchOp::Potrf { p: 63, batch: 1 },
1394            DispatchOp::Potrf { p: 24, batch: 512 },
1395            DispatchOp::SmallDenseBatchedPotrf { p: 24, batch: 8 },
1396            DispatchOp::SmallDenseBatchedPotrf { p: 24, batch: 7 },
1397            DispatchOp::Trsm { m: 128, n: 64 },
1398            DispatchOp::XtDiagX { n: 50_000, p: 96 },
1399            DispatchOp::XtDiagX { n: 700, p: 24 },
1400            DispatchOp::XtDiagY {
1401                n: 50_000,
1402                px: 96,
1403                q: 8,
1404            },
1405            DispatchOp::JointHessian2x2 {
1406                n: 50_000,
1407                pa: 64,
1408                pb: 64,
1409            },
1410        ];
1411        for policy in &policies {
1412            for op in ops {
1413                let admitted = match op {
1414                    DispatchOp::Gemm { m, n, k } => {
1415                        op.flops() >= policy.gemm_min_flops as u128 && m.min(n).min(k) > 0
1416                    }
1417                    DispatchOp::BatchedGemm { batch, m, n, k } => {
1418                        op.flops() >= policy.gemm_min_flops as u128
1419                            && batch > 1
1420                            && m.min(n).min(k) > 0
1421                    }
1422                    DispatchOp::Gemv { m, k } => {
1423                        op.flops() >= policy.gemm_min_flops as u128 && m > 0 && k > 0
1424                    }
1425                    DispatchOp::Potrf { p, batch } => {
1426                        p > 0
1427                            && batch > 0
1428                            && (p >= policy.potrf_min_p
1429                                || (batch > 1 && op.flops() >= policy.gemm_min_flops as u128))
1430                    }
1431                    DispatchOp::SmallDenseBatchedPotrf { p, batch } => {
1432                        p > 0
1433                            && p <= policy.small_dense_batched_potrf_max_p
1434                            && batch >= policy.small_dense_batched_potrf_min_batch
1435                    }
1436                    DispatchOp::Trsm { m, n } => {
1437                        op.flops() >= policy.gemm_min_flops as u128 && m > 0 && n > 0
1438                    }
1439                    DispatchOp::XtDiagX { n, p } => policy.xtwx_target_is_gpu(n, p, true),
1440                    DispatchOp::XtDiagY { n, px, q } => policy.xtwy_target_is_gpu(n, px, q, true),
1441                    DispatchOp::JointHessian2x2 { n, pa, pb } => {
1442                        n > 0
1443                            && (pa > 0 || pb > 0)
1444                            && op.flops() >= policy.gemm_min_flops as u128
1445                    }
1446                };
1447                if admitted {
1448                    assert!(
1449                        op.admissible_under_any_policy(),
1450                        "pre-probe bound must not refuse an op the real admission accepts: \
1451                         {op:?} under {policy:?}"
1452                    );
1453                }
1454            }
1455        }
1456    }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::{DispatchOp, route_through_gpu, try_fast_ab};
1462    use crate::GpuPolicy;
1463    use crate::device_runtime::GpuRuntime;
1464
1465    fn available_runtime(label: &str) -> Option<&'static GpuRuntime> {
1466        match GpuRuntime::resolve(GpuPolicy::Auto) {
1467            Ok(runtime) => runtime,
1468            Err(error) => panic!("[{label}] GPU probe fault: {error}"),
1469        }
1470    }
1471
1472    #[test]
1473    fn sae_shape_dispatch_ops_decline_without_cuda_else_route_when_cuda_runtime_is_present() {
1474        let n = 2_000usize;
1475        let p = 2_048usize;
1476        let m = 12usize;
1477        let k = 8usize;
1478        let dense_reduction_ops = [
1479            DispatchOp::XtDiagX { n, p },
1480            DispatchOp::XtDiagY { n, px: p, q: m * k },
1481            DispatchOp::JointHessian2x2 {
1482                n,
1483                pa: p,
1484                pb: m * k,
1485            },
1486            DispatchOp::Gemm {
1487                m: p,
1488                n: p,
1489                k: n * m,
1490            },
1491        ];
1492        let batched_potrf = DispatchOp::SmallDenseBatchedPotrf { p: m, batch: n };
1493        let Some(runtime) = available_runtime("sae dispatch gate") else {
1494            for op in dense_reduction_ops
1495                .iter()
1496                .copied()
1497                .chain(std::iter::once(batched_potrf))
1498            {
1499                assert!(
1500                    route_through_gpu(op).is_none(),
1501                    "no CUDA runtime is available, yet the SAE dispatch gate admitted {op:?}"
1502                );
1503            }
1504            return;
1505        };
1506
1507        for op in dense_reduction_ops {
1508            assert!(
1509                op.flops() >= runtime.policy.gemm_min_flops as u128,
1510                "SAE dispatch fixture must clear the runtime GEMM work floor: op={op:?}, flops={}, floor={}",
1511                op.flops(),
1512                runtime.policy.gemm_min_flops
1513            );
1514            assert!(
1515                route_through_gpu(op).is_some(),
1516                "SAE dispatch fixture should route to GPU when CUDA is present: {op:?}"
1517            );
1518        }
1519
1520        assert!(
1521            route_through_gpu(batched_potrf).is_some(),
1522            "uniform SAE row blocks should reach the small-dense batched POTRF gate"
1523        );
1524    }
1525
1526    /// Resolving an available runtime must install the dense-GEMM dispatch
1527    /// hook into `gam_linalg`, so a profitable `fast_ab` call routes through
1528    /// the device — and the device result must match the CPU oracle within
1529    /// IEEE reduction tolerance. This is the regression guard for the bug
1530    /// where `CudaGemmDispatch` existed but `register_gpu_dispatch` was never
1531    /// called, leaving every engine GEMM silently on the CPU.
1532    #[test]
1533    fn global_runtime_declines_without_cuda_else_installs_fast_ab_hook_and_matches_cpu() {
1534        use ndarray::Array2;
1535
1536        let (m, k, n) = (512usize, 512usize, 512usize);
1537        let Some(_runtime) = available_runtime("fast_ab hook") else {
1538            assert!(
1539                route_through_gpu(DispatchOp::Gemm { m, n, k }).is_none(),
1540                "no CUDA runtime is available, yet a profitable dense GEMM was admitted"
1541            );
1542            return;
1543        };
1544        // After resolution returned an available device, the hook MUST be installed.
1545        assert!(
1546            gam_linalg::gpu_hook::gpu_dispatch().is_some(),
1547            "GpuRuntime::resolve(Auto) returned a device but did not register the \
1548             dense-GEMM dispatch hook — fast_ab would silently stay on the CPU"
1549        );
1550
1551        // A profitable GEMM (m=n=k=512 → 2·512³ ≈ 268 MFLOP, above the
1552        // 100 MFLOP policy floor) must route to the device. Kept modest so
1553        // the debug-build CPU oracle below stays a few seconds, not a minute.
1554        assert!(
1555            route_through_gpu(DispatchOp::Gemm { m, n, k }).is_some(),
1556            "a 268 MFLOP GEMM must clear the policy floor and route to GPU"
1557        );
1558
1559        // Deterministic, well-conditioned operands.
1560        let a = Array2::<f64>::from_shape_fn((m, k), |(i, j)| {
1561            ((i * 7 + j * 3) % 13) as f64 * 0.01 - 0.06
1562        });
1563        let b = Array2::<f64>::from_shape_fn((k, n), |(i, j)| {
1564            ((i * 5 + j * 11) % 17) as f64 * 0.01 - 0.08
1565        });
1566
1567        // Device result via the dispatch entry point.
1568        let gpu = try_fast_ab(a.view(), b.view())
1569            .expect("profitable GEMM must produce a device result once admitted");
1570
1571        // CPU oracle (plain triple loop — independent of faer/matrixmultiply).
1572        let mut cpu = Array2::<f64>::zeros((m, n));
1573        for i in 0..m {
1574            for j in 0..n {
1575                let mut acc = 0.0f64;
1576                for p in 0..k {
1577                    acc += a[[i, p]] * b[[p, j]];
1578                }
1579                cpu[[i, j]] = acc;
1580            }
1581        }
1582
1583        let mut max_abs = 0.0f64;
1584        for i in 0..m {
1585            for j in 0..n {
1586                max_abs = max_abs.max((gpu[[i, j]] - cpu[[i, j]]).abs());
1587            }
1588        }
1589        assert!(
1590            max_abs < 1e-9,
1591            "device GEMM disagreed with the CPU oracle: max|Δ| = {max_abs:e}"
1592        );
1593    }
1594
1595    /// The transpose-free `gemm_cuda` path (`Cᵀ = op(B)ᵀ·op(A)ᵀ`, no host
1596    /// `to_col_major`/`from_col_major`) must match a CPU oracle across all
1597    /// four `(trans_a, trans_b)` combinations AND non-square, tall-skinny
1598    /// shapes — the regime (200000×200 · 200×k) where the host transpose
1599    /// previously dominated. This guards the leading-dimension/operand-swap
1600    /// derivation against silent index bugs.
1601    #[cfg(target_os = "linux")]
1602    #[test]
1603    fn transpose_free_gemm_declines_without_cuda_else_matches_cpu_all_trans_and_shapes() {
1604        use crate::blas::gemm_cuda;
1605        use ndarray::Array2;
1606
1607        let Some(runtime) = available_runtime("gemm transpose-free") else {
1608            assert!(
1609                route_through_gpu(DispatchOp::Gemm {
1610                    m: 512,
1611                    n: 512,
1612                    k: 512,
1613                })
1614                .is_none(),
1615                "no CUDA runtime is available, yet the transpose-free GEMM seam admitted work"
1616            );
1617            return;
1618        };
1619
1620        // (rows, inner, cols) logical product dims, deliberately all distinct
1621        // and rectangular so any lda/ldb/ldc mix-up surfaces.
1622        let cases = [(6usize, 4usize, 5usize), (17, 23, 9), (200, 31, 7)];
1623        for (m, k, n) in cases {
1624            // Base operands sized for the no-transpose orientation; transposed
1625            // variants are built by swapping dims so the logical product is
1626            // always (m × n).
1627            let mk = Array2::<f64>::from_shape_fn((m, k), |(i, j)| {
1628                ((i * 31 + j * 17) % 19) as f64 * 0.013 - 0.11
1629            });
1630            let km = Array2::<f64>::from_shape_fn((k, m), |(i, j)| {
1631                ((i * 13 + j * 29) % 23) as f64 * 0.011 - 0.07
1632            });
1633            let kn = Array2::<f64>::from_shape_fn((k, n), |(i, j)| {
1634                ((i * 7 + j * 5) % 17) as f64 * 0.017 - 0.09
1635            });
1636            let nk = Array2::<f64>::from_shape_fn((n, k), |(i, j)| {
1637                ((i * 19 + j * 11) % 13) as f64 * 0.015 - 0.05
1638            });
1639
1640            for &trans_a in &[false, true] {
1641                for &trans_b in &[false, true] {
1642                    let a = if trans_a { &km } else { &mk };
1643                    let b = if trans_b { &nk } else { &kn };
1644
1645                    let gpu = gemm_cuda(runtime, a.view(), b.view(), trans_a, trans_b).expect(
1646                        "transpose-free device GEMM must produce a result when a device is present",
1647                    );
1648                    assert_eq!(
1649                        gpu.dim(),
1650                        (m, n),
1651                        "output shape wrong for trans_a={trans_a} trans_b={trans_b} ({m}×{k}×{n})"
1652                    );
1653
1654                    // CPU oracle on the logically-transposed operands.
1655                    let mut cpu = Array2::<f64>::zeros((m, n));
1656                    for i in 0..m {
1657                        for j in 0..n {
1658                            let mut acc = 0.0f64;
1659                            for p in 0..k {
1660                                let av = if trans_a { a[[p, i]] } else { a[[i, p]] };
1661                                let bv = if trans_b { b[[j, p]] } else { b[[p, j]] };
1662                                acc += av * bv;
1663                            }
1664                            cpu[[i, j]] = acc;
1665                        }
1666                    }
1667
1668                    let mut max_abs = 0.0f64;
1669                    for i in 0..m {
1670                        for j in 0..n {
1671                            max_abs = max_abs.max((gpu[[i, j]] - cpu[[i, j]]).abs());
1672                        }
1673                    }
1674                    assert!(
1675                        max_abs < 1e-9,
1676                        "transpose-free GEMM mismatch (trans_a={trans_a} trans_b={trans_b}, \
1677                         {m}×{k}×{n}): max|Δ| = {max_abs:e}"
1678                    );
1679                }
1680            }
1681        }
1682    }
1683}
1684
1685// ---------------------------------------------------------------------------
1686// Backend selection. The wrappers keep CUDA types out of solver modules while
1687// delegating to cudarc-backed BLAS, solver, and custom kernel implementations.
1688// ---------------------------------------------------------------------------
1689
1690#[cfg(target_os = "linux")]
1691mod cuda_backend {
1692    //! CUDA-backed implementations of the dispatch entry points.
1693    //!
1694    //! The real device kernels live in `super::super::blas` and
1695    //! `super::super::kernels::*`; this module simply forwards. When the
1696    //! lower layer reports an unrecoverable backend error (OOM, transient
1697    //! launch failure, …) the wrapper reports `None` to the policy-aware outer
1698    //! boundary, which fails rather than silently changing execution backend.
1699    //! Successful numerical results match the CPU code modulo IEEE-754
1700    //! reduction order.
1701
1702    use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3};
1703
1704    use super::super::device_runtime::GpuRuntime;
1705    use crate::driver::{from_col_major, to_col_major, to_i32};
1706    use cudarc::cusolver::{DnHandle, sys as cusolver_sys};
1707    use cudarc::driver::{DevicePtrMut, sys as driver_sys};
1708
1709    #[inline]
1710    pub(super) fn gemm(
1711        runtime: &GpuRuntime,
1712        a: ArrayView2<'_, f64>,
1713        b: ArrayView2<'_, f64>,
1714        trans_a: bool,
1715        trans_b: bool,
1716    ) -> Option<Array2<f64>> {
1717        super::super::blas::gemm_cuda(runtime, a, b, trans_a, trans_b)
1718    }
1719
1720    #[inline]
1721    pub(super) fn gemm_on_ordinal(
1722        ordinal: usize,
1723        a: ArrayView2<'_, f64>,
1724        b: ArrayView2<'_, f64>,
1725        trans_a: bool,
1726        trans_b: bool,
1727    ) -> Option<Array2<f64>> {
1728        super::super::blas::gemm_on_ordinal_cuda(ordinal, a, b, trans_a, trans_b)
1729    }
1730
1731    #[inline]
1732    pub(super) fn gemv(
1733        runtime: &GpuRuntime,
1734        a: ArrayView2<'_, f64>,
1735        v: ArrayView1<'_, f64>,
1736        trans_a: bool,
1737    ) -> Option<Array1<f64>> {
1738        super::super::blas::gemv_cuda(runtime, a, v, trans_a)
1739    }
1740
1741    #[inline]
1742    pub(super) fn gemm_broadcast_b_batched(
1743        ordinal: usize,
1744        a: ArrayView3<'_, f64>,
1745        b: ArrayView2<'_, f64>,
1746    ) -> Option<Array3<f64>> {
1747        super::super::blas::gemm_broadcast_b_batched_cuda(ordinal, a, b)
1748    }
1749
1750    #[inline]
1751    pub(super) fn gemm_abt_strided_batched(
1752        ordinal: usize,
1753        a: ArrayView3<'_, f64>,
1754        b: ArrayView3<'_, f64>,
1755    ) -> Option<Array3<f64>> {
1756        super::super::blas::gemm_abt_strided_batched_cuda(ordinal, a, b)
1757    }
1758
1759    #[inline]
1760    pub(super) fn xt_diag_x(
1761        runtime: &GpuRuntime,
1762        x: ArrayView2<'_, f64>,
1763        w: ArrayView1<'_, f64>,
1764    ) -> Option<Array2<f64>> {
1765        super::super::blas::xt_diag_x_cuda(runtime, x, w)
1766    }
1767
1768    #[inline]
1769    pub(super) fn xt_diag_y(
1770        runtime: &GpuRuntime,
1771        x: ArrayView2<'_, f64>,
1772        w: ArrayView1<'_, f64>,
1773        y: ArrayView2<'_, f64>,
1774    ) -> Option<Array2<f64>> {
1775        super::super::blas::xt_diag_y_cuda(runtime, x, w, y)
1776    }
1777
1778    #[inline]
1779    pub(super) fn joint_hessian_2x2(
1780        runtime: &GpuRuntime,
1781        x_a: ArrayView2<'_, f64>,
1782        x_b: ArrayView2<'_, f64>,
1783        w_aa: ArrayView1<'_, f64>,
1784        w_ab: ArrayView1<'_, f64>,
1785        w_bb: ArrayView1<'_, f64>,
1786    ) -> Option<Array2<f64>> {
1787        super::super::blas::joint_hessian_2x2_cuda(runtime, x_a, x_b, w_aa, w_ab, w_bb)
1788    }
1789
1790    #[inline]
1791    pub(super) fn trsm(
1792        runtime: &GpuRuntime,
1793        triangular: ArrayView2<'_, f64>,
1794        rhs: ArrayView2<'_, f64>,
1795        upper: bool,
1796    ) -> Option<Array2<f64>> {
1797        super::super::blas::trsm_cuda(runtime, triangular, rhs, upper)
1798    }
1799
1800    #[inline]
1801    pub(super) fn cholesky_lower(
1802        runtime: &GpuRuntime,
1803        a: ArrayView2<'_, f64>,
1804    ) -> Option<Array2<f64>> {
1805        let (p, p2) = a.dim();
1806        if p == 0 || p != p2 {
1807            return None;
1808        }
1809        let stream = super::super::device_runtime::cuda_context_for(runtime.device.ordinal)?
1810            .new_stream()
1811            .ok()?;
1812        let solver = DnHandle::new(stream.clone()).ok()?;
1813        let a_col = to_col_major(&a);
1814        let mut a_dev = stream.clone_htod(&*a_col).ok()?;
1815        potrf_lower_in_place(&solver, &stream, p, &mut a_dev)?;
1816        let factor_col = stream.clone_dtoh(&a_dev).ok()?;
1817        let mut lower = from_col_major(&factor_col, p, p)?;
1818        for row in 0..p {
1819            for col in (row + 1)..p {
1820                lower[[row, col]] = 0.0;
1821            }
1822        }
1823        Some(lower)
1824    }
1825
1826    /// Batched lower-Cholesky on a specific device ordinal. The ordinal's
1827    /// context is expected to be bound on the calling thread (multi-GPU
1828    /// `scatter_batched` worker or the single-device dispatcher).
1829    #[inline]
1830    pub(super) fn cholesky_batched_lower(
1831        ordinal: usize,
1832        matrices: &mut [Array2<f64>],
1833    ) -> Option<()> {
1834        let first = matrices.first()?;
1835        let p = first.nrows();
1836        if p == 0 || first.ncols() != p || matrices.iter().any(|matrix| matrix.dim() != (p, p)) {
1837            return None;
1838        }
1839
1840        let stream = super::super::device_runtime::cuda_context_for(ordinal)?
1841            .new_stream()
1842            .ok()?;
1843        let solver = DnHandle::new(stream.clone()).ok()?;
1844        let matrix_len = p.checked_mul(p)?;
1845        let mut batch_col = Vec::with_capacity(matrices.len().checked_mul(matrix_len)?);
1846        for matrix in matrices.iter() {
1847            batch_col.extend(to_col_major(&matrix.view()).iter().copied());
1848        }
1849        let mut matrices_dev = stream.clone_htod(&batch_col).ok()?;
1850        let matrix_ptrs = {
1851            let (base_ptr, _matrix_record) = matrices_dev.device_ptr_mut(&stream);
1852            let bytes_per_matrix = driver_sys::CUdeviceptr::try_from(
1853                matrix_len.checked_mul(std::mem::size_of::<f64>())?,
1854            )
1855            .ok()?;
1856            let mut matrix_ptrs = Vec::with_capacity(matrices.len());
1857            for idx in 0..matrices.len() {
1858                let offset = driver_sys::CUdeviceptr::try_from(idx).ok()? * bytes_per_matrix;
1859                matrix_ptrs.push(base_ptr + offset);
1860            }
1861            matrix_ptrs
1862        };
1863        let mut matrix_ptrs_dev = stream.clone_htod(&matrix_ptrs).ok()?;
1864        let mut info_dev = stream.alloc_zeros::<i32>(matrices.len()).ok()?;
1865        let p_i = to_i32(p)?;
1866        let batch_i = to_i32(matrices.len())?;
1867        {
1868            let (ptrs_ptr, _ptrs_record) = matrix_ptrs_dev.device_ptr_mut(&stream);
1869            let (info_ptr, _info_record) = info_dev.device_ptr_mut(&stream);
1870            // SAFETY: `ptrs_ptr` points to a device array of batch pointers,
1871            // each pointer targets a live p×p column-major matrix in
1872            // `matrices_dev`, and `info_dev` has one entry per batch item.
1873            let status = unsafe {
1874                cusolver_sys::cusolverDnDpotrfBatched(
1875                    solver.cu(),
1876                    cusolver_sys::cublasFillMode_t::CUBLAS_FILL_MODE_LOWER,
1877                    p_i,
1878                    ptrs_ptr as *mut *mut f64,
1879                    p_i,
1880                    info_ptr as *mut i32,
1881                    batch_i,
1882                )
1883            };
1884            check_cusolver(status)?;
1885        }
1886        let info_host = stream.clone_dtoh(&info_dev).ok()?;
1887        if info_host.iter().any(|info| *info != 0) {
1888            return None;
1889        }
1890        let factored_col = stream.clone_dtoh(&matrices_dev).ok()?;
1891        for (idx, matrix) in matrices.iter_mut().enumerate() {
1892            let start = idx.checked_mul(matrix_len)?;
1893            let end = start.checked_add(matrix_len)?;
1894            let mut lower = from_col_major(&factored_col[start..end], p, p)?;
1895            for row in 0..p {
1896                for col in (row + 1)..p {
1897                    lower[[row, col]] = 0.0;
1898                }
1899            }
1900            *matrix = lower;
1901        }
1902        Some(())
1903    }
1904
1905    /// Single-matrix lower Cholesky POTRF. Thin `Result → Option` adapter over
1906    /// the shared precision-generic core in `solver.rs`
1907    /// ([`crate::solver::potrf_in_place_generic`]) so the cuSOLVER
1908    /// bufferSize/POTRF/info scaffold lives in exactly one place. The batched
1909    /// variant (`cusolverDnDpotrfBatched`) above is kept separate by design.
1910    fn potrf_lower_in_place(
1911        solver: &DnHandle,
1912        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1913        p: usize,
1914        a: &mut cudarc::driver::CudaSlice<f64>,
1915    ) -> Option<()> {
1916        crate::solver::potrf_in_place_generic::<f64>(solver, stream, p, a).ok()
1917    }
1918
1919    #[inline]
1920    fn check_cusolver(status: cusolver_sys::cusolverStatus_t) -> Option<()> {
1921        if status == cusolver_sys::cusolverStatus_t::CUSOLVER_STATUS_SUCCESS {
1922            Some(())
1923        } else {
1924            None
1925        }
1926    }
1927}