Skip to main content

gam_gpu/
backend_probe.rs

1//! Shared CUDA backend-probe contract for every cudarc-backed module under
2//! `src/gpu/*`.
3//!
4//! Before this module existed, every GPU backend (`bms_flex`,
5//! `survival_flex`, `cubic_bspline_moments`, `cubic_cell`, `pirls_row`,
6//! `sphere`, ...) carried its own near-identical `probe_linux` prologue:
7//!
8//!   1. Resolve the process-wide [`crate::GpuRuntime`] losslessly. Typed hardware
9//!      absence becomes a labelled `DriverLibraryUnavailable`; probe faults
10//!      keep their original [`crate::GpuError`] variant.
11//!   2. Read the runtime's selected device ordinal.
12//!   3. Create (or reuse) the per-ordinal `CudaContext` or fail with a
13//!      `DriverCallFailed { reason: "<module> backend: failed to create
14//!      CUDA context for device N" }`.
15//!   4. Open the context's default `CudaStream`.
16//!   5. Carry the device's compute capability alongside the handles.
17//!
18//! Those five steps are identical apart from the per-module label that gets
19//! woven into the two error messages. Drift between copies meant error
20//! wording, capability handling, context reuse, and stream choice could
21//! diverge module to module. This module hosts the single contract: each
22//! backend now calls [`probe_cuda_backend`] with its label and keeps only
23//! its module caches and optional eager-compilation step.
24//!
25//! The migration is atomic: no backend re-implements the prologue, and
26//! there is no transitional shim.
27
28// `CudaBackendParts` is re-exported alongside the probe entry points: sibling
29// crates (`gam-terms`, `gam-models`, ...) call `probe_cuda_backend` and receive
30// a `CudaBackendParts` value (without ever naming the type), so `probe_cuda_backend`'s
31// public return type must itself be reachable or `-D warnings` rejects the leak
32// (`private_interfaces`). It carries no fields a caller can misuse out of context.
33#[cfg(target_os = "linux")]
34pub use linux::{
35    CachedBackend, CudaBackendContext, CudaBackendParts, probe_backend_with_compile,
36    probe_cuda_backend,
37};
38
39#[cfg(target_os = "linux")]
40mod linux {
41    use crate::device::GpuCapability;
42    use crate::device_cache::{DeviceArena, PtxModuleCache};
43    use crate::device_runtime::{GpuAvailabilityRef, GpuRuntime, cuda_context_for};
44    use crate::gpu_error::GpuError;
45    use cudarc::driver::{CudaContext, CudaStream};
46    use std::sync::{Arc, Mutex};
47
48    /// The handles every cudarc backend shares once the probe succeeds:
49    /// a context on the runtime's selected device, that context's default
50    /// stream, and the device's compute capability. Module-specific
51    /// backends layer their own caches and optional eager compilation on
52    /// top of these.
53    #[derive(Debug)]
54    pub struct CudaBackendParts {
55        pub ctx: Arc<CudaContext>,
56        pub stream: Arc<CudaStream>,
57        pub capability: GpuCapability,
58    }
59
60    /// Probe the process-wide CUDA backend for the calling module.
61    ///
62    /// Resolves the global [`GpuRuntime`], creates (or reuses) the
63    /// [`CudaContext`] for its selected device, opens that context's
64    /// default stream, and returns the trio bundled in [`CudaBackendParts`].
65    /// `label` names the calling module (e.g. `"bms_flex"`) and is woven
66    /// into both failure messages so the uniform contract still attributes
67    /// errors to their originating backend.
68    pub fn probe_cuda_backend(label: &'static str) -> Result<CudaBackendParts, GpuError> {
69        let runtime = match GpuRuntime::availability()? {
70            GpuAvailabilityRef::Available(runtime) => runtime,
71            GpuAvailabilityRef::Absent(reason) => {
72                return Err(GpuError::DriverLibraryUnavailable {
73                    reason: format!("{label} backend: {reason}"),
74                });
75            }
76        };
77        let ordinal = runtime.selected_device().ordinal;
78        let ctx = cuda_context_for(ordinal).ok_or_else(|| {
79            gpu_err!("{label} backend: failed to create CUDA context for device {ordinal}")
80        })?;
81        let stream = ctx.default_stream();
82        let capability = runtime.selected_device().capability.clone();
83        Ok(CudaBackendParts {
84            ctx,
85            stream,
86            capability,
87        })
88    }
89
90    /// Probe the CUDA backend for `label` and run a backend-specific build
91    /// step on the resolved handles.
92    ///
93    /// This is [`probe_cuda_backend`] plus the one piece that genuinely
94    /// differs between backends: the NVRTC compile (and any per-backend cache
95    /// construction). The runtime resolution, context creation, and stream
96    /// selection — together with their uniform, label-attributed error
97    /// messages — live in the shared probe; `build` receives the resolved
98    /// [`CudaBackendParts`] (so it can clone the `Arc<CudaContext>` /
99    /// `Arc<CudaStream>` it needs) and returns the backend's own state `T`.
100    /// A process-wide backend for one kernel family: the context and stream
101    /// from [`probe_cuda_backend`] plus whatever the family builds on top of
102    /// them (compiled modules, device limits), probed and built once and then
103    /// shared by every caller for the life of the process. A failed probe is
104    /// cached too, so a host without a usable device answers every later call
105    /// with the same refusal instead of re-probing.
106    ///
107    /// Declare one per family as a `static` and read it through
108    /// [`CachedBackend::get_or_probe`]; five kernel families used to spell this
109    /// `OnceLock<Result<_, GpuError>>` protocol out by hand (#2470).
110    pub struct CachedBackend<T: 'static> {
111        slot: std::sync::OnceLock<Result<T, GpuError>>,
112    }
113
114    impl<T: 'static> CachedBackend<T> {
115        pub const fn new() -> Self {
116            Self {
117                slot: std::sync::OnceLock::new(),
118            }
119        }
120
121        /// The shared backend, probing and building it on first use.
122        pub fn get_or_probe<F>(
123            &'static self,
124            label: &'static str,
125            build: F,
126        ) -> Result<&'static T, GpuError>
127        where
128            F: FnOnce(CudaBackendParts) -> Result<T, GpuError>,
129        {
130            self.slot
131                .get_or_init(|| build(probe_cuda_backend(label)?))
132                .as_ref()
133                .map_err(GpuError::clone)
134        }
135    }
136
137    impl<T: 'static> Default for CachedBackend<T> {
138        fn default() -> Self {
139            Self::new()
140        }
141    }
142
143    pub fn probe_backend_with_compile<F, T>(label: &'static str, build: F) -> Result<T, GpuError>
144    where
145        F: FnOnce(&CudaBackendParts) -> Result<T, GpuError>,
146    {
147        let parts = probe_cuda_backend(label)?;
148        build(&parts)
149    }
150
151    /// The process-wide device handles every cudarc backend stores after a
152    /// successful probe: the [`CudaContext`], its default [`CudaStream`], the
153    /// lazily NVRTC-compiled [`PtxModuleCache`], and the bucketed
154    /// [`DeviceArena`] of reusable f64 device buffers (held under a `Mutex`
155    /// because large-scale fits dispatch from multiple rayon worker threads; the
156    /// mutex is only held during `alloc` / `release`, not across kernel
157    /// launches). Module-specific backends (`bms_flex`, `survival_flex`, …)
158    /// wrap one of these as their `inner` context so the host-side
159    /// scaffolding (arena pooling, module cache, mutex around alloc) is
160    /// uniform instead of duplicated per backend.
161    pub struct CudaBackendContext {
162        pub ctx: Arc<CudaContext>,
163        pub stream: Arc<CudaStream>,
164        pub module: PtxModuleCache,
165        pub arena: Mutex<DeviceArena>,
166    }
167
168    impl CudaBackendContext {
169        /// Build the stored context from a fresh [`CudaBackendParts`] probe
170        /// result: adopt its context and stream, start an empty module cache
171        /// (the backend's eager-compile step fills it), and an empty device
172        /// arena. The probe's compute `capability` is consumed by the probe
173        /// path itself and is not retained here.
174        pub fn from_parts(parts: CudaBackendParts) -> Self {
175            CudaBackendContext {
176                ctx: parts.ctx,
177                stream: parts.stream,
178                module: PtxModuleCache::new(),
179                arena: Mutex::new(DeviceArena::default()),
180            }
181        }
182    }
183}
184
185#[cfg(all(test, target_os = "linux"))]
186mod tests {
187    use super::probe_cuda_backend;
188    use crate::device_runtime::{GpuAvailabilityRef, GpuRuntime};
189    use crate::gpu_error::GpuError;
190
191    /// Parity: every backend's probe must agree with the shared contract on
192    /// the same device. On a host with no CUDA runtime, the shared probe
193    /// must return the uniform `DriverLibraryUnavailable` carrying the
194    /// caller's label; on a host with a runtime, the probe must resolve the
195    /// *same* selected-device ordinal and compute capability the runtime
196    /// advertises, with a context bound to that ordinal and a usable
197    /// default stream. This is the regression guard that keeps the six
198    /// migrated backends (`bms_flex`, `survival_flex`,
199    /// `cubic_bspline_moments`, `cubic_cell`, `pirls_row`, `sphere`) routed
200    /// through one prologue instead of drifting copies.
201    #[test]
202    fn shared_probe_matches_runtime_device_and_labels_errors() {
203        match GpuRuntime::availability() {
204            Ok(GpuAvailabilityRef::Absent(absence)) => {
205                // No runtime: the shared probe must fail uniformly and
206                // attribute the failure to the supplied label.
207                match probe_cuda_backend("bms_flex") {
208                    Err(GpuError::DriverLibraryUnavailable { reason }) => {
209                        assert_eq!(
210                            reason,
211                            format!("bms_flex backend: {absence}"),
212                            "shared probe must emit the uniform no-runtime message"
213                        );
214                    }
215                    other => panic!(
216                        "expected DriverLibraryUnavailable on a host without a CUDA runtime, \
217                         got {other:?}"
218                    ),
219                }
220            }
221            Ok(GpuAvailabilityRef::Available(runtime)) => {
222                // Runtime present: every label resolves the same selected
223                // device and the same compute capability the runtime
224                // advertises, and the context binds to that ordinal.
225                let expected_ordinal = runtime.selected_device().ordinal;
226                let expected_capability = &runtime.selected_device().capability;
227                for label in [
228                    "bms_flex",
229                    "survival_flex",
230                    "cubic_bspline_moments",
231                    "cubic_cell",
232                    "pirls_row",
233                    "sphere",
234                ] {
235                    let parts = probe_cuda_backend(label)
236                        .unwrap_or_else(|err| panic!("probe for {label} must succeed: {err:?}"));
237                    assert_eq!(
238                        parts.ctx.ordinal(),
239                        expected_ordinal,
240                        "{label}: context must bind the runtime's selected device ordinal"
241                    );
242                    assert_eq!(
243                        &parts.capability, expected_capability,
244                        "{label}: probe capability must match the runtime's selected device"
245                    );
246                    parts
247                        .stream
248                        .synchronize()
249                        .unwrap_or_else(|err| panic!("{label}: default stream must sync: {err:?}"));
250                }
251            }
252            Err(error) => panic!("GPU probe fault must fail this backend contract test: {error}"),
253        }
254    }
255}