Skip to main content

gam_gpu/
device_runtime.rs

1#[cfg(target_os = "linux")]
2use std::cell::Cell;
3#[cfg(target_os = "linux")]
4use std::collections::HashMap;
5#[cfg(target_os = "linux")]
6use std::panic::{self, AssertUnwindSafe, catch_unwind};
7use std::sync::OnceLock;
8use std::sync::atomic::{AtomicU64, Ordering};
9#[cfg(target_os = "linux")]
10use std::sync::{Arc, Mutex};
11
12use super::device::GpuDeviceInfo;
13use super::gpu_error::GpuError;
14use super::policy::GpuDispatchPolicy;
15#[cfg(target_os = "linux")]
16use cudarc::driver::{CudaContext, result, sys};
17
18#[path = "runtime_diagnostics.rs"]
19pub(crate) mod diagnostics;
20
21#[derive(Clone, Debug)]
22#[must_use]
23pub struct GpuRuntime {
24    /// Highest-scoring probed CUDA device. Existing dispatch code routes
25    /// one-shot kernels through this device.
26    pub device: GpuDeviceInfo,
27    /// All usable CUDA devices discovered at probe time, ordered by score.
28    pub devices: Vec<GpuDeviceInfo>,
29    pub policy: GpuDispatchPolicy,
30    pub memory_budget_bytes: usize,
31}
32
33static CPU_REASON: OnceLock<String> = OnceLock::new();
34
35/// A genuine reason CUDA cannot exist on this host. These states are distinct
36/// from [`GpuError`]: absence is an expected hardware/platform fact under
37/// [`GpuPolicy::Auto`](super::GpuPolicy::Auto), whereas an error means a CUDA
38/// installation or device that was present failed to initialize correctly.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub enum GpuAbsence {
41    UnsupportedPlatform,
42    DriverUnavailable { reason: String },
43    NoDevice { reason: String },
44}
45
46impl std::fmt::Display for GpuAbsence {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::UnsupportedPlatform => {
50                f.write_str("CUDA support is unavailable on this platform")
51            }
52            Self::DriverUnavailable { reason } | Self::NoDevice { reason } => f.write_str(reason),
53        }
54    }
55}
56
57/// Lossless result of the process-wide CUDA probe.
58#[derive(Debug)]
59pub enum GpuAvailability {
60    Available(GpuRuntime),
61    Absent(GpuAbsence),
62}
63
64/// Borrowed lossless availability view returned from the one-time cache.
65#[derive(Clone, Copy, Debug)]
66pub enum GpuAvailabilityRef<'a> {
67    Available(&'a GpuRuntime),
68    Absent(&'a GpuAbsence),
69}
70
71/// Process-wide count of lossless runtime-resolution calls.
72///
73/// Incremented on every [`GpuRuntime::availability`] call before the one-time probe
74/// runs — so it counts the moments at which the device probe (and thus CUDA
75/// primary-context creation on each GPU, `cuDevicePrimaryCtxRetain`) could be
76/// triggered. Size-gated accessors that short-circuit for CPU-sized problems
77/// deliberately do not resolve availability, so a test can pin this counter across
78/// such a call and prove the CPU-sized decision path made ZERO driver contact.
79///
80/// Cross-platform (not `cfg(target_os = "linux")`) so the laziness/ordering
81/// contract is testable on CUDA-less hosts: even where the probe itself is a
82/// no-op, the invariant we verify is that the size check precedes resolution.
83static RESOLUTION_CALLS: AtomicU64 = AtomicU64::new(0);
84
85#[cfg(target_os = "linux")]
86thread_local! {
87    static CUDARC_RECOVERY_ACTIVE: Cell<bool> = const { Cell::new(false) };
88}
89
90#[cfg(target_os = "linux")]
91fn panic_message(payload: &(dyn std::any::Any + Send)) -> Option<&str> {
92    payload
93        .downcast_ref::<&'static str>()
94        .copied()
95        .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
96}
97
98/// Suppress loader diagnostics only while this thread can recover them.
99/// An unguarded loader panic must still reach the application's panic hook.
100#[cfg(target_os = "linux")]
101fn install_cudarc_panic_filter() {
102    static HOOK_INSTALLED: OnceLock<()> = OnceLock::new();
103    HOOK_INSTALLED.get_or_init(|| {
104        let prior = panic::take_hook();
105        panic::set_hook(Box::new(move |info| {
106            if cfg!(panic = "unwind")
107                && CUDARC_RECOVERY_ACTIVE.with(Cell::get)
108                && panic_message(info.payload())
109                    .is_some_and(|message| message.starts_with("Unable to dynamically load"))
110            {
111                return;
112            }
113            prior(info);
114        }));
115    });
116}
117
118/// Own both recovery and diagnostic suppression, including nested calls.
119/// Unrelated panics retain their normal hook and unwind behavior.
120#[cfg(target_os = "linux")]
121fn catch_cudarc<T>(call: impl FnOnce() -> T) -> Result<T, String> {
122    install_cudarc_panic_filter();
123    struct RecoveryScope(bool);
124    impl Drop for RecoveryScope {
125        fn drop(&mut self) {
126            CUDARC_RECOVERY_ACTIVE.with(|active| active.set(self.0));
127        }
128    }
129    let scope = RecoveryScope(CUDARC_RECOVERY_ACTIVE.with(|active| active.replace(true)));
130    let outcome = catch_unwind(AssertUnwindSafe(call));
131    drop(scope);
132    match outcome {
133        Ok(value) => Ok(value),
134        Err(payload) => match panic_message(payload.as_ref()) {
135            Some(message) if message.starts_with("Unable to dynamically load") => {
136                Err(message.to_owned())
137            }
138            _ => panic::resume_unwind(payload),
139        },
140    }
141}
142
143impl GpuRuntime {
144    pub fn probe() -> Result<GpuAvailability, GpuError> {
145        #[cfg(target_os = "linux")]
146        {
147            catch_cudarc(Self::probe_devices)
148                .map_err(|reason| GpuError::RuntimeDependencyUnavailable { reason })?
149        }
150        #[cfg(not(target_os = "linux"))]
151        Self::probe_devices()
152    }
153
154    fn probe_devices() -> Result<GpuAvailability, GpuError> {
155        #[cfg(not(target_os = "linux"))]
156        {
157            let reason = "CUDA support not compiled into this build";
158            Self::record_cpu_reason(reason);
159            diagnostics::log_cuda_disabled(reason);
160            return Ok(GpuAvailability::Absent(GpuAbsence::UnsupportedPlatform));
161        }
162
163        #[cfg(target_os = "linux")]
164        {
165            // `cudarc 0.19`'s entry points lazily initialize the CUDA driver
166            // through generated `culib()` helpers. On CPU-only Linux hosts the
167            // first such call emits `panic_no_lib_found` before unwinding, which
168            // polluted large-scale logs even when the panic was later caught and the
169            // fit fell back to CPU. Keep the preflight completely outside
170            // cudarc: use gam's own `libloading` probe first, and only touch
171            // cudarc after the platform loader can open `libcuda`.
172            //
173            // The preflight does not always agree with cudarc's own loader
174            // candidate list (e.g. large-scale workbench images expose CUDA *runtime*
175            // stub libraries under `/usr/local/cuda-*/targets/.../lib` but no
176            // driver `libcuda.so` in any loader path), so we additionally
177            // install a panic-hook filter that suppresses cudarc's
178            // `panic_no_lib_found` message and wrap every cudarc entry point
179            // below in `catch_unwind` to convert the panic into a typed
180            // `GpuError::DriverCallFailed` instead.
181            // #1017 probe-first fix: establish cudarc's primary context P and
182            // initialize the CUDA runtime ON IT as the VERY FIRST CUDA action -- before
183            // gam's libloading libcuda preload, the compute-lib dlopens, and device_count.
184            // The clean cuda_context_for-first path works; the probe-first path failed
185            // because a pre-context CUDA touch left the runtime bound to a non-P context,
186            // so later cuBLAS/cuSOLVER handle creation on the P-stream returned
187            // NOT_INITIALIZED. Making cuda_context_for the first action replicates the
188            // working clean path (CudaContext::new loads libcuda + retains the primary +
189            // ensure runs the runtime init); on a CPU-only host it returns None cleanly
190            // via the panic filter + catch_unwind, and the preload check below still runs.
191            let primary_ready = cuda_context_for(0).is_some();
192            log::trace!("[GPU] probe pre-init primary context + runtime: {primary_ready}");
193            match crate::driver::preload_cuda_driver() {
194                Ok(()) => {}
195                Err(GpuError::DriverLibraryUnavailable { reason }) => {
196                    Self::record_cpu_reason(reason.clone());
197                    log::info!("[GPU] CUDA acceleration disabled: {reason}");
198                    diagnostics::log_cuda_disabled(&reason);
199                    return Ok(GpuAvailability::Absent(GpuAbsence::DriverUnavailable {
200                        reason,
201                    }));
202                }
203                Err(error) => return Err(error),
204            }
205
206            // Driver-only environments (e.g. large-scale workbench images that expose
207            // `libcuda.so.1` but ship no cuBLAS/cuSOLVER/cuSPARSE) used to slip
208            // past the libcuda preflight, enable the runtime, and then panic
209            // out of cudarc's `panic_no_lib_found` on the first `CudaBlas::new`
210            // — the panic crossed the PyO3 FFI boundary as a
211            // `ValueError: fit_table panicked inside Rust boundary: Unable to
212            // dynamically load the "cublas" shared library`. The compute
213            // libraries are dispatch-critical (every cuBLAS / cuSOLVER /
214            // cuSPARSE site under `src/gpu/` calls `CudaBlas::new` /
215            // `DnHandle::new` / cusparse handle creation eagerly during
216            // workspace allocation), so we refuse to advertise GPU unless all
217            // three load cleanly here.
218            for stem in ["cublas", "cusolver", "cusparse"] {
219                if let Err(error) = crate::driver::require_cuda_compute_library(stem) {
220                    let reason = format!("lib{stem} unavailable: {error}");
221                    Self::record_cpu_reason(reason.clone());
222                    log::info!("[GPU] CUDA acceleration disabled: {reason}");
223                    diagnostics::log_cuda_disabled(&reason);
224                    return Err(GpuError::RuntimeDependencyUnavailable { reason });
225                }
226            }
227
228            // cudarc 0.19's `culib()` panics via `panic_no_lib_found` when its
229            // own (separate from gam's) dynamic-loader candidate list cannot
230            // find libcuda — this can happen even after our `preload_cuda_driver`
231            // succeeds, for example if our probe loaded a CUDA stub library but
232            // cudarc's loader searches a disjoint set of names. Convert any such
233            // panic into a typed probe failure so the runtime cleanly disables
234            // CUDA and the CPU fallback proceeds without alarming stderr noise.
235            let device_count = match catch_cudarc(CudaContext::device_count) {
236                Err(_) => {
237                    return Err(GpuError::DriverCallFailed {
238                        reason: "cudarc failed after the CUDA driver preflight succeeded"
239                            .to_string(),
240                    });
241                }
242                Ok(Ok(count)) => count,
243                Ok(Err(error)) => {
244                    // `device_count` performs `cuInit`, so this is the first
245                    // moment the host's kernel driver actually answers. A
246                    // refusal that is an ENVIRONMENT fact (userland CUDA
247                    // libraries with no matching kernel driver — the container
248                    // / CPU-node case #2267 hit as
249                    // `CUDA_ERROR_SYSTEM_DRIVER_MISMATCH`) is typed absence:
250                    // Auto falls back to CPU, Required still refuses with the
251                    // same diagnosis. Anything else stays a probe fault.
252                    if let Some(absence) = absence_from_driver_init_error(&error) {
253                        let reason = absence.to_string();
254                        Self::record_cpu_reason(reason.clone());
255                        log::info!("[GPU] CUDA acceleration disabled: {reason}");
256                        diagnostics::log_cuda_disabled(&reason);
257                        return Ok(GpuAvailability::Absent(absence));
258                    }
259                    return Err(GpuError::DriverCallFailed {
260                        reason: error.to_string(),
261                    });
262                }
263            };
264            if device_count <= 0 {
265                let reason = "CUDA driver reported no devices";
266                Self::record_cpu_reason(reason);
267                diagnostics::log_cuda_disabled(reason);
268                return Ok(GpuAvailability::Absent(GpuAbsence::NoDevice {
269                    reason: reason.to_string(),
270                }));
271            }
272
273            let mut devices = Vec::new();
274            for ordinal in
275                0..usize::try_from(device_count).map_err(|_| GpuError::DriverCallFailed {
276                    reason: "negative CUDA device count".into(),
277                })?
278            {
279                let ctx = cuda_context_for(ordinal).ok_or_else(|| {
280                    gpu_err!("failed to create CUDA context for device {ordinal}")
281                })?;
282                catch_cudarc(|| ctx.bind_to_thread())
283                    .map_err(|_| GpuError::DriverCallFailed {
284                        reason: "CUDA context binding panicked after driver discovery".to_string(),
285                    })?
286                    .map_err(|err| GpuError::DriverCallFailed {
287                        reason: err.to_string(),
288                    })?;
289                devices.push(catch_cudarc(|| cuda_device_info(ordinal, &ctx)).map_err(
290                    |_| GpuError::DriverCallFailed {
291                        reason:
292                            "CUDA device inspection panicked after driver discovery".to_string(),
293                    },
294                )??);
295            }
296
297            devices.sort_by(|a, b| b.score().total_cmp(&a.score()));
298            let Some(device) = devices.first().cloned() else {
299                Self::record_cpu_reason("CUDA driver reported no usable devices");
300                diagnostics::log_cuda_disabled("CUDA driver reported no usable devices");
301                return Ok(GpuAvailability::Absent(GpuAbsence::NoDevice {
302                    reason: "CUDA driver reported no usable devices".to_string(),
303                }));
304            };
305
306            let policy = crate::calibration::calibrated_policy_for_device(&device);
307            let memory_budget_bytes = device.memory_budget_bytes();
308            diagnostics::log_cuda_enabled(&device, &policy);
309            diagnostics::log_cuda_pool(&devices);
310
311            Ok(GpuAvailability::Available(Self {
312                device,
313                devices,
314                policy,
315                memory_budget_bytes,
316            }))
317        }
318    }
319
320    /// Return the cached probe outcome without collapsing faults into absence.
321    pub fn availability() -> Result<GpuAvailabilityRef<'static>, GpuError> {
322        // Record every entry BEFORE the `OnceLock` probe, so the size-gated
323        // accessors below (which never reach this point for CPU-sized problems)
324        // can be proven not to have triggered a device probe / context creation.
325        RESOLUTION_CALLS.fetch_add(1, Ordering::Relaxed);
326        static RUNTIME: OnceLock<Result<GpuAvailability, GpuError>> = OnceLock::new();
327        let cached = RUNTIME.get_or_init(|| {
328            let outcome = Self::probe();
329            if let Err(error) = &outcome {
330                let reason = error.to_string();
331                Self::record_cpu_reason(reason.clone());
332                diagnostics::log_cuda_disabled(&reason);
333            }
334            // Install the dense-GEMM dispatch hook exactly when a usable
335            // device was probed. Without this, `gam_linalg::faer_ndarray::fast_ab`
336            // (and the `fast_atb`/`fast_av`/`xt_diag_x` family) never sees a
337            // dispatcher — `gpu_dispatch()` stays `None` — so every dense
338            // product in the engine silently runs on the CPU even when the
339            // V100 is present and the workload clears the policy flop floor.
340            // The hook is a first-write-wins `OnceLock` keyed only on the
341            // presence of a runtime; registering it here, inside the same
342            // `get_or_init` that decides the runtime, guarantees it is
343            // installed before any `fast_ab` caller can observe an available
344            // runtime. The policy gate inside each `try_*` still decides
345            // CPU-vs-GPU per call, so small products are unaffected.
346            if matches!(&outcome, Ok(GpuAvailability::Available(_))) {
347                gam_linalg::gpu_hook::register_gpu_dispatch(Box::new(
348                    super::linalg_dispatch::CudaGemmDispatch,
349                ));
350            }
351            outcome
352        });
353        match cached {
354            Ok(GpuAvailability::Available(runtime)) => Ok(GpuAvailabilityRef::Available(runtime)),
355            Ok(GpuAvailability::Absent(reason)) => Ok(GpuAvailabilityRef::Absent(reason)),
356            Err(error) => Err(error.clone()),
357        }
358    }
359
360    /// Resolve CUDA under an explicit policy. `Ok(None)` is reserved for a
361    /// genuine absence under Auto/Off; probe faults always remain `Err`, and
362    /// Required converts absence into `RequiredDeviceUnavailable`.
363    pub fn resolve(policy: super::GpuPolicy) -> Result<Option<&'static Self>, GpuError> {
364        if policy == super::GpuPolicy::Off {
365            return Ok(None);
366        }
367        Self::resolve_availability(policy, Self::availability())
368    }
369
370    fn resolve_availability<'a>(
371        policy: super::GpuPolicy,
372        availability: Result<GpuAvailabilityRef<'a>, GpuError>,
373    ) -> Result<Option<&'a Self>, GpuError> {
374        match availability? {
375            GpuAvailabilityRef::Available(runtime) => Ok(Some(runtime)),
376            GpuAvailabilityRef::Absent(_reason) if policy == super::GpuPolicy::Auto => Ok(None),
377            GpuAvailabilityRef::Absent(reason) => Err(GpuError::RequiredDeviceUnavailable {
378                reason: reason.to_string(),
379            }),
380        }
381    }
382
383    /// Resolve CUDA under Required semantics and return the device handle.
384    pub fn require() -> Result<&'static Self, GpuError> {
385        Self::resolve(super::GpuPolicy::Required)?.ok_or_else(|| {
386            GpuError::RequiredDeviceUnavailable {
387                reason: "required CUDA runtime resolved to an absent state".to_string(),
388            }
389        })
390    }
391
392    /// Size-gated [`Self::resolve`] for independent fused row kernels.
393    ///
394    /// Batches below
395    /// [`GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N`] cannot be
396    /// admitted by either the default or any device-calibrated policy. Refuse
397    /// them before availability resolution so a CPU-sized first call does not
398    /// create CUDA contexts and run calibration merely to learn that it should
399    /// stay on the CPU. At and above the universal floor, the concrete
400    /// runtime's calibrated policy remains authoritative.
401    pub fn resolve_if_fused_batch_exceeds_floor(
402        policy: super::GpuPolicy,
403        rows: usize,
404    ) -> Result<Option<&'static Self>, GpuError> {
405        if rows < GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N {
406            return Ok(None);
407        }
408        Self::resolve(policy)
409    }
410
411    #[must_use]
412    pub fn policy(&self) -> &GpuDispatchPolicy {
413        &self.policy
414    }
415
416    #[must_use]
417    pub fn selected_device(&self) -> &GpuDeviceInfo {
418        &self.device
419    }
420
421    #[must_use]
422    pub(crate) fn cpu_reason() -> Option<&'static str> {
423        CPU_REASON.get().map(String::as_str)
424    }
425
426    fn record_cpu_reason(reason: impl Into<String>) {
427        // First reason wins: the earliest fallback is the one that explains the
428        // rest. A later reason is dropped deliberately, and visibly.
429        if let Err(dropped) = CPU_REASON.set(reason.into()) {
430            log::debug!(
431                "CPU fallback reason already recorded as {:?}; keeping it and dropping '{dropped}'",
432                CPU_REASON.get().map(String::as_str)
433            );
434        }
435    }
436}
437
438/// Classify a CUDA driver-*initialization* failure that is a fact about the
439/// host environment rather than a fault of a device that was present.
440///
441/// `cuInit` is the first call the kernel driver answers. The codes below all
442/// mean "CUDA cannot work on this host as configured" — a loaded `libcuda`
443/// userland with a missing, older, or mismatched kernel driver, a linker stub
444/// standing in for the real library, or no attached device. Those states are
445/// [`GpuAbsence`] by this module's own definition (absence is an expected
446/// hardware/platform fact under `GpuPolicy::Auto`): container images and CPU
447/// nodes routinely carry CUDA userland libraries they cannot back with a
448/// driver, and a fit under Auto must fall back to CPU there instead of dying
449/// inside runtime resolution (#2267). Every other code — illegal address,
450/// out-of-memory, ECC faults, ... — still means "a CUDA installation that was
451/// present failed", and stays a probe fault.
452#[cfg(target_os = "linux")]
453fn absence_from_driver_init_error(error: &result::DriverError) -> Option<GpuAbsence> {
454    use sys::cudaError_enum as CudaErrorCode;
455    // Format the raw enum code, NEVER the DriverError itself: cudarc's
456    // Display/Debug for DriverError resolve the error string through its
457    // dynamic loader (`culib()`), which panics via `panic_no_lib_found` on
458    // exactly the driverless hosts this classifier exists for. The enum's
459    // derived Debug is a pure Rust name and is safe everywhere.
460    let code = error.0;
461    let classification = match code {
462        CudaErrorCode::CUDA_ERROR_NO_DEVICE => {
463            return Some(GpuAbsence::NoDevice {
464                reason: format!(
465                    "CUDA driver initialized but reports no attached device ({code:?})"
466                ),
467            });
468        }
469        CudaErrorCode::CUDA_ERROR_STUB_LIBRARY => {
470            "the loaded libcuda is a linker stub, not a real driver"
471        }
472        // NOTE: there is deliberately no INSUFFICIENT_DRIVER arm — that code
473        // (`cudaErrorInsufficientDriver`) exists only in the CUDA *runtime*
474        // API; the driver API reports the userland/kernel version split as
475        // `CUDA_ERROR_SYSTEM_DRIVER_MISMATCH` below.
476        CudaErrorCode::CUDA_ERROR_SYSTEM_NOT_READY => {
477            "the CUDA system is not ready (kernel driver or fabric daemon not running)"
478        }
479        CudaErrorCode::CUDA_ERROR_SYSTEM_DRIVER_MISMATCH => {
480            "the CUDA userland libraries do not match the host kernel driver"
481        }
482        CudaErrorCode::CUDA_ERROR_COMPAT_NOT_SUPPORTED_ON_DEVICE => {
483            "CUDA forward-compatibility mode is not supported on the visible device"
484        }
485        _ => return None,
486    };
487    Some(GpuAbsence::DriverUnavailable {
488        reason: format!("CUDA initialization refused: {classification} ({code:?})"),
489    })
490}
491
492/// Make the CUDA **runtime** API usable on `ordinal`.
493///
494/// gam drives the GPU through the CUDA *driver* API (cudarc [`CudaContext`]),
495/// which materialises the driver primary context but never selects a device for
496/// the CUDA *runtime* API. cuBLAS / cuSOLVER are runtime-based, so `cublasCreate`
497/// / `cusolverDnCreate` return `CUBLAS_STATUS_NOT_INITIALIZED` /
498/// `CUSOLVER_STATUS_NOT_INITIALIZED` until the runtime has a current device —
499/// which silently disables *every* GPU linear-algebra path (the dispatch sites
500/// map the handle error to `Unavailable` and fall back to CPU). We select the
501/// device on the calling host thread (cheap, idempotent) and force one-time
502/// runtime primary-context materialisation per device via the canonical
503/// `cudaMalloc`/`cudaFree` idiom, so every downstream handle creation succeeds.
504#[cfg(target_os = "linux")]
505fn ensure_cuda_runtime_device(ordinal: usize) {
506    let Ok(o) = i32::try_from(ordinal) else {
507        return;
508    };
509    // SAFETY: the `runtime` cudarc feature is enabled; cudaSetDevice on a valid
510    // ordinal is idempotent and per-host-thread.
511    let set_rc = unsafe { cudarc::runtime::sys::cudaSetDevice(o) };
512    log::trace!("[GPU] runtime cudaSetDevice({o}) -> {set_rc:?}");
513    // Materialise the runtime primary context for this device: cuBLAS/cuSOLVER
514    // `*Create` use whatever context is current at creation time, so the runtime
515    // device must be selected and its primary context materialised before a
516    // handle is made. A 256-byte allocate-then-free is the canonical,
517    // ~microsecond way to force it. This is invoked exactly once per (thread,
518    // ordinal) by `bind_and_touch_runtime` — the NOT_INITIALIZED condition it
519    // repairs is per-thread-per-device and does NOT re-arm per call once the
520    // primary context is current and the runtime is materialised on the thread.
521    let mut p: *mut core::ffi::c_void = core::ptr::null_mut();
522    // SAFETY: forces runtime primary-context creation on the current device.
523    let malloc_rc = unsafe { cudarc::runtime::sys::cudaMalloc(&mut p as *mut _ as *mut _, 256) };
524    log::trace!("[GPU] runtime cudaMalloc -> {malloc_rc:?}");
525    if !p.is_null() {
526        // SAFETY: `p` is the live device allocation returned just above.
527        let free_rc = unsafe { cudarc::runtime::sys::cudaFree(p) };
528        log::trace!("[GPU] runtime cudaFree -> {free_rc:?}");
529    }
530}
531
532#[cfg(target_os = "linux")]
533thread_local! {
534    /// The device ordinal whose primary context is bound as THIS thread's
535    /// current context AND whose runtime primary context has already been
536    /// materialised on this thread. `Some(ordinal)` means the last
537    /// [`cuda_context_for`] touch on this thread was `ordinal` and nothing has
538    /// switched it since, so the per-call `bind_to_thread` + runtime
539    /// materialisation can be skipped.
540    ///
541    /// Switching to a different ordinal (or the initial `None`) invalidates the
542    /// memo and forces a full rebind + re-materialisation, so the per-thread-
543    /// per-device NOT_INITIALIZED repair (#1017) is preserved exactly: the
544    /// condition it fixes is arm-once-per-(thread, device), and a memo keyed on
545    /// the thread's currently-bound ordinal only skips work when that same
546    /// ordinal is already current — i.e. when neither the driver context nor the
547    /// runtime device could have drifted.
548    static BOUND_RUNTIME_ORDINAL: Cell<Option<usize>> = const { Cell::new(None) };
549}
550
551/// Bind cudarc's primary context for `ordinal` current on this thread and
552/// materialise the runtime primary context on it — memoised once per (thread,
553/// ordinal).
554///
555/// The bind + runtime touch exist to repair the probe-first
556/// CUBLAS/CUSOLVER_STATUS_NOT_INITIALIZED bug: on a fresh solve thread the
557/// cached-context path would let the CUDA runtime initialise its OWN device
558/// context, so a later `cublasCreate`/`cusolverDnCreate` on the primary-context
559/// stream fails. Binding the primary context current and forcing runtime
560/// materialisation on the SAME context before returning fixes it. That repair
561/// is durable per (thread, ordinal); it does not re-arm per call. So when this
562/// thread's current context is already `ordinal` we skip the bind and the
563/// 256-byte cudaMalloc/cudaFree entirely, removing the per-call driver tax while
564/// preserving the invariant — a switch to any other ordinal re-runs the full
565/// repair.
566#[cfg(target_os = "linux")]
567fn bind_and_touch_runtime(ordinal: usize, ctx: &Arc<CudaContext>) {
568    if BOUND_RUNTIME_ORDINAL.with(Cell::get) == Some(ordinal) {
569        return;
570    }
571    let bound = catch_cudarc(|| ctx.bind_to_thread());
572    log::trace!(
573        "[GPU] cuda_context_for bind ok={} ordinal={ordinal}",
574        matches!(bound, Ok(Ok(())))
575    );
576    ensure_cuda_runtime_device(ordinal);
577    // Latch the memo only after a SUCCESSFUL bind: a failed bind left the
578    // thread's current context indeterminate, so the next call must retry the
579    // full repair rather than assume `ordinal` is current.
580    if matches!(bound, Ok(Ok(()))) {
581        BOUND_RUNTIME_ORDINAL.with(|c| c.set(Some(ordinal)));
582    }
583}
584
585#[cfg(target_os = "linux")]
586pub fn cuda_context_for(ordinal: usize) -> Option<Arc<CudaContext>> {
587    static CONTEXTS: OnceLock<Mutex<HashMap<usize, Arc<CudaContext>>>> = OnceLock::new();
588    let contexts = CONTEXTS.get_or_init(|| Mutex::new(HashMap::new()));
589    if let Some(ctx) = contexts.lock().ok()?.get(&ordinal).cloned() {
590        bind_and_touch_runtime(ordinal, &ctx);
591        return Some(ctx);
592    }
593    // cudarc 0.19 panics from `panic_no_lib_found` if its loader fails to
594    // locate libcuda. Demote that to `None` so the runtime probe surfaces a
595    // typed `DriverUnavailable` rather than tearing down the worker thread.
596    let ctx = catch_cudarc(|| CudaContext::new(ordinal)).ok()?.ok()?;
597    let out = {
598        let mut guard = contexts.lock().ok()?;
599        guard.entry(ordinal).or_insert_with(|| ctx.clone()).clone()
600    };
601    // CudaContext::new already bound the primary context, but the HashMap may return
602    // an entry created on another thread; the memoised bind rebinds so the primary
603    // context is current on THIS thread before the runtime touch (same probe-first
604    // NOT_INITIALIZED guard) on the first touch, and is a no-op thereafter.
605    bind_and_touch_runtime(ordinal, &out);
606    Some(out)
607}
608
609#[cfg(target_os = "linux")]
610fn cuda_device_info(ordinal: usize, ctx: &CudaContext) -> Result<GpuDeviceInfo, GpuError> {
611    result::init().map_err(|err| GpuError::DriverCallFailed {
612        reason: err.to_string(),
613    })?;
614    let device =
615        result::device::get(
616            i32::try_from(ordinal).map_err(|_| GpuError::DriverCallFailed {
617                reason: "device ordinal overflow".into(),
618            })?,
619        )
620        .map_err(|err| GpuError::DriverCallFailed {
621            reason: err.to_string(),
622        })?;
623    let attr = |attribute| -> Result<i32, GpuError> {
624        // SAFETY: device comes from cudarc's validated device::get.
625        unsafe { result::device::get_attribute(device, attribute) }.map_err(|err| {
626            GpuError::DriverCallFailed {
627                reason: err.to_string(),
628            }
629        })
630    };
631    let (free_mem_bytes, total_mem_bytes) =
632        ctx.mem_get_info()
633            .map_err(|err| GpuError::DriverCallFailed {
634                reason: err.to_string(),
635            })?;
636    let major = attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR)?;
637    let minor = attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR)?;
638    Ok(GpuDeviceInfo {
639        ordinal,
640        name: result::device::get_name(device).unwrap_or_else(|err| {
641            log::debug!(
642                "CUDA device {ordinal}: name query failed ({err}); using a positional label"
643            );
644            format!("CUDA device {ordinal}")
645        }),
646        capability: super::device::GpuCapability::from_compute_capability(major, minor),
647        sm_count: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT)?,
648        max_threads_per_sm: attr(
649            sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR,
650        )?,
651        max_shared_mem_per_block: attr(
652            sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK,
653        )
654        .unwrap_or(0) as usize,
655        l2_cache_bytes: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_L2_CACHE_SIZE)
656            .unwrap_or(0) as usize,
657        total_mem_bytes,
658        free_mem_bytes,
659        ecc_enabled: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_ECC_ENABLED)
660            .unwrap_or(0)
661            != 0,
662        integrated: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_INTEGRATED).unwrap_or(0)
663            != 0,
664        mig_mode: false,
665    })
666}
667
668#[cfg(test)]
669mod policy_resolution_contract_tests {
670    use super::*;
671    use crate::GpuPolicy;
672
673    /// Exercise the installed hook in fresh processes so other parallel tests
674    /// cannot replace it or hide a diagnostic in libtest's output capture.
675    #[cfg(target_os = "linux")]
676    #[test]
677    fn cudarc_loader_panic_diagnostics_follow_recovery_scope() {
678        const CHILD_MODE_PREFIX: &str = "__gam_cudarc_child_";
679        const LOADER_PANIC: &str = "Unable to dynamically load synthetic CUDA library";
680        if let Some(mode) = std::env::args()
681            .find_map(|argument| argument.strip_prefix(CHILD_MODE_PREFIX).map(str::to_owned))
682        {
683            install_cudarc_panic_filter();
684            match mode.as_str() {
685                "caught" => {
686                    assert_eq!(
687                        catch_cudarc::<()>(|| panic!("{LOADER_PANIC}")),
688                        Err(LOADER_PANIC.into()),
689                    );
690                    assert!(!CUDARC_RECOVERY_ACTIVE.with(Cell::get));
691                }
692                "nested" => {
693                    let outer = catch_cudarc::<()>(|| {
694                        assert!(catch_cudarc::<()>(|| panic!("{LOADER_PANIC}")).is_err());
695                        assert!(CUDARC_RECOVERY_ACTIVE.with(Cell::get));
696                        panic!("{LOADER_PANIC}");
697                    });
698                    assert_eq!(outer, Err(LOADER_PANIC.into()));
699                    assert!(!CUDARC_RECOVERY_ACTIVE.with(Cell::get));
700                }
701                "after" => {
702                    assert!(catch_cudarc::<()>(|| panic!("{LOADER_PANIC}")).is_err());
703                    panic!("{LOADER_PANIC}");
704                }
705                "other_thread" => {
706                    catch_cudarc(|| {
707                        assert!(
708                            std::thread::spawn(|| panic!("{LOADER_PANIC}"))
709                                .join()
710                                .is_err()
711                        );
712                    })
713                    .expect("a different thread's panic must not enter this recovery");
714                }
715                "unrelated" => {
716                    catch_cudarc::<()>(|| panic!("unrelated failure"))
717                        .expect("unrelated panics must unwind");
718                }
719                "unguarded" => panic!("{LOADER_PANIC}"),
720                _ => panic!("unknown subprocess mode: {mode}"),
721            }
722            return;
723        }
724        for (mode, succeeds, diagnostic) in [
725            ("caught", true, None),
726            ("nested", true, None),
727            ("after", false, Some(LOADER_PANIC)),
728            ("other_thread", true, Some(LOADER_PANIC)),
729            ("unrelated", false, Some("unrelated failure")),
730            ("unguarded", false, Some(LOADER_PANIC)),
731        ] {
732            let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
733                .args([
734                    "--exact",
735                    "device_runtime::policy_resolution_contract_tests::cudarc_loader_panic_diagnostics_follow_recovery_scope",
736                    "--nocapture",
737                ])
738                // A skip filter that matches no test carries the child mode
739                // through libtest's argument parser without environment state.
740                .args(["--skip", &format!("{CHILD_MODE_PREFIX}{mode}")])
741                .output()
742                .expect("run hook regression subprocess");
743            let stderr = String::from_utf8_lossy(&output.stderr);
744            assert_eq!(output.status.success(), succeeds, "mode={mode}: {stderr}");
745            assert!(String::from_utf8_lossy(&output.stdout).contains("running 1 test"));
746            match diagnostic {
747                Some(message) => assert!(stderr.contains(message), "mode={mode}: {stderr}"),
748                None => assert!(stderr.is_empty(), "mode={mode}: {stderr}"),
749            }
750        }
751    }
752
753    #[test]
754    fn auto_maps_only_typed_absence_to_none() {
755        let absence = GpuAbsence::NoDevice {
756            reason: "synthetic device-free absence".to_string(),
757        };
758        let resolved = GpuRuntime::resolve_availability(
759            GpuPolicy::Auto,
760            Ok(GpuAvailabilityRef::Absent(&absence)),
761        )
762        .expect("typed absence is expected under Auto");
763        assert!(resolved.is_none());
764    }
765
766    #[test]
767    fn required_turns_only_typed_absence_into_required_unavailable() {
768        let absence = GpuAbsence::DriverUnavailable {
769            reason: "synthetic missing driver".to_string(),
770        };
771        let error = GpuRuntime::resolve_availability(
772            GpuPolicy::Required,
773            Ok(GpuAvailabilityRef::Absent(&absence)),
774        )
775        .expect_err("Required must reject typed absence");
776        assert!(matches!(
777            error,
778            GpuError::RequiredDeviceUnavailable { ref reason }
779                if reason == "synthetic missing driver"
780        ));
781    }
782
783    /// #2267: a CUDA userland whose kernel driver is missing or mismatched is
784    /// an environment fact. `cuInit`-boundary refusals of that class must be
785    /// typed absence — Auto proceeds on CPU, Required refuses with the same
786    /// diagnosis — never a probe fault that kills the fit under Auto.
787    #[cfg(target_os = "linux")]
788    #[test]
789    fn driver_mismatch_at_init_is_typed_absence_not_a_fault() {
790        for code in [
791            sys::cudaError_enum::CUDA_ERROR_SYSTEM_DRIVER_MISMATCH,
792            sys::cudaError_enum::CUDA_ERROR_STUB_LIBRARY,
793            sys::cudaError_enum::CUDA_ERROR_SYSTEM_NOT_READY,
794            sys::cudaError_enum::CUDA_ERROR_COMPAT_NOT_SUPPORTED_ON_DEVICE,
795        ] {
796            let absence = absence_from_driver_init_error(&result::DriverError(code))
797                .unwrap_or_else(|| panic!("{code:?} is an environment fact, not a device fault"));
798            assert!(
799                matches!(absence, GpuAbsence::DriverUnavailable { .. }),
800                "{code:?} must classify as an unavailable driver"
801            );
802            let resolved = GpuRuntime::resolve_availability(
803                GpuPolicy::Auto,
804                Ok(GpuAvailabilityRef::Absent(&absence)),
805            )
806            .expect("Auto must accept driver-environment absence");
807            assert!(resolved.is_none(), "Auto must fall back to CPU on {code:?}");
808            let required_error = GpuRuntime::resolve_availability(
809                GpuPolicy::Required,
810                Ok(GpuAvailabilityRef::Absent(&absence)),
811            )
812            .expect_err("Required must refuse driver-environment absence");
813            assert!(
814                matches!(required_error, GpuError::RequiredDeviceUnavailable { .. }),
815                "Required must carry the environment diagnosis for {code:?}"
816            );
817        }
818        let no_device = absence_from_driver_init_error(&result::DriverError(
819            sys::cudaError_enum::CUDA_ERROR_NO_DEVICE,
820        ))
821        .expect("no attached device is an environment fact");
822        assert!(matches!(no_device, GpuAbsence::NoDevice { .. }));
823    }
824
825    /// Faults of a present CUDA installation must never be reclassified into
826    /// absence — the Auto policy is allowed to hide missing hardware, never a
827    /// broken device.
828    #[cfg(target_os = "linux")]
829    #[test]
830    fn present_device_faults_never_classify_as_absence() {
831        for code in [
832            sys::cudaError_enum::CUDA_ERROR_ILLEGAL_ADDRESS,
833            sys::cudaError_enum::CUDA_ERROR_OUT_OF_MEMORY,
834            sys::cudaError_enum::CUDA_ERROR_NOT_INITIALIZED,
835            sys::cudaError_enum::CUDA_ERROR_ECC_UNCORRECTABLE,
836            sys::cudaError_enum::CUDA_ERROR_UNKNOWN,
837        ] {
838            assert!(
839                absence_from_driver_init_error(&result::DriverError(code)).is_none(),
840                "{code:?} is a fault of present hardware and must stay a probe fault"
841            );
842        }
843    }
844
845    #[test]
846    fn auto_and_required_preserve_probe_fault_variants() {
847        for policy in [GpuPolicy::Auto, GpuPolicy::Required] {
848            let error = GpuRuntime::resolve_availability(
849                policy,
850                Err(GpuError::RuntimeDependencyUnavailable {
851                    reason: "synthetic missing cuBLAS".to_string(),
852                }),
853            )
854            .expect_err("probe faults must never project to absence");
855            assert!(matches!(
856                error,
857                GpuError::RuntimeDependencyUnavailable { ref reason }
858                    if reason == "synthetic missing cuBLAS"
859            ));
860        }
861    }
862}