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 } => {
53 f.write_str(reason)
54 }
55 }
56 }
57}
58
59/// Lossless result of the process-wide CUDA probe.
60#[derive(Debug)]
61pub enum GpuAvailability {
62 Available(GpuRuntime),
63 Absent(GpuAbsence),
64}
65
66/// Borrowed lossless availability view returned from the one-time cache.
67#[derive(Clone, Copy, Debug)]
68pub enum GpuAvailabilityRef<'a> {
69 Available(&'a GpuRuntime),
70 Absent(&'a GpuAbsence),
71}
72
73/// Process-wide count of lossless runtime-resolution calls.
74///
75/// Incremented on every [`GpuRuntime::availability`] call before the one-time probe
76/// runs — so it counts the moments at which the device probe (and thus CUDA
77/// primary-context creation on each GPU, `cuDevicePrimaryCtxRetain`) could be
78/// triggered. Size-gated accessors that short-circuit for CPU-sized problems
79/// deliberately do not resolve availability, so a test can pin this counter across
80/// such a call and prove the CPU-sized decision path made ZERO driver contact.
81///
82/// Cross-platform (not `cfg(target_os = "linux")`) so the laziness/ordering
83/// contract is testable on CUDA-less hosts: even where the probe itself is a
84/// no-op, the invariant we verify is that the size check precedes resolution.
85static RESOLUTION_CALLS: AtomicU64 = AtomicU64::new(0);
86
87/// Install a process-wide panic hook (idempotent) that drops cudarc's
88/// `panic_no_lib_found` message instead of writing it to stderr. All other
89/// panics flow to the previously installed hook unchanged. The site cudarc
90/// 0.19 panics from is `cudarc-0.19.7/src/lib.rs:200` inside its dynamic
91/// loader; messages from that path start with `Unable to dynamically load`.
92/// Caller code wraps the same cudarc entry points in `catch_unwind`, so the
93/// panic is recovered — this hook just prevents the stderr noise that made
94/// operators think the fit had crashed.
95#[cfg(target_os = "linux")]
96fn install_cudarc_panic_filter() {
97 static HOOK_INSTALLED: OnceLock<()> = OnceLock::new();
98 HOOK_INSTALLED.get_or_init(|| {
99 let prior = panic::take_hook();
100 panic::set_hook(Box::new(move |info| {
101 let payload = info.payload();
102 let message = payload
103 .downcast_ref::<&'static str>()
104 .copied()
105 .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
106 .unwrap_or("");
107 if message.starts_with("Unable to dynamically load") {
108 return;
109 }
110 prior(info);
111 }));
112 });
113}
114
115impl GpuRuntime {
116 pub fn probe() -> Result<GpuAvailability, GpuError> {
117 #[cfg(not(target_os = "linux"))]
118 {
119 let reason = "CUDA support not compiled into this build";
120 Self::record_cpu_reason(reason);
121 diagnostics::log_cuda_disabled(reason);
122 return Ok(GpuAvailability::Absent(GpuAbsence::UnsupportedPlatform));
123 }
124
125 #[cfg(target_os = "linux")]
126 {
127 // `cudarc 0.19`'s entry points lazily initialize the CUDA driver
128 // through generated `culib()` helpers. On CPU-only Linux hosts the
129 // first such call emits `panic_no_lib_found` before unwinding, which
130 // polluted large-scale logs even when the panic was later caught and the
131 // fit fell back to CPU. Keep the preflight completely outside
132 // cudarc: use gam's own `libloading` probe first, and only touch
133 // cudarc after the platform loader can open `libcuda`.
134 //
135 // The preflight does not always agree with cudarc's own loader
136 // candidate list (e.g. large-scale workbench images expose CUDA *runtime*
137 // stub libraries under `/usr/local/cuda-*/targets/.../lib` but no
138 // driver `libcuda.so` in any loader path), so we additionally
139 // install a panic-hook filter that suppresses cudarc's
140 // `panic_no_lib_found` message and wrap every cudarc entry point
141 // below in `catch_unwind` to convert the panic into a typed
142 // `GpuError::DriverCallFailed` instead.
143 install_cudarc_panic_filter();
144 // #1017 probe-first fix: establish cudarc's primary context P and
145 // initialize the CUDA runtime ON IT as the VERY FIRST CUDA action -- before
146 // gam's libloading libcuda preload, the compute-lib dlopens, and device_count.
147 // The clean cuda_context_for-first path works; the probe-first path failed
148 // because a pre-context CUDA touch left the runtime bound to a non-P context,
149 // so later cuBLAS/cuSOLVER handle creation on the P-stream returned
150 // NOT_INITIALIZED. Making cuda_context_for the first action replicates the
151 // working clean path (CudaContext::new loads libcuda + retains the primary +
152 // ensure runs the runtime init); on a CPU-only host it returns None cleanly
153 // via the panic filter + catch_unwind, and the preload check below still runs.
154 let primary_ready = cuda_context_for(0).is_some();
155 log::trace!("[GPU] probe pre-init primary context + runtime: {primary_ready}");
156 match crate::driver::preload_cuda_driver() {
157 Ok(()) => {}
158 Err(GpuError::DriverLibraryUnavailable { reason }) => {
159 Self::record_cpu_reason(reason.clone());
160 log::info!("[GPU] CUDA acceleration disabled: {reason}");
161 diagnostics::log_cuda_disabled(&reason);
162 return Ok(GpuAvailability::Absent(GpuAbsence::DriverUnavailable {
163 reason,
164 }));
165 }
166 Err(error) => return Err(error),
167 }
168
169 // Driver-only environments (e.g. large-scale workbench images that expose
170 // `libcuda.so.1` but ship no cuBLAS/cuSOLVER/cuSPARSE) used to slip
171 // past the libcuda preflight, enable the runtime, and then panic
172 // out of cudarc's `panic_no_lib_found` on the first `CudaBlas::new`
173 // — the panic crossed the PyO3 FFI boundary as a
174 // `ValueError: fit_table panicked inside Rust boundary: Unable to
175 // dynamically load the "cublas" shared library`. The compute
176 // libraries are dispatch-critical (every cuBLAS / cuSOLVER /
177 // cuSPARSE site under `src/gpu/` calls `CudaBlas::new` /
178 // `DnHandle::new` / cusparse handle creation eagerly during
179 // workspace allocation), so we refuse to advertise GPU unless all
180 // three load cleanly here.
181 for stem in ["cublas", "cusolver", "cusparse"] {
182 if let Err(error) = crate::driver::require_cuda_compute_library(stem) {
183 let reason = format!("lib{stem} unavailable: {error}");
184 Self::record_cpu_reason(reason.clone());
185 log::info!("[GPU] CUDA acceleration disabled: {reason}");
186 diagnostics::log_cuda_disabled(&reason);
187 return Err(GpuError::RuntimeDependencyUnavailable { reason });
188 }
189 }
190
191 // cudarc 0.19's `culib()` panics via `panic_no_lib_found` when its
192 // own (separate from gam's) dynamic-loader candidate list cannot
193 // find libcuda — this can happen even after our `preload_cuda_driver`
194 // succeeds, for example if our probe loaded a CUDA stub library but
195 // cudarc's loader searches a disjoint set of names. Convert any such
196 // panic into a typed probe failure so the runtime cleanly disables
197 // CUDA and the CPU fallback proceeds without alarming stderr noise.
198 let device_count = match catch_unwind(AssertUnwindSafe(CudaContext::device_count)) {
199 Err(_) => {
200 return Err(GpuError::DriverCallFailed {
201 reason: "cudarc failed after the CUDA driver preflight succeeded"
202 .to_string(),
203 });
204 }
205 Ok(Ok(count)) => count,
206 Ok(Err(error)) => {
207 // `device_count` performs `cuInit`, so this is the first
208 // moment the host's kernel driver actually answers. A
209 // refusal that is an ENVIRONMENT fact (userland CUDA
210 // libraries with no matching kernel driver — the container
211 // / CPU-node case #2267 hit as
212 // `CUDA_ERROR_SYSTEM_DRIVER_MISMATCH`) is typed absence:
213 // Auto falls back to CPU, Required still refuses with the
214 // same diagnosis. Anything else stays a probe fault.
215 if let Some(absence) = absence_from_driver_init_error(&error) {
216 let reason = absence.to_string();
217 Self::record_cpu_reason(reason.clone());
218 log::info!("[GPU] CUDA acceleration disabled: {reason}");
219 diagnostics::log_cuda_disabled(&reason);
220 return Ok(GpuAvailability::Absent(absence));
221 }
222 return Err(GpuError::DriverCallFailed {
223 reason: error.to_string(),
224 });
225 }
226 };
227 if device_count <= 0 {
228 let reason = "CUDA driver reported no devices";
229 Self::record_cpu_reason(reason);
230 diagnostics::log_cuda_disabled(reason);
231 return Ok(GpuAvailability::Absent(GpuAbsence::NoDevice {
232 reason: reason.to_string(),
233 }));
234 }
235
236 let mut devices = Vec::new();
237 for ordinal in
238 0..usize::try_from(device_count).map_err(|_| GpuError::DriverCallFailed {
239 reason: "negative CUDA device count".into(),
240 })?
241 {
242 let ctx = cuda_context_for(ordinal).ok_or_else(|| {
243 gpu_err!("failed to create CUDA context for device {ordinal}")
244 })?;
245 catch_unwind(AssertUnwindSafe(|| ctx.bind_to_thread()))
246 .map_err(|_| GpuError::DriverCallFailed {
247 reason: "CUDA context binding panicked after driver discovery".to_string(),
248 })?
249 .map_err(|err| GpuError::DriverCallFailed {
250 reason: err.to_string(),
251 })?;
252 devices.push(
253 catch_unwind(AssertUnwindSafe(|| cuda_device_info(ordinal, &ctx))).map_err(
254 |_| GpuError::DriverCallFailed {
255 reason: "CUDA device inspection panicked after driver discovery"
256 .to_string(),
257 },
258 )??,
259 );
260 }
261
262 devices.sort_by(|a, b| b.score().total_cmp(&a.score()));
263 let Some(device) = devices.first().cloned() else {
264 Self::record_cpu_reason("CUDA driver reported no usable devices");
265 diagnostics::log_cuda_disabled("CUDA driver reported no usable devices");
266 return Ok(GpuAvailability::Absent(GpuAbsence::NoDevice {
267 reason: "CUDA driver reported no usable devices".to_string(),
268 }));
269 };
270
271 let policy = crate::calibration::calibrated_policy_for_device(&device);
272 let memory_budget_bytes = device.memory_budget_bytes();
273 diagnostics::log_cuda_enabled(&device, &policy);
274 diagnostics::log_cuda_pool(&devices);
275
276 Ok(GpuAvailability::Available(Self {
277 device,
278 devices,
279 policy,
280 memory_budget_bytes,
281 }))
282 }
283 }
284
285 /// Return the cached probe outcome without collapsing faults into absence.
286 pub fn availability() -> Result<GpuAvailabilityRef<'static>, GpuError> {
287 // Record every entry BEFORE the `OnceLock` probe, so the size-gated
288 // accessors below (which never reach this point for CPU-sized problems)
289 // can be proven not to have triggered a device probe / context creation.
290 RESOLUTION_CALLS.fetch_add(1, Ordering::Relaxed);
291 static RUNTIME: OnceLock<Result<GpuAvailability, GpuError>> = OnceLock::new();
292 let cached = RUNTIME.get_or_init(|| {
293 let outcome = Self::probe();
294 if let Err(error) = &outcome {
295 let reason = error.to_string();
296 Self::record_cpu_reason(reason.clone());
297 diagnostics::log_cuda_disabled(&reason);
298 }
299 // Install the dense-GEMM dispatch hook exactly when a usable
300 // device was probed. Without this, `gam_linalg::faer_ndarray::fast_ab`
301 // (and the `fast_atb`/`fast_av`/`xt_diag_x` family) never sees a
302 // dispatcher — `gpu_dispatch()` stays `None` — so every dense
303 // product in the engine silently runs on the CPU even when the
304 // V100 is present and the workload clears the policy flop floor.
305 // The hook is a first-write-wins `OnceLock` keyed only on the
306 // presence of a runtime; registering it here, inside the same
307 // `get_or_init` that decides the runtime, guarantees it is
308 // installed before any `fast_ab` caller can observe an available
309 // runtime. The policy gate inside each `try_*` still decides
310 // CPU-vs-GPU per call, so small products are unaffected.
311 if matches!(&outcome, Ok(GpuAvailability::Available(_))) {
312 gam_linalg::gpu_hook::register_gpu_dispatch(Box::new(
313 super::linalg_dispatch::CudaGemmDispatch,
314 ));
315 }
316 outcome
317 });
318 match cached {
319 Ok(GpuAvailability::Available(runtime)) => {
320 Ok(GpuAvailabilityRef::Available(runtime))
321 }
322 Ok(GpuAvailability::Absent(reason)) => Ok(GpuAvailabilityRef::Absent(reason)),
323 Err(error) => Err(error.clone()),
324 }
325 }
326
327 /// Resolve CUDA under an explicit policy. `Ok(None)` is reserved for a
328 /// genuine absence under Auto/Off; probe faults always remain `Err`, and
329 /// Required converts absence into `RequiredDeviceUnavailable`.
330 pub fn resolve(policy: super::GpuPolicy) -> Result<Option<&'static Self>, GpuError> {
331 if policy == super::GpuPolicy::Off {
332 return Ok(None);
333 }
334 Self::resolve_availability(policy, Self::availability())
335 }
336
337 fn resolve_availability<'a>(
338 policy: super::GpuPolicy,
339 availability: Result<GpuAvailabilityRef<'a>, GpuError>,
340 ) -> Result<Option<&'a Self>, GpuError> {
341 match availability? {
342 GpuAvailabilityRef::Available(runtime) => Ok(Some(runtime)),
343 GpuAvailabilityRef::Absent(_reason) if policy == super::GpuPolicy::Auto => Ok(None),
344 GpuAvailabilityRef::Absent(reason) => Err(GpuError::RequiredDeviceUnavailable {
345 reason: reason.to_string(),
346 }),
347 }
348 }
349
350 /// Resolve CUDA under Required semantics and return the device handle.
351 pub fn require() -> Result<&'static Self, GpuError> {
352 Self::resolve(super::GpuPolicy::Required)?.ok_or_else(|| {
353 GpuError::RequiredDeviceUnavailable {
354 reason: "required CUDA runtime resolved to an absent state".to_string(),
355 }
356 })
357 }
358
359 /// Number of times [`Self::availability`] has been entered process-wide.
360 ///
361 /// Test-facing instrumentation for the laziness contract: a size-gated
362 /// caller that returns before resolving availability leaves this unchanged, so
363 /// a test can assert a CPU-sized decision path created no CUDA context. This
364 /// is a monotone call counter, NOT a probe-success flag.
365 #[must_use]
366 pub fn resolution_call_count() -> u64 {
367 RESOLUTION_CALLS.load(Ordering::Relaxed)
368 }
369
370 /// Size-gated [`Self::resolve`]: resolve the process-wide runtime only when the
371 /// estimated dense arithmetic `work_flops` clears the GPU-dispatch flop floor.
372 ///
373 /// This is the ordering fix for the CUDA startup tax. For a CPU-sized problem
374 /// (`work_flops` below the floor) it returns `Ok(None)` without calling
375 /// [`Self::resolve`], so the device probe — and the `cuDevicePrimaryCtxRetain`
376 /// primary-context creation it performs on every GPU — never runs. The
377 /// problem-size decision therefore strictly precedes any driver contact, and
378 /// a CPU-sized fit pays ZERO CUDA cost.
379 ///
380 /// The floor is [`GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS`] — the
381 /// smallest `gemm_min_flops` ANY reachable policy (default seed or
382 /// device-calibrated) can carry, known WITHOUT a device — so the gate never
383 /// needs a probe to decide it should not probe, and refusing below it can
384 /// never block work that any policy would have dispatched. Work at or above
385 /// the floor falls through to the identical lossless resolution path (where
386 /// the real, possibly calibrated policy still gates each op), so device
387 /// behaviour for genuinely GPU-sized problems is unchanged.
388 pub fn resolve_if_dense_work_exceeds_floor(
389 policy: super::GpuPolicy,
390 work_flops: u128,
391 ) -> Result<Option<&'static Self>, GpuError> {
392 if work_flops < GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS {
393 return Ok(None);
394 }
395 Self::resolve(policy)
396 }
397
398 /// Size-gated [`Self::resolve`] for independent fused row kernels.
399 ///
400 /// Batches below
401 /// [`GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N`] cannot be
402 /// admitted by either the default or any device-calibrated policy. Refuse
403 /// them before availability resolution so a CPU-sized first call does not
404 /// create CUDA contexts and run calibration merely to learn that it should
405 /// stay on the CPU. At and above the universal floor, the concrete
406 /// runtime's calibrated policy remains authoritative.
407 pub fn resolve_if_fused_batch_exceeds_floor(
408 policy: super::GpuPolicy,
409 rows: usize,
410 ) -> Result<Option<&'static Self>, GpuError> {
411 if rows < GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N {
412 return Ok(None);
413 }
414 Self::resolve(policy)
415 }
416
417 #[must_use]
418 pub fn policy(&self) -> &GpuDispatchPolicy {
419 &self.policy
420 }
421
422 #[must_use]
423 pub fn selected_device(&self) -> &GpuDeviceInfo {
424 &self.device
425 }
426
427 #[must_use]
428 pub(crate) fn cpu_reason() -> Option<&'static str> {
429 CPU_REASON.get().map(String::as_str)
430 }
431
432 fn record_cpu_reason(reason: impl Into<String>) {
433 CPU_REASON.set(reason.into()).ok();
434 }
435}
436
437/// Classify a CUDA driver-*initialization* failure that is a fact about the
438/// host environment rather than a fault of a device that was present.
439///
440/// `cuInit` is the first call the kernel driver answers. The codes below all
441/// mean "CUDA cannot work on this host as configured" — a loaded `libcuda`
442/// userland with a missing, older, or mismatched kernel driver, a linker stub
443/// standing in for the real library, or no attached device. Those states are
444/// [`GpuAbsence`] by this module's own definition (absence is an expected
445/// hardware/platform fact under `GpuPolicy::Auto`): container images and CPU
446/// nodes routinely carry CUDA userland libraries they cannot back with a
447/// driver, and a fit under Auto must fall back to CPU there instead of dying
448/// inside runtime resolution (#2267). Every other code — illegal address,
449/// out-of-memory, ECC faults, ... — still means "a CUDA installation that was
450/// present failed", and stays a probe fault.
451#[cfg(target_os = "linux")]
452fn absence_from_driver_init_error(error: &result::DriverError) -> Option<GpuAbsence> {
453 use sys::cudaError_enum as CudaErrorCode;
454 // Format the raw enum code, NEVER the DriverError itself: cudarc's
455 // Display/Debug for DriverError resolve the error string through its
456 // dynamic loader (`culib()`), which panics via `panic_no_lib_found` on
457 // exactly the driverless hosts this classifier exists for. The enum's
458 // derived Debug is a pure Rust name and is safe everywhere.
459 let code = error.0;
460 let classification = match code {
461 CudaErrorCode::CUDA_ERROR_NO_DEVICE => {
462 return Some(GpuAbsence::NoDevice {
463 reason: format!(
464 "CUDA driver initialized but reports no attached device ({code:?})"
465 ),
466 });
467 }
468 CudaErrorCode::CUDA_ERROR_STUB_LIBRARY => {
469 "the loaded libcuda is a linker stub, not a real driver"
470 }
471 // NOTE: there is deliberately no INSUFFICIENT_DRIVER arm — that code
472 // (`cudaErrorInsufficientDriver`) exists only in the CUDA *runtime*
473 // API; the driver API reports the userland/kernel version split as
474 // `CUDA_ERROR_SYSTEM_DRIVER_MISMATCH` below.
475 CudaErrorCode::CUDA_ERROR_SYSTEM_NOT_READY => {
476 "the CUDA system is not ready (kernel driver or fabric daemon not running)"
477 }
478 CudaErrorCode::CUDA_ERROR_SYSTEM_DRIVER_MISMATCH => {
479 "the CUDA userland libraries do not match the host kernel driver"
480 }
481 CudaErrorCode::CUDA_ERROR_COMPAT_NOT_SUPPORTED_ON_DEVICE => {
482 "CUDA forward-compatibility mode is not supported on the visible device"
483 }
484 _ => return None,
485 };
486 Some(GpuAbsence::DriverUnavailable {
487 reason: format!("CUDA initialization refused: {classification} ({code:?})"),
488 })
489}
490
491/// Make the CUDA **runtime** API usable on `ordinal`.
492///
493/// gam drives the GPU through the CUDA *driver* API (cudarc [`CudaContext`]),
494/// which materialises the driver primary context but never selects a device for
495/// the CUDA *runtime* API. cuBLAS / cuSOLVER are runtime-based, so `cublasCreate`
496/// / `cusolverDnCreate` return `CUBLAS_STATUS_NOT_INITIALIZED` /
497/// `CUSOLVER_STATUS_NOT_INITIALIZED` until the runtime has a current device —
498/// which silently disables *every* GPU linear-algebra path (the dispatch sites
499/// map the handle error to `Unavailable` and fall back to CPU). We select the
500/// device on the calling host thread (cheap, idempotent) and force one-time
501/// runtime primary-context materialisation per device via the canonical
502/// `cudaMalloc`/`cudaFree` idiom, so every downstream handle creation succeeds.
503#[cfg(target_os = "linux")]
504fn ensure_cuda_runtime_device(ordinal: usize) {
505 let Ok(o) = i32::try_from(ordinal) else {
506 return;
507 };
508 // SAFETY: the `runtime` cudarc feature is enabled; cudaSetDevice on a valid
509 // ordinal is idempotent and per-host-thread.
510 let set_rc = unsafe { cudarc::runtime::sys::cudaSetDevice(o) };
511 log::trace!("[GPU] runtime cudaSetDevice({o}) -> {set_rc:?}");
512 // Materialise the runtime primary context for this device: cuBLAS/cuSOLVER
513 // `*Create` use whatever context is current at creation time, so the runtime
514 // device must be selected and its primary context materialised before a
515 // handle is made. A 256-byte allocate-then-free is the canonical,
516 // ~microsecond way to force it. This is invoked exactly once per (thread,
517 // ordinal) by `bind_and_touch_runtime` — the NOT_INITIALIZED condition it
518 // repairs is per-thread-per-device and does NOT re-arm per call once the
519 // primary context is current and the runtime is materialised on the thread.
520 let mut p: *mut core::ffi::c_void = core::ptr::null_mut();
521 // SAFETY: forces runtime primary-context creation on the current device.
522 let malloc_rc = unsafe { cudarc::runtime::sys::cudaMalloc(&mut p as *mut _ as *mut _, 256) };
523 log::trace!("[GPU] runtime cudaMalloc -> {malloc_rc:?}");
524 if !p.is_null() {
525 // SAFETY: `p` is the live device allocation returned just above.
526 let free_rc = unsafe { cudarc::runtime::sys::cudaFree(p) };
527 log::trace!("[GPU] runtime cudaFree -> {free_rc:?}");
528 }
529}
530
531#[cfg(target_os = "linux")]
532thread_local! {
533 /// The device ordinal whose primary context is bound as THIS thread's
534 /// current context AND whose runtime primary context has already been
535 /// materialised on this thread. `Some(ordinal)` means the last
536 /// [`cuda_context_for`] touch on this thread was `ordinal` and nothing has
537 /// switched it since, so the per-call `bind_to_thread` + runtime
538 /// materialisation can be skipped.
539 ///
540 /// Switching to a different ordinal (or the initial `None`) invalidates the
541 /// memo and forces a full rebind + re-materialisation, so the per-thread-
542 /// per-device NOT_INITIALIZED repair (#1017) is preserved exactly: the
543 /// condition it fixes is arm-once-per-(thread, device), and a memo keyed on
544 /// the thread's currently-bound ordinal only skips work when that same
545 /// ordinal is already current — i.e. when neither the driver context nor the
546 /// runtime device could have drifted.
547 static BOUND_RUNTIME_ORDINAL: Cell<Option<usize>> = const { Cell::new(None) };
548}
549
550/// Bind cudarc's primary context for `ordinal` current on this thread and
551/// materialise the runtime primary context on it — memoised once per (thread,
552/// ordinal).
553///
554/// The bind + runtime touch exist to repair the probe-first
555/// CUBLAS/CUSOLVER_STATUS_NOT_INITIALIZED bug: on a fresh solve thread the
556/// cached-context path would let the CUDA runtime initialise its OWN device
557/// context, so a later `cublasCreate`/`cusolverDnCreate` on the primary-context
558/// stream fails. Binding the primary context current and forcing runtime
559/// materialisation on the SAME context before returning fixes it. That repair
560/// is durable per (thread, ordinal); it does not re-arm per call. So when this
561/// thread's current context is already `ordinal` we skip the bind and the
562/// 256-byte cudaMalloc/cudaFree entirely, removing the per-call driver tax while
563/// preserving the invariant — a switch to any other ordinal re-runs the full
564/// repair.
565#[cfg(target_os = "linux")]
566fn bind_and_touch_runtime(ordinal: usize, ctx: &Arc<CudaContext>) {
567 if BOUND_RUNTIME_ORDINAL.with(Cell::get) == Some(ordinal) {
568 return;
569 }
570 let bound = catch_unwind(AssertUnwindSafe(|| ctx.bind_to_thread()));
571 log::trace!(
572 "[GPU] cuda_context_for bind ok={} ordinal={ordinal}",
573 matches!(bound, Ok(Ok(())))
574 );
575 ensure_cuda_runtime_device(ordinal);
576 // Latch the memo only after a SUCCESSFUL bind: a failed bind left the
577 // thread's current context indeterminate, so the next call must retry the
578 // full repair rather than assume `ordinal` is current.
579 if matches!(bound, Ok(Ok(()))) {
580 BOUND_RUNTIME_ORDINAL.with(|c| c.set(Some(ordinal)));
581 }
582}
583
584#[cfg(target_os = "linux")]
585pub fn cuda_context_for(ordinal: usize) -> Option<Arc<CudaContext>> {
586 static CONTEXTS: OnceLock<Mutex<HashMap<usize, Arc<CudaContext>>>> = OnceLock::new();
587 let contexts = CONTEXTS.get_or_init(|| Mutex::new(HashMap::new()));
588 if let Some(ctx) = contexts.lock().ok()?.get(&ordinal).cloned() {
589 bind_and_touch_runtime(ordinal, &ctx);
590 return Some(ctx);
591 }
592 // cudarc 0.19 panics from `panic_no_lib_found` if its loader fails to
593 // locate libcuda. Demote that to `None` so the runtime probe surfaces a
594 // typed `DriverUnavailable` rather than tearing down the worker thread.
595 let ctx = catch_unwind(AssertUnwindSafe(|| CudaContext::new(ordinal)))
596 .ok()?
597 .ok()?;
598 let out = {
599 let mut guard = contexts.lock().ok()?;
600 guard.entry(ordinal).or_insert_with(|| ctx.clone()).clone()
601 };
602 // CudaContext::new already bound the primary context, but the HashMap may return
603 // an entry created on another thread; the memoised bind rebinds so the primary
604 // context is current on THIS thread before the runtime touch (same probe-first
605 // NOT_INITIALIZED guard) on the first touch, and is a no-op thereafter.
606 bind_and_touch_runtime(ordinal, &out);
607 Some(out)
608}
609
610#[cfg(target_os = "linux")]
611fn cuda_device_info(ordinal: usize, ctx: &CudaContext) -> Result<GpuDeviceInfo, GpuError> {
612 result::init().map_err(|err| GpuError::DriverCallFailed {
613 reason: err.to_string(),
614 })?;
615 let device =
616 result::device::get(
617 i32::try_from(ordinal).map_err(|_| GpuError::DriverCallFailed {
618 reason: "device ordinal overflow".into(),
619 })?,
620 )
621 .map_err(|err| GpuError::DriverCallFailed {
622 reason: err.to_string(),
623 })?;
624 let attr = |attribute| -> Result<i32, GpuError> {
625 // SAFETY: device comes from cudarc's validated device::get.
626 unsafe { result::device::get_attribute(device, attribute) }.map_err(|err| {
627 GpuError::DriverCallFailed {
628 reason: err.to_string(),
629 }
630 })
631 };
632 let (free_mem_bytes, total_mem_bytes) =
633 ctx.mem_get_info()
634 .map_err(|err| GpuError::DriverCallFailed {
635 reason: err.to_string(),
636 })?;
637 let major = attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR)?;
638 let minor = attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR)?;
639 Ok(GpuDeviceInfo {
640 ordinal,
641 name: result::device::get_name(device).unwrap_or_else(|_| format!("CUDA device {ordinal}")),
642 capability: super::device::GpuCapability::from_compute_capability(major, minor),
643 sm_count: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT)?,
644 max_threads_per_sm: attr(
645 sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR,
646 )?,
647 max_shared_mem_per_block: attr(
648 sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK,
649 )
650 .unwrap_or(0) as usize,
651 l2_cache_bytes: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_L2_CACHE_SIZE)
652 .unwrap_or(0) as usize,
653 total_mem_bytes,
654 free_mem_bytes,
655 ecc_enabled: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_ECC_ENABLED)
656 .unwrap_or(0)
657 != 0,
658 integrated: attr(sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_INTEGRATED).unwrap_or(0)
659 != 0,
660 mig_mode: false,
661 })
662}
663
664#[cfg(test)]
665mod module_path_lock_tests {
666 //! Locks the canonical module path for the GPU device runtime so a future
667 //! rename is a deliberate, reviewed change (precedent: issue #1157's
668 //! "lock module path" tests). This file was renamed from the generic,
669 //! colliding `gpu/runtime.rs` to `gpu/device_runtime.rs` under issue #1137.
670
671 #[test]
672 fn gpu_device_runtime_module_path_is_canonical() {
673 // Resolving `GpuRuntime` through the `device_runtime` module path
674 // pins the honest name; if the module is renamed this stops compiling.
675 _ = crate::device_runtime::GpuRuntime::resolution_call_count();
676 let type_name = std::any::type_name::<crate::device_runtime::GpuRuntime>();
677 assert!(
678 type_name.contains("device_runtime"),
679 "GpuRuntime must live in the `device_runtime` module (got {type_name})"
680 );
681 }
682}
683
684#[cfg(test)]
685mod laziness_gate_tests {
686 //! Pins the CUDA startup-tax ordering fix: a CPU-sized problem must reach
687 //! its size decision WITHOUT ever resolving GPU availability (which is what
688 //! triggers the one-time device probe + `cuDevicePrimaryCtxRetain`
689 //! primary-context creation on every GPU). Runs on any host — on a CUDA-less
690 //! box the probe is a no-op, but the invariant under test is purely the
691 //! control-flow ordering (size check strictly before resolution), which is
692 //! observable through the process-wide `resolution_call_count` counter.
693 //!
694 //! nextest runs each test in its own process, so the counter starts at a
695 //! clean baseline per test; the assertions use a delta against `before` so
696 //! they are robust regardless of the absolute starting value.
697 use super::*;
698
699 #[test]
700 fn cpu_sized_dense_work_never_resolves_availability() {
701 let before = GpuRuntime::resolution_call_count();
702 // Dense work far below the GPU-dispatch flop floor: a CPU-sized fit.
703 assert!(
704 GpuRuntime::resolve_if_dense_work_exceeds_floor(super::super::GpuPolicy::Auto, 1_000)
705 .expect("the pre-probe size gate itself is infallible")
706 .is_none(),
707 "CPU-sized work must not select the device"
708 );
709 assert_eq!(
710 GpuRuntime::resolution_call_count(),
711 before,
712 "the size gate must short-circuit BEFORE resolution/probe for CPU-sized \
713 work, so no CUDA context is ever created"
714 );
715 }
716
717 #[test]
718 fn cpu_sized_fused_batch_never_resolves_availability() {
719 let floor = GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N;
720 let before = GpuRuntime::resolution_call_count();
721 for _ in 0..COUNTER_PROBE_CALLS {
722 assert!(
723 GpuRuntime::resolve_if_fused_batch_exceeds_floor(
724 super::super::GpuPolicy::Auto,
725 floor - 1,
726 )
727 .expect("the below-floor fused-batch gate cannot probe or fail")
728 .is_none(),
729 "a universally CPU-sized fused batch must not select the device"
730 );
731 }
732 let below_floor_delta = GpuRuntime::resolution_call_count() - before;
733 assert!(
734 below_floor_delta < COUNTER_PROBE_CALLS,
735 "the fused-batch size gate must short-circuit before runtime resolution: \
736 {below_floor_delta} resolution entries during {COUNTER_PROBE_CALLS} \
737 below-floor calls"
738 );
739
740 let at_floor_before = GpuRuntime::resolution_call_count();
741 for _ in 0..COUNTER_PROBE_CALLS {
742 let runtime = GpuRuntime::resolve_if_fused_batch_exceeds_floor(
743 super::super::GpuPolicy::Auto,
744 floor,
745 )
746 .expect("a probe fault must fail the fused-batch boundary gate");
747 assert!(
748 runtime.is_none_or(|runtime| !runtime.devices.is_empty()),
749 "an available runtime must expose at least one usable device"
750 );
751 }
752 assert!(
753 GpuRuntime::resolution_call_count() - at_floor_before >= COUNTER_PROBE_CALLS,
754 "the fused-batch floor boundary must consult the runtime's calibrated policy"
755 );
756 }
757
758 /// The resolution counter is process-global and the test binary runs in
759 /// parallel: on a real GPU box dozens of concurrent tests legitimately
760 /// enter `availability()` between any two reads (this is exactly how the
761 /// exact `before + 1` form of these gates failed on hardware while
762 /// staying green on quiet CPU-only runners — #2313's hardware-only
763 /// coverage class). Calling the gate `N` times and bounding the delta
764 /// makes the control-flow property immune to that traffic: a gate that
765 /// probes contributes ≥ N calls; one that never probes contributes 0,
766 /// and unrelated concurrent traffic is orders of magnitude below N.
767 const COUNTER_PROBE_CALLS: u64 = 4096;
768
769 #[test]
770 fn gpu_sized_dense_work_falls_through_to_resolution() {
771 let before = GpuRuntime::resolution_call_count();
772 // Above any plausible floor: every call must consult the runtime,
773 // i.e. the gate does not change behaviour for genuinely GPU-sized
774 // problems. The returned handle is irrelevant here (None on CPU-only
775 // boxes); the observable is the consultation count below.
776 for _ in 0..COUNTER_PROBE_CALLS {
777 let runtime = GpuRuntime::resolve_if_dense_work_exceeds_floor(
778 super::super::GpuPolicy::Auto,
779 u128::MAX,
780 )
781 .expect("a probe fault must fail this gate instead of looking absent");
782 assert!(
783 runtime.is_none_or(|runtime| !runtime.devices.is_empty()),
784 "an available runtime must expose at least one usable device"
785 );
786 }
787 assert!(
788 GpuRuntime::resolution_call_count() - before >= COUNTER_PROBE_CALLS,
789 "GPU-sized work must fall through to availability resolution on every call"
790 );
791 }
792
793 #[test]
794 fn floor_is_the_min_calibratable_gemm_threshold() {
795 // The gate's floor is the smallest gemm_min_flops any reachable policy
796 // (default seed OR device-calibrated) can carry — known without a
797 // device, so the decision to NOT probe never needs a probe, and the
798 // refusal can never block work some calibrated policy would dispatch.
799 let floor = GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS;
800 let before = GpuRuntime::resolution_call_count();
801 for _ in 0..COUNTER_PROBE_CALLS {
802 assert!(
803 GpuRuntime::resolve_if_dense_work_exceeds_floor(
804 super::super::GpuPolicy::Auto,
805 floor - 1,
806 )
807 .expect("the below-floor gate cannot probe or fail")
808 .is_none()
809 );
810 }
811 let below_floor_delta = GpuRuntime::resolution_call_count() - before;
812 assert!(
813 below_floor_delta < COUNTER_PROBE_CALLS,
814 "below-floor work must never probe the runtime: {below_floor_delta} \
815 resolution entries during {COUNTER_PROBE_CALLS} below-floor calls"
816 );
817 // At the floor the gate must consult the runtime (fall through) on
818 // every call.
819 let at_floor_before = GpuRuntime::resolution_call_count();
820 for _ in 0..COUNTER_PROBE_CALLS {
821 let runtime = GpuRuntime::resolve_if_dense_work_exceeds_floor(
822 super::super::GpuPolicy::Auto,
823 floor,
824 )
825 .expect("a probe fault must fail the boundary gate instead of looking absent");
826 assert!(
827 runtime.is_none_or(|runtime| !runtime.devices.is_empty()),
828 "a successful floor-boundary probe must expose at least one usable device"
829 );
830 }
831 assert!(
832 GpuRuntime::resolution_call_count() - at_floor_before >= COUNTER_PROBE_CALLS,
833 "floor-boundary work must fall through to availability resolution on every call"
834 );
835 }
836}
837
838#[cfg(test)]
839mod policy_resolution_contract_tests {
840 use super::*;
841 use crate::GpuPolicy;
842
843 #[test]
844 fn auto_maps_only_typed_absence_to_none() {
845 let absence = GpuAbsence::NoDevice {
846 reason: "synthetic device-free absence".to_string(),
847 };
848 let resolved = GpuRuntime::resolve_availability(
849 GpuPolicy::Auto,
850 Ok(GpuAvailabilityRef::Absent(&absence)),
851 )
852 .expect("typed absence is expected under Auto");
853 assert!(resolved.is_none());
854 }
855
856 #[test]
857 fn required_turns_only_typed_absence_into_required_unavailable() {
858 let absence = GpuAbsence::DriverUnavailable {
859 reason: "synthetic missing driver".to_string(),
860 };
861 let error = GpuRuntime::resolve_availability(
862 GpuPolicy::Required,
863 Ok(GpuAvailabilityRef::Absent(&absence)),
864 )
865 .expect_err("Required must reject typed absence");
866 assert!(matches!(
867 error,
868 GpuError::RequiredDeviceUnavailable { ref reason }
869 if reason == "synthetic missing driver"
870 ));
871 }
872
873 /// #2267: a CUDA userland whose kernel driver is missing or mismatched is
874 /// an environment fact. `cuInit`-boundary refusals of that class must be
875 /// typed absence — Auto proceeds on CPU, Required refuses with the same
876 /// diagnosis — never a probe fault that kills the fit under Auto.
877 #[cfg(target_os = "linux")]
878 #[test]
879 fn driver_mismatch_at_init_is_typed_absence_not_a_fault() {
880 for code in [
881 sys::cudaError_enum::CUDA_ERROR_SYSTEM_DRIVER_MISMATCH,
882 sys::cudaError_enum::CUDA_ERROR_STUB_LIBRARY,
883 sys::cudaError_enum::CUDA_ERROR_SYSTEM_NOT_READY,
884 sys::cudaError_enum::CUDA_ERROR_COMPAT_NOT_SUPPORTED_ON_DEVICE,
885 ] {
886 let absence = absence_from_driver_init_error(&result::DriverError(code))
887 .unwrap_or_else(|| panic!("{code:?} is an environment fact, not a device fault"));
888 assert!(
889 matches!(absence, GpuAbsence::DriverUnavailable { .. }),
890 "{code:?} must classify as an unavailable driver"
891 );
892 let resolved =
893 GpuRuntime::resolve_availability(GpuPolicy::Auto, Ok(GpuAvailabilityRef::Absent(&absence)))
894 .expect("Auto must accept driver-environment absence");
895 assert!(resolved.is_none(), "Auto must fall back to CPU on {code:?}");
896 let required_error = GpuRuntime::resolve_availability(
897 GpuPolicy::Required,
898 Ok(GpuAvailabilityRef::Absent(&absence)),
899 )
900 .expect_err("Required must refuse driver-environment absence");
901 assert!(
902 matches!(required_error, GpuError::RequiredDeviceUnavailable { .. }),
903 "Required must carry the environment diagnosis for {code:?}"
904 );
905 }
906 let no_device = absence_from_driver_init_error(&result::DriverError(
907 sys::cudaError_enum::CUDA_ERROR_NO_DEVICE,
908 ))
909 .expect("no attached device is an environment fact");
910 assert!(matches!(no_device, GpuAbsence::NoDevice { .. }));
911 }
912
913 /// Faults of a present CUDA installation must never be reclassified into
914 /// absence — the Auto policy is allowed to hide missing hardware, never a
915 /// broken device.
916 #[cfg(target_os = "linux")]
917 #[test]
918 fn present_device_faults_never_classify_as_absence() {
919 for code in [
920 sys::cudaError_enum::CUDA_ERROR_ILLEGAL_ADDRESS,
921 sys::cudaError_enum::CUDA_ERROR_OUT_OF_MEMORY,
922 sys::cudaError_enum::CUDA_ERROR_NOT_INITIALIZED,
923 sys::cudaError_enum::CUDA_ERROR_ECC_UNCORRECTABLE,
924 sys::cudaError_enum::CUDA_ERROR_UNKNOWN,
925 ] {
926 assert!(
927 absence_from_driver_init_error(&result::DriverError(code)).is_none(),
928 "{code:?} is a fault of present hardware and must stay a probe fault"
929 );
930 }
931 }
932
933 #[test]
934 fn auto_and_required_preserve_probe_fault_variants() {
935 for policy in [GpuPolicy::Auto, GpuPolicy::Required] {
936 let error = GpuRuntime::resolve_availability(
937 policy,
938 Err(GpuError::RuntimeDependencyUnavailable {
939 reason: "synthetic missing cuBLAS".to_string(),
940 }),
941 )
942 .expect_err("probe faults must never project to absence");
943 assert!(matches!(
944 error,
945 GpuError::RuntimeDependencyUnavailable { ref reason }
946 if reason == "synthetic missing cuBLAS"
947 ));
948 }
949 }
950}
951
952#[cfg(all(test, target_os = "linux"))]
953mod tests {
954 use super::*;
955
956 /// On a CPU-only host (no `libcuda.dylib` / `libcuda.so` reachable via the
957 /// platform loader), exercising every cudarc-touching entry point in this
958 /// crate must produce a clean `None`/`Err` and never trigger
959 /// `cudarc::panic_no_lib_found`. This is the regression guard for issues
960 /// #168 and #176, which observed a `PanicException` escaping the PyO3
961 /// boundary on macOS when `sae_manifold_fit(..., atom_basis="duchon")` or
962 /// `d_atom=1` ran on a host with no CUDA driver.
963 ///
964 /// On a host where libcuda *is* present the test still passes — it asserts
965 /// only that calls don't panic and that `is_culib_present()` agrees with
966 /// the typed availability result about the absence of a driver.
967 #[test]
968 fn cpu_only_host_never_panics_on_gpu_entry_points() {
969 // Without libcuda the runtime must report unavailable rather than
970 // panicking from inside `culib()`; with libcuda the runtime may or
971 // may not have a usable device, but the panic-free contract still
972 // holds and the dispatch smoke test below exercises it.
973 match crate::driver::preload_cuda_driver() {
974 Ok(()) => {}
975 Err(GpuError::DriverLibraryUnavailable { .. }) => assert!(
976 matches!(
977 GpuRuntime::availability(),
978 Ok(GpuAvailabilityRef::Absent(GpuAbsence::DriverUnavailable { .. }))
979 ),
980 "typed driver absence must remain absence through runtime availability"
981 ),
982 Err(error) => panic!("a present-but-broken CUDA driver must fail this test: {error}"),
983 }
984
985 // Every public GPU dispatch must return a value (no panic) when the
986 // runtime is unavailable. We use minimum-size inputs so a host that
987 // *does* have a GPU still passes (workload below dispatch threshold
988 // → returns None / Err / CPU fallback the same way).
989 use ndarray::{Array1, Array2};
990 let a = Array2::<f64>::zeros((4, 3));
991 let b = Array2::<f64>::zeros((3, 2));
992 let v = Array1::<f64>::zeros(3);
993 let w = Array1::<f64>::ones(4);
994
995 // gpu::linalg_dispatch dispatchers
996 crate::try_fast_ab(a.view(), b.view());
997 crate::try_fast_av(a.view(), v.view());
998 crate::try_fast_atv(a.view(), w.view());
999 let mut chol_in = Array2::<f64>::eye(3);
1000 crate::try_cholesky_lower_inplace(&mut chol_in);
1001
1002 // gpu::solver Cholesky entry points
1003 let h = Array2::<f64>::eye(3);
1004 let rhs = Array2::<f64>::zeros((3, 1));
1005 let solve_outcome = crate::solver::cholesky_solve_gpu(h.view(), rhs.view());
1006 let factor_outcome = crate::solver::cholesky_lower_gpu(h.view());
1007 match GpuRuntime::availability() {
1008 Ok(GpuAvailabilityRef::Absent(_)) => {
1009 assert!(
1010 solve_outcome.is_err(),
1011 "cholesky_solve_gpu must Err when runtime is unavailable"
1012 );
1013 assert!(
1014 factor_outcome.is_err(),
1015 "cholesky_lower_gpu must Err when runtime is unavailable"
1016 );
1017 }
1018 Ok(GpuAvailabilityRef::Available(_)) => {}
1019 Err(error) => panic!("GPU probe fault must fail this dispatch smoke test: {error}"),
1020 }
1021
1022 // NOTE: the weighted-crossprod GPU dispatcher with CPU fallback
1023 // (`weighted_crossprod_gpu`) moved out of this crate to `gam-solve`
1024 // (`gpu::pirls_gpu`) during the #1521 crate carve, since it depends on
1025 // the higher-level PIRLS assembly. Its panic-free / Ok-via-CPU-fallback
1026 // contract is now exercised by a regression test there
1027 // (`weighted_crossprod_gpu_cpu_fallback_*`), not from gam-gpu, which
1028 // cannot reach gam-solve without a dependency cycle.
1029 }
1030}