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