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