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