Skip to main content

gam_gpu/
mod.rs

1// GPU acceleration support.
2//
3// Infrastructure modules live at this level and are intentionally callable
4// from CPU-only builds: all public entry points are available without CUDA,
5// and the runtime reports an unavailable backend instead of changing
6// numerical results. CUDA-specific code is compiled only for Linux builds that
7// enable the `cuda` feature, so cudarc is never loaded by default CPU-only
8// builds.
9
10// `gpu_error` is declared first so its `#[macro_use]` macros (`gpu_err!`,
11// `gpu_bail!`) are in textual scope for every module below — `backend_probe`
12// in particular calls `gpu_err!` unqualified. Referring to these
13// `#[macro_export]` macros by absolute path (`crate::gpu_err`) is rejected
14// here: `lib.rs` pulls this module tree in via `include!`, which makes every
15// exported macro "macro-expanded", and absolute-path access to those is a
16// denied future-incompat lint.
17#[macro_use]
18pub mod gpu_error;
19pub mod backend_probe;
20pub mod blas;
21#[cfg(target_os = "linux")]
22pub mod calibration;
23pub mod device;
24pub mod device_cache;
25pub mod device_runtime;
26pub mod dictionary_score;
27pub mod driver;
28pub mod encode_throughput;
29pub mod engagement;
30pub mod linalg_dispatch;
31pub mod memory;
32pub mod numerics_device;
33pub mod numerics_host;
34pub mod policy;
35pub mod pool;
36pub mod profile;
37pub mod solver;
38/// One gate for every GPU-conditional test (#2422); `pub` because the tests
39/// that need it live in other crates.
40pub mod test_gate;
41
42// Domain-specific GPU kernels are isolated from the infrastructure modules.
43pub mod kernels;
44
45pub use device::GpuDeviceInfo;
46pub use device_runtime::{GpuAbsence, GpuAvailability, GpuAvailabilityRef, GpuRuntime};
47pub use dictionary_score::{
48    DEFAULT_DICTIONARY_SCORE_MIN_ELEMS, DEFAULT_DICTIONARY_SCORE_TILE_ELEMS,
49    DictionaryScoreRoutePlan,
50};
51pub use gpu_error::GpuError;
52pub use memory::{DeviceBuffer, DeviceCsrMatrix, DeviceMatrix, DeviceVector};
53pub use policy::{GpuDispatchPolicy, GpuMixedPrecisionPolicy};
54pub use pool::{balanced_partition, scatter_batched};
55pub use profile::{GpuExecutionTelemetry, KernelStat, KernelStatsSnapshot};
56
57// ---------------------------------------------------------------------------
58// User-facing policy and instrumentation hooks (formerly src/gpu.rs).
59//
60// The first production-safe step for acceleration is an explicit policy
61// layer: `Auto` may opportunistically use supported device-resident kernels,
62// `Off` guarantees the CPU path, and `Required` turns an unsupported GPU route
63// into a hard error instead of a silent CPU fallback. The numerical kernels
64// are wired to call these helpers before selecting a backend; until a vendor
65// backend is compiled in this module intentionally reports "unsupported" so
66// `required` fails loudly while `auto` remains a correct CPU fallback.
67// ---------------------------------------------------------------------------
68
69use serde::{Deserialize, Serialize};
70use std::fmt;
71use std::sync::OnceLock;
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum CudaBackendStatus {
75    CudaUnavailable,
76    CudaReady,
77}
78
79/// User-facing GPU backend policy.
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum GpuPolicy {
83    /// Let the solver use GPU kernels only for supported, large-enough paths.
84    #[default]
85    Auto,
86    /// Always use CPU kernels.
87    Off,
88    /// Require GPU kernels and error if the requested path is unsupported.
89    Required,
90}
91
92impl GpuPolicy {
93    pub fn parse(raw: &str) -> Option<Self> {
94        match raw.trim().to_ascii_lowercase().as_str() {
95            "auto" => Some(Self::Auto),
96            "off" => Some(Self::Off),
97            "required" => Some(Self::Required),
98            _ => None,
99        }
100    }
101
102    #[inline]
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::Auto => "auto",
106            Self::Off => "off",
107            Self::Required => "required",
108        }
109    }
110}
111
112impl fmt::Display for GpuPolicy {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub enum GpuKernel {
120    DenseMatvec,
121    DenseTransposeMatvec,
122    DenseXtWX,
123    CandidateScreen,
124    DenseSolve,
125    MatrixFreePcg,
126    SparseAssembly,
127    SpatialKernelOperator,
128    MarginalSlopeRows,
129    RemlTrace,
130    FinalInference,
131}
132
133impl GpuKernel {
134    pub const fn as_str(self) -> &'static str {
135        match self {
136            Self::DenseMatvec => "dense-matvec",
137            Self::DenseTransposeMatvec => "dense-transpose-matvec",
138            Self::DenseXtWX => "dense-xtwx",
139            Self::CandidateScreen => "candidate-screen",
140            Self::DenseSolve => "dense-solve",
141            Self::MatrixFreePcg => "matrix-free-pcg",
142            Self::SparseAssembly => "sparse-assembly",
143            Self::SpatialKernelOperator => "spatial-kernel-operator",
144            Self::MarginalSlopeRows => "marginal-slope-rows",
145            Self::RemlTrace => "reml-trace",
146            Self::FinalInference => "final-inference",
147        }
148    }
149}
150
151/// A backend-selection decision for a single hot kernel.
152#[derive(Clone, Debug)]
153pub struct GpuDecision {
154    pub policy: GpuPolicy,
155    pub kernel: GpuKernel,
156    pub use_gpu: bool,
157    pub reason: &'static str,
158}
159
160static POLICY: OnceLock<GpuPolicy> = OnceLock::new();
161
162#[inline]
163pub fn global_policy() -> GpuPolicy {
164    // Reading the policy must NOT claim the OnceLock slot: returning the
165    // default `Auto` via `get_or_init` would race against an explicit
166    // `configure_global_policy(...)` made later in the same process and
167    // silently lock the policy to `Auto`.  Keep the slot uninitialized
168    // until explicitly configured so first-writer-wins applies only to
169    // genuine writes, not to incidental reads from probe/dispatch code.
170    match POLICY.get() {
171        Some(p) => *p,
172        None => GpuPolicy::Auto,
173    }
174}
175
176/// Configure the process-wide policy before solver kernels are selected.
177/// If a previous explicit configuration already set the policy, the first
178/// value wins so concurrent fits cannot race policy changes.  Reads of
179/// `global_policy()` never claim the slot, so the very first explicit
180/// configuration always sticks even if dispatch code observed the
181/// default `Auto` beforehand.
182pub fn configure_global_policy(policy: GpuPolicy) {
183    // First-writer-wins semantics; a late call is ignored, but which policy was
184    // dropped is exactly what explains a process that ran on the wrong backend.
185    if let Err(rejected) = POLICY.set(policy) {
186        log::debug!(
187            "gam-gpu: global policy already configured as {:?}; ignoring the later {rejected:?}",
188            POLICY.get()
189        );
190    }
191}
192
193/// True when direct solver GPU entry points should be attempted.
194///
195/// `Auto` attempts CUDA only after the runtime probe finds a usable device.
196/// `Off` pins the process to CPU. `Required` attempts the GPU path so missing
197/// runtime/backend support becomes an explicit error at the callee instead of
198/// an implicit CPU route.
199#[inline]
200pub fn cuda_selected() -> Result<bool, GpuError> {
201    match global_policy() {
202        GpuPolicy::Off => Ok(false),
203        policy @ (GpuPolicy::Auto | GpuPolicy::Required) => {
204            Ok(device_runtime::GpuRuntime::resolve(policy)?.is_some())
205        }
206    }
207}
208
209/// Joint eligibility state for a GPU kernel at the call site.
210///
211/// Callers construct exactly one variant, which encodes both the compile-time
212/// backend presence and the runtime workload threshold check.  Replacing the
213/// former `(supported: bool, large_enough: bool)` pair removes the possibility
214/// of silently swapping the two flags at a call site: each meaningful state
215/// has exactly one constructor and the `match` inside [`decide`] is total.
216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217pub enum GpuEligibility {
218    /// Vendor backend is not compiled into this build for this kernel.
219    BackendNotCompiled,
220    /// Backend is compiled in, but the workload (n, m, ...) is below the
221    /// runtime threshold for this kernel.
222    WorkloadBelowThreshold,
223    /// Backend is compiled in and the workload is large enough; the only
224    /// remaining gates are policy and runtime probe.
225    Eligible,
226}
227
228impl GpuEligibility {
229    /// Combine the compile-time backend flag with the workload predicate into
230    /// the canonical joint state.  Use this only when you genuinely have two
231    /// independent booleans; otherwise prefer constructing a variant directly.
232    #[inline]
233    pub const fn from_flags(supported: bool, large_enough: bool) -> Self {
234        if !supported {
235            Self::BackendNotCompiled
236        } else if !large_enough {
237            Self::WorkloadBelowThreshold
238        } else {
239            Self::Eligible
240        }
241    }
242}
243
244/// Decide whether a GPU kernel may run. This is deliberately conservative:
245/// with no compiled vendor backend, `auto` returns CPU fallback and `required`
246/// returns an error at the call site through [`GpuDecision::require_supported`].
247pub fn decide(
248    kernel: GpuKernel,
249    eligibility: GpuEligibility,
250) -> Result<GpuDecision, GpuError> {
251    let policy = global_policy();
252    // Auto must consult the actual probed runtime, not only the
253    // compile-time eligibility.  Without this, `decide()` would claim
254    // GPU when the kernel is "compiled in" even though lossless resolution
255    // observed typed absence. Probe faults are returned rather than being
256    // hidden behind the CPU route.
257    let runtime_available = device_runtime::GpuRuntime::resolve(policy)?.is_some();
258    let (use_gpu, reason) = match (policy, eligibility) {
259        (GpuPolicy::Off, _) => (false, "cpu-gpu-policy-off"),
260        (GpuPolicy::Auto, GpuEligibility::BackendNotCompiled) => {
261            (false, "cpu-gpu-backend-not-compiled")
262        }
263        (GpuPolicy::Auto, _) if !runtime_available => (false, "cpu-gpu-runtime-unavailable"),
264        (GpuPolicy::Auto, GpuEligibility::WorkloadBelowThreshold) => {
265            (false, "cpu-workload-below-gpu-threshold")
266        }
267        (GpuPolicy::Auto, GpuEligibility::Eligible) => (true, "gpu-auto-supported"),
268        (GpuPolicy::Required, GpuEligibility::BackendNotCompiled) => {
269            (false, "cpu-gpu-required-unsupported")
270        }
271        // Under `required`, the workload-threshold gate is intentionally bypassed:
272        // the user explicitly asked for GPU regardless of size.
273        (GpuPolicy::Required, GpuEligibility::WorkloadBelowThreshold)
274        | (GpuPolicy::Required, GpuEligibility::Eligible) => (true, "gpu-required-supported"),
275    };
276    Ok(GpuDecision {
277        policy,
278        kernel,
279        use_gpu,
280        reason,
281    })
282}
283
284impl GpuDecision {
285    pub fn require_supported(&self) -> Result<(), String> {
286        if self.policy == GpuPolicy::Required && !self.use_gpu {
287            return Err(format!(
288                "gpu=required requested kernel '{}' but no supported device backend is available ({})",
289                self.kernel.as_str(),
290                self.reason
291            ));
292        }
293        Ok(())
294    }
295
296    pub fn log(self) {
297        log::debug!(
298            "[GPU backend] kernel={} policy={} selected={} reason={}",
299            self.kernel.as_str(),
300            self.policy.as_str(),
301            self.use_gpu,
302            self.reason
303        );
304    }
305}
306
307/// Emit the roadmap-visible kernels at startup/debug time without affecting
308/// numerical execution. This keeps backend coverage auditable as real device
309/// kernels are added incrementally.
310pub fn log_backend_inventory_once() {
311    static LOGGED: OnceLock<()> = OnceLock::new();
312    LOGGED.get_or_init(|| {
313        let compiled_backends = if cfg!(target_os = "linux") {
314            "cuda-dynamic"
315        } else {
316            "none"
317        };
318        log::debug!(
319            "[GPU backend] policy={} compiled_backends={} kernels=dense-matvec,dense-transpose-matvec,dense-xtwx,candidate-screen,dense-solve,matrix-free-pcg,sparse-assembly,spatial-kernel-operator,marginal-slope-rows,reml-trace,final-inference",
320            global_policy().as_str(),
321            compiled_backends
322        );
323    });
324}
325
326#[inline]
327pub fn try_fast_ab(
328    a: ndarray::ArrayView2<'_, f64>,
329    b: ndarray::ArrayView2<'_, f64>,
330) -> Option<ndarray::Array2<f64>> {
331    linalg_dispatch::try_fast_ab(a, b)
332}
333#[inline]
334pub fn try_fast_atb_on_ordinal(
335    ordinal: usize,
336    a: ndarray::ArrayView2<'_, f64>,
337    b: ndarray::ArrayView2<'_, f64>,
338) -> Option<ndarray::Array2<f64>> {
339    linalg_dispatch::try_fast_atb_on_ordinal(ordinal, a, b)
340}
341#[inline]
342pub fn try_fast_av(
343    a: ndarray::ArrayView2<'_, f64>,
344    v: ndarray::ArrayView1<'_, f64>,
345) -> Option<ndarray::Array1<f64>> {
346    linalg_dispatch::try_fast_av(a, v)
347}
348#[inline]
349pub fn try_fast_atv(
350    a: ndarray::ArrayView2<'_, f64>,
351    v: ndarray::ArrayView1<'_, f64>,
352) -> Option<ndarray::Array1<f64>> {
353    linalg_dispatch::try_fast_atv(a, v)
354}
355#[inline]
356pub fn try_fast_ab_broadcast_b_batched(
357    a: ndarray::ArrayView3<'_, f64>,
358    b: ndarray::ArrayView2<'_, f64>,
359) -> Option<ndarray::Array3<f64>> {
360    linalg_dispatch::try_fast_ab_broadcast_b_batched(a, b)
361}
362#[inline]
363pub fn try_fast_abt_strided_batched(
364    a: ndarray::ArrayView3<'_, f64>,
365    b: ndarray::ArrayView3<'_, f64>,
366) -> Option<ndarray::Array3<f64>> {
367    linalg_dispatch::try_fast_abt_strided_batched(a, b)
368}
369#[inline]
370pub fn try_fast_abt_strided_batched_with_policy(
371    a: ndarray::ArrayView3<'_, f64>,
372    b: ndarray::ArrayView3<'_, f64>,
373    policy: GpuPolicy,
374) -> Option<ndarray::Array3<f64>> {
375    linalg_dispatch::try_fast_abt_strided_batched_with_policy(a, b, policy)
376}
377#[inline]
378pub fn try_cholesky_lower_inplace(a: &mut ndarray::Array2<f64>) -> Option<()> {
379    linalg_dispatch::try_cholesky_lower_inplace(a)
380}
381#[inline]
382pub fn try_cholesky_batched_lower_inplace(matrices: &mut [ndarray::Array2<f64>]) -> Option<()> {
383    linalg_dispatch::try_cholesky_batched_lower_inplace(matrices)
384}
385#[inline]
386pub fn try_cholesky_batched_lower_inplace_with_policy(
387    matrices: &mut [ndarray::Array2<f64>],
388    policy: GpuPolicy,
389) -> Option<()> {
390    linalg_dispatch::try_cholesky_batched_lower_inplace_with_policy(matrices, policy)
391}
392#[inline]
393pub fn try_solve_lower_triangular_matrix(
394    lower: ndarray::ArrayView2<'_, f64>,
395    rhs: ndarray::ArrayView2<'_, f64>,
396) -> Option<ndarray::Array2<f64>> {
397    linalg_dispatch::try_solve_lower_triangular_matrix(lower, rhs)
398}
399#[inline]
400pub fn try_solve_upper_triangular_matrix(
401    upper: ndarray::ArrayView2<'_, f64>,
402    rhs: ndarray::ArrayView2<'_, f64>,
403) -> Option<ndarray::Array2<f64>> {
404    linalg_dispatch::try_solve_upper_triangular_matrix(upper, rhs)
405}
406#[cfg(test)]
407mod policy_tests {
408    use super::*;
409
410    #[test]
411    fn parses_canonical_user_gpu_policy_values() {
412        assert_eq!(GpuPolicy::parse("auto"), Some(GpuPolicy::Auto));
413        assert_eq!(GpuPolicy::parse("off"), Some(GpuPolicy::Off));
414        assert_eq!(
415            GpuPolicy::parse("required"),
416            Some(GpuPolicy::Required)
417        );
418        assert_eq!(GpuPolicy::parse("force"), None);
419        assert_eq!(GpuPolicy::parse("cpu"), None);
420        assert_eq!(GpuPolicy::parse(""), None);
421        assert_eq!(GpuPolicy::parse("wat"), None);
422    }
423
424    #[test]
425    fn execution_path_defaults_to_cpu() {
426        use gam_problem::ExecutionPath;
427        // The truthful execution-path classifier must default to the CPU path,
428        // so a result struct that is never told otherwise cannot claim the
429        // device (the original `used_device: bool` defaulted the same way, but
430        // now the "no device" state is a named, non-lying variant).
431        assert_eq!(ExecutionPath::default(), ExecutionPath::Cpu);
432        assert!(!ExecutionPath::Cpu.used_device());
433        assert!(ExecutionPath::GpuResidentFull.used_device());
434    }
435
436    #[test]
437    fn gpu_mode_required_fails_closed_when_device_absent() {
438        use crate::device_runtime::{GpuAvailabilityRef, GpuRuntime};
439        // Off always refuses, regardless of hardware.
440        assert!(GpuRuntime::resolve(GpuPolicy::Off).unwrap().is_none());
441
442        match GpuRuntime::availability() {
443            Ok(GpuAvailabilityRef::Available(_)) => {
444                // On a GPU host both Auto and Required must succeed.
445                assert!(matches!(
446                    GpuRuntime::resolve(GpuPolicy::Required),
447                    Ok(Some(_))
448                ));
449                assert!(matches!(GpuRuntime::resolve(GpuPolicy::Auto), Ok(Some(_))));
450            }
451            Ok(GpuAvailabilityRef::Absent(_)) => {
452                // Fail-closed: Required surfaces a structured error rather than
453                // a silent CPU fallback. Auto alone maps typed absence to None.
454                let required = GpuRuntime::resolve(GpuPolicy::Required);
455                assert!(
456                    matches!(required, Err(GpuError::RequiredDeviceUnavailable { .. })),
457                    "GpuPolicy::Required must fail closed when the device is absent, got {required:?}"
458                );
459                assert!(matches!(GpuRuntime::resolve(GpuPolicy::Auto), Ok(None)));
460            }
461            Err(error) => panic!("GPU probe fault must fail this contract test: {error}"),
462        }
463    }
464
465    #[test]
466    fn pirls_loop_admission_requires_runtime_size_and_known_family() {
467        use crate::policy::{PirlsLoopAdmission, PirlsLoopCurvatureKind, PirlsLoopFamilyKind};
468        let pol = GpuDispatchPolicy::default();
469        let base = PirlsLoopAdmission {
470            n: 80_000,
471            p: 44,
472            family: Some(PirlsLoopFamilyKind::BernoulliLogit),
473            curvature: PirlsLoopCurvatureKind::Fisher,
474            gpu_available: true,
475        };
476        assert!(pol.should_use_gpu_pirls_loop(base));
477        // No runtime → never dispatch.
478        assert!(!pol.should_use_gpu_pirls_loop(PirlsLoopAdmission {
479            gpu_available: false,
480            ..base
481        }));
482        // Below dense-work floor.
483        assert!(!pol.should_use_gpu_pirls_loop(PirlsLoopAdmission { n: 1_000, ..base }));
484        // Small n with large p is admitted because 2*n*p^2 clears the work floor.
485        assert!(pol.should_use_gpu_pirls_loop(PirlsLoopAdmission {
486            n: 2_000,
487            p: 2_048,
488            ..base
489        }));
490        // Below column floor.
491        assert!(!pol.should_use_gpu_pirls_loop(PirlsLoopAdmission { p: 8, ..base }));
492        // Custom family (not in 6 JIT-cached set) declines.
493        assert!(!pol.should_use_gpu_pirls_loop(PirlsLoopAdmission {
494            family: None,
495            ..base
496        }));
497    }
498
499    #[test]
500    fn required_policy_reports_unsupported_kernel() {
501        let decision = GpuDecision {
502            policy: GpuPolicy::Required,
503            kernel: GpuKernel::DenseXtWX,
504            use_gpu: false,
505            reason: "gpu-required-unsupported",
506        };
507        let err = decision.require_supported().unwrap_err();
508        assert!(err.contains("dense-xtwx"));
509        assert!(err.contains("gpu=required"));
510    }
511}