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