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}