Skip to main content

gam_gpu/
driver.rs

1//! Shared CUDA driver presence/loading helpers used by every cuBLAS / cuSPARSE
2//! / cuSOLVER routing module.
3//!
4//! The GPU path uses ONE context model: cudarc's device PRIMARY context
5//! (`cuDevicePrimaryCtxRetain`, bound in `device_runtime::cuda_context_for`).
6//! cuBLAS/cuSOLVER/cuSPARSE handles attach to that current context; there is no
7//! separate user `cuCtxCreate` context (its removal fixed the #1017
8//! NOT_INITIALIZED handle failures). This module keeps only the libcuda
9//! presence probes, byte-size/layout helpers, and the `check_cuda` status wrap.
10
11use libloading::Library;
12#[cfg(target_os = "linux")]
13use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW};
14use ndarray::{Array2, ArrayBase, Data, Ix2};
15use std::borrow::Cow;
16use std::error::Error as StdError;
17use std::path::Path;
18#[cfg(target_os = "linux")]
19use std::path::PathBuf;
20use std::sync::OnceLock;
21
22use super::gpu_error::GpuError;
23
24pub type CuResult = i32;
25// NOTE (#1017): the `DriverApi` / `CudaWorkingState` / `DeviceAllocation` cluster
26// that lived here was REMOVED. It created a SEPARATE user CUDA context via
27// `cuCtxCreate` — distinct from cudarc's device PRIMARY context (cuDevicePrimaryCtxRetain)
28// that the live GPU path actually uses — which is the documented cause of the
29// cuBLAS/cuSOLVER NOT_INITIALIZED handle failures (handles bind to whichever
30// context is current). The cluster had ZERO consumers once the runtime routed
31// through `cuda_context_for` (the primary context) in `device_runtime.rs`, so it
32// was dead dual-context code. Keep ONE context model: the cudarc primary context.
33// Do not reintroduce `cuCtxCreate` for issuing work.
34
35/// Bind to a CUDA driver that is ALREADY RESIDENT in this process, if any.
36///
37/// `RTLD_NOLOAD` makes `dlopen` return a handle only when some other
38/// component (e.g. torch) has already mapped the library — it never loads a
39/// new copy. Inside a torch process, walking the candidate list below can
40/// dlopen a SECOND driver instance (system driver vs the CUDA-toolkit compat
41/// driver at `/usr/local/cuda*/compat/`): CUDA contexts created by torch's
42/// instance are invisible to ours, which is the measured dual-stack failure
43/// "no CUDA context for ordinal 0" (and steering the loader at the compat
44/// driver instead breaks torch with error 803) — gam#2259. Binding to the
45/// resident copy first guarantees ONE driver instance per process, so gam's
46/// runtime shares torch's contexts; a standalone process has nothing
47/// resident and falls through to the candidate walk unchanged.
48#[cfg(target_os = "linux")]
49fn already_resident_cuda_driver() -> Option<Library> {
50    const RTLD_NOLOAD: std::os::raw::c_int = 0x4;
51    for soname in ["libcuda.so.1", "libcuda.so"] {
52        // SAFETY: RTLD_NOLOAD never runs a new loader initializer — it only
53        // binds to a library some other component already loaded.
54        if let Ok(library) = unsafe { UnixLibrary::open(Some(soname), RTLD_NOW | RTLD_NOLOAD) } {
55            return Some(library.into());
56        }
57    }
58    None
59}
60
61fn load_library_names(candidates: &[String]) -> Result<Library, GpuError> {
62    #[cfg(target_os = "linux")]
63    if let Some(resident) = already_resident_cuda_driver() {
64        return Ok(resident);
65    }
66    let mut load_faults = Vec::new();
67    for candidate in candidates {
68        // SAFETY: Library::new runs the library's loader initializer; we
69        // only pass CUDA driver candidates discovered from fixed NVIDIA
70        // driver directories or canonical libcuda sonames.
71        match unsafe { Library::new(candidate) } {
72            Ok(library) => return Ok(library),
73            Err(error) => {
74                let detail = library_load_error_detail(&error);
75                let candidate_present = Path::new(candidate).components().count() > 1
76                    && std::fs::symlink_metadata(candidate).is_ok();
77                if !load_failure_is_candidate_absence(candidate, candidate_present, &detail) {
78                    load_faults.push(format!("{candidate}: {detail}"));
79                }
80            }
81        }
82    }
83    if !load_faults.is_empty() {
84        return Err(GpuError::DriverLibraryLoadFailed {
85            reason: format!(
86                "CUDA library candidates were found but failed to load: {}",
87                load_faults.join("; ")
88            ),
89        });
90    }
91    Err(GpuError::DriverLibraryUnavailable {
92        reason: format!("could not load any of: {}", candidates.join(", ")),
93    })
94}
95
96/// Recover the platform loader's actual diagnostic from `libloading`.
97///
98/// `libloading 0.9` deliberately made its top-level `Display` stable and
99/// generic (`"dlopen failed"` / `"LoadLibraryExW failed"`); the `dlerror()` or
100/// Windows loader detail now lives in the standard error source chain. CUDA
101/// admission needs that detail to distinguish a genuinely absent driver from
102/// a present library with an ABI or transitive-dependency fault.
103fn library_load_error_detail(error: &libloading::Error) -> String {
104    let mut detail = error.to_string();
105    let mut source = StdError::source(error);
106    while let Some(cause) = source {
107        detail = cause.to_string();
108        source = cause.source();
109    }
110    detail
111}
112
113/// True only when a failed loader attempt proves that the requested candidate
114/// itself is absent. A named object that exists but is corrupt, ABI-incompatible,
115/// or missing a transitive dependency is a load fault. For bare sonames, glibc's
116/// missing-object diagnostic begins with the requested soname; a missing
117/// transitive dependency begins with that dependency instead and is therefore
118/// deliberately not classified as absence.
119fn load_failure_is_candidate_absence(
120    candidate: &str,
121    candidate_present: bool,
122    message: &str,
123) -> bool {
124    if Path::new(candidate).components().count() > 1 {
125        return !candidate_present;
126    }
127    let missing_object = message.starts_with(candidate)
128        && (message.contains("No such file or directory")
129            || message.contains("cannot open shared object file")
130            || message.contains("image not found"));
131    missing_object
132}
133
134fn load_static_cuda_driver_library() -> Result<&'static Library, GpuError> {
135    static LIBRARY: OnceLock<Result<Library, GpuError>> = OnceLock::new();
136    LIBRARY
137        .get_or_init(|| load_library_names(&cuda_library_candidate_names()))
138        .as_ref()
139        .map_err(Clone::clone)
140}
141
142pub fn preload_cuda_driver() -> Result<(), GpuError> {
143    static PRELOAD: OnceLock<Result<(), GpuError>> = OnceLock::new();
144    PRELOAD
145        .get_or_init(|| {
146            load_static_cuda_driver_library()?;
147            Ok(())
148        })
149        .clone()
150}
151
152#[cfg(test)]
153mod loader_classification_tests {
154    #[cfg(target_os = "linux")]
155    use super::library_load_error_detail;
156    use super::load_failure_is_candidate_absence;
157
158    #[cfg(target_os = "linux")]
159    #[test]
160    fn libloading_source_preserves_the_missing_bare_soname() {
161        const SONAME: &str = "libgamfit_cuda_driver_absence_probe.so.2411";
162        // SAFETY: this deliberately attempts to open a unique nonexistent
163        // soname and retains no symbols or library handle.
164        let error = match unsafe { libloading::Library::new(SONAME) } {
165            Ok(_) => panic!("the CUDA absence-probe soname unexpectedly exists"),
166            Err(error) => error,
167        };
168        let detail = library_load_error_detail(&error);
169        assert!(
170            load_failure_is_candidate_absence(SONAME, false, &detail),
171            "missing-soname detail was not classified as absence: {detail}"
172        );
173    }
174
175    #[test]
176    fn missing_bare_soname_is_absence() {
177        assert!(load_failure_is_candidate_absence(
178            "libcuda.so.1",
179            false,
180            "libcuda.so.1: cannot open shared object file: No such file or directory",
181        ));
182    }
183
184    #[test]
185    fn missing_transitive_dependency_is_a_load_fault() {
186        assert!(!load_failure_is_candidate_absence(
187            "libcuda.so.1",
188            false,
189            "libnvidia-fatbinaryloader.so.555: cannot open shared object file: No such file or directory",
190        ));
191    }
192
193    #[test]
194    fn present_but_invalid_absolute_candidate_is_a_load_fault() {
195        assert!(!load_failure_is_candidate_absence(
196            "/opt/cuda/libcuda.so.1",
197            true,
198            "/opt/cuda/libcuda.so.1: invalid ELF header",
199        ));
200    }
201
202    #[test]
203    fn absent_absolute_candidate_is_absence() {
204        assert!(load_failure_is_candidate_absence(
205            "/opt/cuda/libcuda.so.1",
206            false,
207            "/opt/cuda/libcuda.so.1: cannot open shared object file: No such file or directory",
208        ));
209    }
210}
211
212#[cfg(target_os = "linux")]
213fn preload_cuda_userspace_libraries() -> Result<(), String> {
214    static PRELOAD: OnceLock<Result<Vec<UnixLibrary>, String>> = OnceLock::new();
215    PRELOAD
216        .get_or_init(|| {
217            let paths = cuda_userspace_preload_paths()?;
218            if paths.is_empty() {
219                return Ok(Vec::new());
220            }
221            let mut loaded = Vec::new();
222            for path in paths {
223                // SAFETY: these candidates are CUDA userspace libraries found
224                // in canonical toolkit directories or pip's nvidia-*-cu12
225                // wheel layout. RTLD_GLOBAL is required so transitive deps
226                // such as libcusolver -> libnvJitLink resolve without an
227                // LD_LIBRARY_PATH mutation.
228                match unsafe { UnixLibrary::open(Some(&path), RTLD_NOW | RTLD_GLOBAL) } {
229                    Ok(library) => loaded.push(library),
230                    Err(err) => {
231                        return Err(format!(
232                            "could not preload CUDA userspace library {}: {err}",
233                            path.display()
234                        ));
235                    }
236                }
237            }
238            Ok(loaded)
239        })
240        .as_ref()
241        .map_err(Clone::clone)?;
242    // The loaded handles stay resident in `PRELOAD` for the process lifetime,
243    // which is the whole point of the preload; callers need only the verdict.
244    Ok(())
245}
246
247/// Require that the platform loader can open the named CUDA compute library
248/// (`cublas`, `cusolver`, `cusparse`) from the one selected userspace stack.
249///
250/// cudarc 0.19 attempts to lazy-load these via its own generated
251/// `panic_no_lib_found` helpers the first time `CudaBlas::new` /
252/// `DnHandle::new` / cuSPARSE handle creation is invoked. On a host that
253/// has only the CUDA *driver* (e.g. large-scale workbench images expose
254/// `libcuda.so.1` but no cuBLAS at all), those calls panic out of the
255/// PyO3 FFI boundary instead of returning a typed error.
256///
257/// `GpuRuntime::probe()` calls this for every compute library it depends on;
258/// failure retains the exact stack-selection or loader error in the typed GPU
259/// refusal and keeps cudarc's panic completely off the call path.
260pub fn require_cuda_compute_library(stem: &str) -> Result<(), String> {
261    // Cache the probe per stem and KEEP the loaded handle alive for the process
262    // lifetime. Dropping the `Library` here dlclose's it; that dlopen+dlclose
263    // cycle tears down the compute library's global init state, after which
264    // cudarc's own cublasCreate / cusolverDnCreate fail
265    // CUBLAS/CUSOLVER_STATUS_NOT_INITIALIZED on the next handle creation (the GPU
266    // then silently declines and falls back to CPU). Holding the handle keeps the
267    // library mapped and initialized so cudarc reuses it intact.
268    static PROBED: OnceLock<
269        std::sync::Mutex<std::collections::HashMap<String, Result<(), String>>>,
270    > = OnceLock::new();
271    static KEEP_ALIVE: OnceLock<std::sync::Mutex<Vec<Library>>> = OnceLock::new();
272    let probed = PROBED.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
273    if let Ok(cache) = probed.lock() {
274        if let Some(outcome) = cache.get(stem) {
275            return outcome.clone();
276        }
277    }
278    #[cfg(target_os = "linux")]
279    preload_cuda_userspace_libraries()?;
280    let outcome = match load_library_names(&cuda_compute_library_candidate_names(stem)) {
281        Ok(library) => {
282            if let Ok(mut keep) = KEEP_ALIVE
283                .get_or_init(|| std::sync::Mutex::new(Vec::new()))
284                .lock()
285            {
286                keep.push(library);
287            }
288            Ok(())
289        }
290        Err(error) => Err(error.to_string()),
291    };
292    if let Ok(mut cache) = probed.lock() {
293        cache.insert(stem.to_string(), outcome.clone());
294    }
295    outcome
296}
297
298#[cfg(target_os = "linux")]
299fn cuda_userspace_preload_paths() -> Result<Vec<PathBuf>, String> {
300    // A host package such as PyTorch may already own the process's CUDA
301    // userspace stack. Loading gam's system-first stack on top of that maps a
302    // second cudart/cuBLAS implementation and splits context/handle ownership.
303    // Continue the already-mapped stack instead: pip NVIDIA wheels are spread
304    // across component directories under one `nvidia/` root, while a system
305    // toolkit keeps this preload set in one directory.
306    let mapped = mapped_cuda_userspace_libraries()?;
307    if !mapped.is_empty() {
308        return complete_mapped_cuda_stack(&mapped);
309    }
310
311    let system_dirs = cuda_system_library_dirs();
312    for dir in &system_dirs {
313        if let Some(stack) = complete_system_cuda_stack(dir) {
314            return Ok(dedup_paths(stack));
315        }
316        if let Some(stack) = system_cuda_stack_with_packaged_nvjitlink(dir) {
317            return Ok(dedup_paths(stack));
318        }
319    }
320    for root in nvidia_package_roots() {
321        if let Some(stack) = complete_nvidia_cuda_stack(&root) {
322            return Ok(dedup_paths(stack));
323        }
324    }
325    Ok(Vec::new())
326}
327
328/// The CUDA component a userspace library path belongs to, taken from its
329/// SONAME stem (`libcublas.so.12` -> `cublas`, `libnvJitLink.so.12` ->
330/// `nvJitLink`). Used to reason about which mapped libraries are the same
331/// component sourced from different roots.
332#[cfg(target_os = "linux")]
333fn cuda_library_component(path: &Path) -> Option<String> {
334    let name = path.file_name()?.to_str()?;
335    let stem = name.strip_prefix("lib")?.split(".so").next()?;
336    if stem.is_empty() {
337        None
338    } else {
339        Some(stem.to_string())
340    }
341}
342
343/// The CUDA *compute* libraries. These are handle-based and share context /
344/// workspace state with whatever copy the process already initialised, so a
345/// second copy must never be mapped from a different root — that split is the
346/// double-free / `NOT_INITIALIZED` hazard the mapped-stack continuation exists
347/// to avoid. `cudart` / `nvJitLink` are runtime/driver-adjacent and tolerate a
348/// redundant duplicate (dlopen-by-SONAME binds one deterministically).
349#[cfg(target_os = "linux")]
350fn is_cuda_compute_component(component: &str) -> bool {
351    matches!(component, "cublas" | "cublasLt" | "cusolver" | "cusparse")
352}
353
354#[cfg(target_os = "linux")]
355fn complete_mapped_cuda_stack(mapped: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
356    let canonical = |p: &Path| {
357        p.canonicalize().unwrap_or_else(|error| {
358            log::debug!(
359                "cuda stack: cannot canonicalize {}: {error}; \
360                 comparing the path as given",
361                p.display()
362            );
363            p.to_path_buf()
364        })
365    };
366
367    let mut candidates = Vec::new();
368    for path in mapped {
369        if let Some(root) = nvidia_package_root_for_library(path)
370            && let Some(stack) = complete_nvidia_cuda_stack(&root)
371        {
372            candidates.push(stack);
373        }
374        if let Some(parent) = path.parent()
375            && let Some(stack) = complete_system_cuda_stack_path(parent)
376        {
377            candidates.push(stack);
378        }
379    }
380
381    // Canonical path + component for every mapped library, computed once.
382    let mapped_meta: Vec<(PathBuf, Option<String>)> = mapped
383        .iter()
384        .map(|m| (canonical(m), cuda_library_component(m)))
385        .collect();
386
387    // Fast path: a single complete stack that contains EVERY mapped library.
388    // This is the unchanged behaviour for a process whose CUDA userspace all
389    // comes from one root (a pure system toolkit or a pure pip-wheel stack).
390    for stack in &candidates {
391        let stack = dedup_paths(stack.clone());
392        let stack_canon: Vec<PathBuf> = stack.iter().map(|c| canonical(c)).collect();
393        if mapped_meta
394            .iter()
395            .all(|(m, _)| stack_canon.iter().any(|c| c == m))
396        {
397            return Ok(stack);
398        }
399    }
400
401    // Split-stack path: a process can legitimately map a system `libcudart`
402    // (pulled onto the default loader path by ldconfig) alongside a host
403    // framework's pip-wheel stack (e.g. PyTorch's `nvidia-*-cu12` wheels), so
404    // NO single complete stack contains every mapped path. Continue a stack
405    // only when the process is genuinely using ALL of it, and only tolerate a
406    // benign duplicate — never a split of the handle-based compute libraries:
407    //
408    //   (a) every library in the candidate stack is already mapped — we
409    //       CONTINUE a stack the process runs on, we never introduce a library
410    //       from a partially-present root; and
411    //   (b) every mapped library is either part of that stack or a redundant
412    //       duplicate of a NON-compute component (`cudart` / `nvJitLink`) the
413    //       stack already provides. A mapped compute library (`cuBLAS` /
414    //       `cuSOLVER` / `cuSPARSE`) from another root is a genuine split and
415    //       disqualifies the candidate — mapping a second copy is the
416    //       double-free / `NOT_INITIALIZED` hazard this continuation avoids.
417    //
418    // Libraries mixed across two partially-mapped roots satisfy neither, so the
419    // conservative refusal below still fires for them.
420    for stack in candidates {
421        let stack = dedup_paths(stack);
422        let stack_canon: Vec<PathBuf> = stack.iter().map(|c| canonical(c)).collect();
423        let fully_mapped = stack_canon
424            .iter()
425            .all(|s| mapped_meta.iter().any(|(m, _)| m == s));
426        if !fully_mapped {
427            continue;
428        }
429        let consistent = mapped_meta.iter().all(|(m, component)| {
430            if stack_canon.iter().any(|s| s == m) {
431                return true;
432            }
433            match component {
434                Some(component) if !is_cuda_compute_component(component) => stack
435                    .iter()
436                    .any(|s| cuda_library_component(s).as_deref() == Some(component)),
437                _ => false,
438            }
439        });
440        if consistent {
441            return Ok(stack);
442        }
443    }
444
445    Err(format!(
446        "CUDA userspace is already mapped from no single complete stack: {}",
447        mapped
448            .iter()
449            .map(|path| path.display().to_string())
450            .collect::<Vec<_>>()
451            .join(", ")
452    ))
453}
454
455#[cfg(target_os = "linux")]
456fn mapped_cuda_userspace_libraries() -> Result<Vec<PathBuf>, String> {
457    let maps = std::fs::read_to_string("/proc/self/maps")
458        .map_err(|error| format!("cannot inspect mapped CUDA userspace libraries: {error}"))?;
459    let mut mapped = Vec::new();
460    for line in maps.lines() {
461        let Some(raw_path) = line.split_whitespace().last() else {
462            continue;
463        };
464        if !raw_path.starts_with('/') {
465            continue;
466        }
467        let path = PathBuf::from(raw_path);
468        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
469            continue;
470        };
471        if [
472            "libcudart.so",
473            "libnvJitLink.so",
474            "libcublasLt.so",
475            "libcublas.so",
476            "libcusparse.so",
477            "libcusolver.so",
478        ]
479        .iter()
480        .any(|prefix| name.starts_with(prefix))
481        {
482            mapped.push(path);
483        }
484    }
485    Ok(dedup_paths(mapped))
486}
487
488#[cfg(target_os = "linux")]
489fn nvidia_package_root_for_library(path: &Path) -> Option<PathBuf> {
490    path.ancestors()
491        .find(|ancestor| ancestor.file_name().and_then(|name| name.to_str()) == Some("nvidia"))
492        .map(Path::to_path_buf)
493}
494
495fn cuda_compute_library_candidate_names(stem: &str) -> Vec<String> {
496    let base = format!("lib{stem}");
497    let mut out: Vec<String> = Vec::new();
498    // Bare soname forms — exercised by the platform loader against
499    // LD_LIBRARY_PATH and the default search dirs.
500    out.push(format!("{base}.so"));
501    out.push(format!("{base}.so.1"));
502    // Major-version walk mirroring cudarc's own candidate list so the
503    // preflight agrees with whatever cudarc would have tried next.
504    for major in (9..=13).rev() {
505        out.push(format!("{base}.so.{major}"));
506    }
507    #[cfg(target_os = "linux")]
508    {
509        for dir in cuda_system_library_dirs() {
510            out.push(format!("{dir}/{base}.so"));
511            for major in (9..=13).rev() {
512                out.push(format!("{dir}/{base}.so.{major}"));
513            }
514            append_versioned_linux_so_candidates(&mut out, Path::new(dir), &base);
515        }
516        for root in nvidia_package_roots() {
517            let lib_dir = root.join(nvidia_component_for_stem(stem)).join("lib");
518            out.push(format!("{}/{}.so", lib_dir.display(), base));
519            for major in (9..=13).rev() {
520                out.push(format!("{}/{}.so.{major}", lib_dir.display(), base));
521            }
522            append_versioned_linux_so_candidates(&mut out, &lib_dir, &base);
523        }
524    }
525    out
526}
527
528#[cfg(target_os = "linux")]
529fn cuda_system_library_dirs() -> Vec<&'static str> {
530    vec![
531        "/usr/local/cuda/lib64",
532        "/usr/local/cuda/lib",
533        "/usr/local/cuda/targets/x86_64-linux/lib",
534        "/usr/lib/x86_64-linux-gnu",
535        "/usr/lib64",
536        "/usr/lib/wsl/lib",
537        "/opt/cuda/lib64",
538    ]
539}
540
541#[cfg(target_os = "linux")]
542fn complete_system_cuda_stack(dir: &str) -> Option<Vec<PathBuf>> {
543    complete_system_cuda_stack_path(Path::new(dir))
544}
545
546#[cfg(target_os = "linux")]
547fn complete_system_cuda_stack_path(dir: &Path) -> Option<Vec<PathBuf>> {
548    let stack = vec![
549        first_existing(dir, &["libcudart.so.13", "libcudart.so.12", "libcudart.so"])?,
550        first_existing(
551            dir,
552            &[
553                "libnvJitLink.so.13",
554                "libnvJitLink.so.12",
555                "libnvJitLink.so",
556            ],
557        )?,
558        first_existing(
559            dir,
560            &["libcublasLt.so.13", "libcublasLt.so.12", "libcublasLt.so"],
561        )?,
562        first_existing(dir, &["libcublas.so.13", "libcublas.so.12", "libcublas.so"])?,
563        first_existing(
564            dir,
565            &["libcusparse.so.13", "libcusparse.so.12", "libcusparse.so"],
566        )?,
567        first_existing(
568            dir,
569            &[
570                "libcusolver.so.13",
571                "libcusolver.so.12",
572                "libcusolver.so.11",
573                "libcusolver.so",
574            ],
575        )?,
576    ];
577    Some(stack)
578}
579
580#[cfg(target_os = "linux")]
581fn system_cuda_stack_with_packaged_nvjitlink(dir: &str) -> Option<Vec<PathBuf>> {
582    let dir = Path::new(dir);
583    let nvjitlink = packaged_nvjitlink_library()?;
584    let stack = vec![
585        first_existing(dir, &["libcudart.so.13", "libcudart.so.12", "libcudart.so"])?,
586        nvjitlink,
587        first_existing(
588            dir,
589            &["libcublasLt.so.13", "libcublasLt.so.12", "libcublasLt.so"],
590        )?,
591        first_existing(dir, &["libcublas.so.13", "libcublas.so.12", "libcublas.so"])?,
592        first_existing(
593            dir,
594            &["libcusparse.so.13", "libcusparse.so.12", "libcusparse.so"],
595        )?,
596        first_existing(
597            dir,
598            &[
599                "libcusolver.so.13",
600                "libcusolver.so.12",
601                "libcusolver.so.11",
602                "libcusolver.so",
603            ],
604        )?,
605    ];
606    Some(stack)
607}
608
609#[cfg(target_os = "linux")]
610fn complete_nvidia_cuda_stack(root: &Path) -> Option<Vec<PathBuf>> {
611    let stack = vec![
612        first_existing(
613            &root.join("cuda_runtime").join("lib"),
614            &["libcudart.so.13", "libcudart.so.12", "libcudart.so"],
615        )?,
616        first_existing(
617            &root.join("nvjitlink").join("lib"),
618            &[
619                "libnvJitLink.so.13",
620                "libnvJitLink.so.12",
621                "libnvJitLink.so",
622            ],
623        )?,
624        first_existing(
625            &root.join("cublas").join("lib"),
626            &["libcublasLt.so.13", "libcublasLt.so.12", "libcublasLt.so"],
627        )?,
628        first_existing(
629            &root.join("cublas").join("lib"),
630            &["libcublas.so.13", "libcublas.so.12", "libcublas.so"],
631        )?,
632        first_existing(
633            &root.join("cusparse").join("lib"),
634            &["libcusparse.so.13", "libcusparse.so.12", "libcusparse.so"],
635        )?,
636        first_existing(
637            &root.join("cusolver").join("lib"),
638            &[
639                "libcusolver.so.13",
640                "libcusolver.so.12",
641                "libcusolver.so.11",
642                "libcusolver.so",
643            ],
644        )?,
645    ];
646    Some(stack)
647}
648
649#[cfg(target_os = "linux")]
650fn packaged_nvjitlink_library() -> Option<PathBuf> {
651    for root in nvidia_package_roots() {
652        let lib_dir = root.join("nvjitlink").join("lib");
653        if let Some(path) = first_existing(
654            &lib_dir,
655            &[
656                "libnvJitLink.so.13",
657                "libnvJitLink.so.12",
658                "libnvJitLink.so",
659            ],
660        ) {
661            return Some(path);
662        }
663    }
664    None
665}
666
667#[cfg(target_os = "linux")]
668fn nvidia_component_for_stem(stem: &str) -> String {
669    match stem {
670        "cublas" => "cublas".to_string(),
671        "cusolver" => "cusolver".to_string(),
672        "cusparse" => "cusparse".to_string(),
673        "nvJitLink" | "nvjitlink" => "nvjitlink".to_string(),
674        "cudart" | "cuda_runtime" => "cuda_runtime".to_string(),
675        _ => stem.to_string(),
676    }
677}
678
679#[cfg(target_os = "linux")]
680fn nvidia_package_roots() -> Vec<PathBuf> {
681    let mut roots = Vec::new();
682    if let Some(home) = current_user_home_dir() {
683        collect_python_nvidia_roots(home.join(".local/lib"), &mut roots);
684    }
685    collect_python_nvidia_roots(Path::new("/usr/local/lib").to_path_buf(), &mut roots);
686    collect_python_nvidia_roots(Path::new("/usr/lib").to_path_buf(), &mut roots);
687    dedup_paths(roots)
688}
689
690#[cfg(target_os = "linux")]
691fn current_user_home_dir() -> Option<PathBuf> {
692    let status = std::fs::read_to_string("/proc/self/status").ok()?;
693    let uid = status
694        .lines()
695        .find_map(|line| line.strip_prefix("Uid:"))?
696        .split_whitespace()
697        .next()?;
698    let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
699    for line in passwd.lines() {
700        let mut fields = line.split(':');
701        fields.next()?;
702        fields.next()?;
703        if fields.next()? != uid {
704            continue;
705        }
706        fields.next()?;
707        fields.next()?;
708        return Some(PathBuf::from(fields.next()?));
709    }
710    None
711}
712
713#[cfg(target_os = "linux")]
714fn collect_python_nvidia_roots(base: PathBuf, out: &mut Vec<PathBuf>) {
715    let Ok(entries) = std::fs::read_dir(base) else {
716        return;
717    };
718    for entry in entries.flatten() {
719        let path = entry.path();
720        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
721            continue;
722        };
723        if !name.starts_with("python") {
724            continue;
725        }
726        for site_dir in ["site-packages", "dist-packages"] {
727            let root = path.join(site_dir).join("nvidia");
728            if root.exists() {
729                out.push(root);
730            }
731        }
732    }
733}
734
735#[cfg(target_os = "linux")]
736fn first_existing(dir: &Path, names: &[&str]) -> Option<PathBuf> {
737    for name in names {
738        let path = dir.join(name);
739        if path.exists() {
740            return Some(path);
741        }
742    }
743    None
744}
745
746#[cfg(target_os = "linux")]
747fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
748    let mut out = Vec::new();
749    for path in paths {
750        let canonical = path.canonicalize().unwrap_or(path);
751        if !out.iter().any(|existing| existing == &canonical) {
752            out.push(canonical);
753        }
754    }
755    out
756}
757
758#[cfg(target_os = "linux")]
759fn append_versioned_linux_so_candidates(out: &mut Vec<String>, dir: &Path, base: &str) {
760    let Ok(entries) = std::fs::read_dir(dir) else {
761        return;
762    };
763    let prefix = format!("{base}.so.");
764    let mut versioned = Vec::new();
765    for entry in entries.flatten() {
766        let path = entry.path();
767        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
768            continue;
769        };
770        if name.starts_with(&prefix) {
771            versioned.push(path);
772        }
773    }
774    versioned.sort();
775    for path in versioned {
776        let candidate = path.to_string_lossy().into_owned();
777        if !out.iter().any(|existing| existing == &candidate) {
778            out.push(candidate);
779        }
780    }
781}
782
783fn cuda_library_candidate_names() -> Vec<String> {
784    let mut out: Vec<String> = cuda_library_candidates()
785        .iter()
786        .map(|candidate| (*candidate).to_string())
787        .collect();
788    if cfg!(target_os = "linux") {
789        for dir in [
790            "/usr/local/nvidia/lib64",
791            "/usr/local/nvidia/lib",
792            "/usr/local/cuda/compat",
793            "/usr/lib/x86_64-linux-gnu",
794            "/usr/lib64",
795            "/usr/lib/wsl/lib",
796        ] {
797            append_versioned_linux_libcuda_candidates(&mut out, Path::new(dir));
798        }
799    }
800    out
801}
802
803fn append_versioned_linux_libcuda_candidates(out: &mut Vec<String>, dir: &Path) {
804    let Ok(entries) = std::fs::read_dir(dir) else {
805        return;
806    };
807    let mut versioned = Vec::new();
808    for entry in entries.flatten() {
809        let path = entry.path();
810        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
811            continue;
812        };
813        if name.starts_with("libcuda.so.") && name != "libcuda.so.1" {
814            versioned.push(path);
815        }
816    }
817    versioned.sort();
818    for path in versioned {
819        let candidate = path.to_string_lossy().into_owned();
820        if !out.iter().any(|existing| existing == &candidate) {
821            out.push(candidate);
822        }
823    }
824}
825
826pub fn cuda_library_candidates() -> &'static [&'static str] {
827    if cfg!(target_os = "windows") {
828        &["nvcuda.dll"]
829    } else if cfg!(target_os = "macos") {
830        &["/usr/local/cuda/lib/libcuda.dylib", "libcuda.dylib"]
831    } else {
832        &[
833            "/usr/local/nvidia/lib64/libcuda.so.1",
834            "/usr/local/nvidia/lib64/libcuda.so",
835            "/usr/local/nvidia/lib/libcuda.so.1",
836            "/usr/local/nvidia/lib/libcuda.so",
837            "/usr/local/cuda/compat/libcuda.so.1",
838            "/usr/local/cuda/compat/libcuda.so",
839            "/usr/lib/x86_64-linux-gnu/libcuda.so.1",
840            "/usr/lib/x86_64-linux-gnu/libcuda.so",
841            "/usr/lib64/libcuda.so.1",
842            "/usr/lib64/libcuda.so",
843            "/usr/lib/wsl/lib/libcuda.so.1",
844            "/usr/lib/wsl/lib/libcuda.so",
845            "libcuda.so.1",
846            "libcuda.so",
847        ]
848    }
849}
850
851#[inline]
852pub fn to_i32(value: usize) -> Option<i32> {
853    i32::try_from(value).ok()
854}
855
856/// Repack a 2D `ndarray::ArrayBase` (row-major) into the column-major
857/// layout expected by every cuBLAS / cuSOLVER entry point.
858///
859/// Walks each column once via ndarray's iter (no per-element bounds checks)
860/// and extends into a pre-sized `Vec`. On large-scale inputs (n≈3×10⁵,
861/// p≈35) this replaces a per-element `a[[row, col]]` indexing loop that
862/// dominated the host side of every GPU dispatch.
863///
864/// Fast path: if the input is already F-order (column-major, contiguous in
865/// memory-order), borrow its raw buffer directly — no allocation, no copy.
866/// Standard row-major ndarrays still go through the permutation path.
867pub fn to_col_major<'a, S: Data<Elem = f64>>(a: &'a ArrayBase<S, Ix2>) -> Cow<'a, [f64]> {
868    let (rows, cols) = a.dim();
869    let strides = a.strides();
870    // F-order contiguous: column stride == 1, row stride == rows.
871    // `as_slice_memory_order` confirms the buffer is contiguous in memory.
872    if rows > 0
873        && cols > 0
874        && strides[0] == 1
875        && strides[1] == rows as isize
876        && let Some(slice) = a.as_slice_memory_order()
877    {
878        return Cow::Borrowed(slice);
879    }
880    let mut out: Vec<f64> = Vec::with_capacity(rows.saturating_mul(cols));
881    for col in 0..cols {
882        out.extend(a.column(col).iter().copied());
883    }
884    Cow::Owned(out)
885}
886
887/// Borrow (or pack) a 2D array's buffer in ROW-major (C) order.
888///
889/// The col-major dual of [`to_col_major`]: when the input is already
890/// C-contiguous its raw buffer IS the row-major flat layout, so this borrows
891/// it with no allocation or copy. Non-contiguous / F-order inputs are packed
892/// row by row.
893///
894/// This is the host-transpose-free upload path. A row-major `(r × c)` buffer,
895/// reinterpreted as a column-major buffer, is exactly the transpose `(c × r)`
896/// of the logical matrix — which is what the swapped-operand cuBLAS GEMM
897/// (`Cᵀ = Bᵀ·Aᵀ`) consumes, letting both the design upload and the result
898/// download skip the O(r·c) scalar permutation that dominated tall-skinny
899/// GEMMs on the host.
900pub fn to_row_major<'a, S: Data<Elem = f64>>(a: &'a ArrayBase<S, Ix2>) -> Cow<'a, [f64]> {
901    let (rows, cols) = a.dim();
902    let strides = a.strides();
903    // C-order contiguous: row stride == cols, column stride == 1.
904    if rows > 0
905        && cols > 0
906        && strides[1] == 1
907        && strides[0] == cols as isize
908        && let Some(slice) = a.as_slice_memory_order()
909    {
910        return Cow::Borrowed(slice);
911    }
912    let mut out: Vec<f64> = Vec::with_capacity(rows.saturating_mul(cols));
913    for row in 0..rows {
914        out.extend(a.row(row).iter().copied());
915    }
916    Cow::Owned(out)
917}
918
919/// Wrap a row-major flat buffer of shape `(rows, cols)` as an `Array2<f64>`
920/// without permutation. The buffer is consumed (no copy when its length
921/// matches). Returns `None` on a length mismatch.
922pub fn array_from_row_major(values: Vec<f64>, rows: usize, cols: usize) -> Option<Array2<f64>> {
923    if values.len() != rows.checked_mul(cols)? {
924        return None;
925    }
926    Array2::from_shape_vec((rows, cols), values).ok()
927}
928
929/// Convert a column-major flat buffer back into row-major `Array2<f64>`.
930pub fn from_col_major_inplace(values: &[f64], out: &mut Array2<f64>) -> Option<()> {
931    let (rows, cols) = out.dim();
932    if values.len() != rows.checked_mul(cols)? {
933        return None;
934    }
935    for col in 0..cols {
936        let src = ndarray::ArrayView1::from(&values[col * rows..(col + 1) * rows]);
937        out.column_mut(col).assign(&src);
938    }
939    Some(())
940}
941
942pub fn from_col_major(values: &[f64], rows: usize, cols: usize) -> Option<Array2<f64>> {
943    let mut out = Array2::<f64>::zeros((rows, cols));
944    from_col_major_inplace(values, &mut out)?;
945    Some(out)
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use ndarray::array;
952
953    #[cfg(target_os = "linux")]
954    fn fake_nvidia_stack(root: &Path) -> Vec<PathBuf> {
955        let libraries = [
956            ("cuda_runtime", "libcudart.so.12"),
957            ("nvjitlink", "libnvJitLink.so.12"),
958            ("cublas", "libcublasLt.so.12"),
959            ("cublas", "libcublas.so.12"),
960            ("cusparse", "libcusparse.so.12"),
961            ("cusolver", "libcusolver.so.11"),
962        ];
963        libraries
964            .into_iter()
965            .map(|(component, name)| {
966                let path = root.join(component).join("lib").join(name);
967                std::fs::create_dir_all(path.parent().expect("library parent"))
968                    .expect("create fake CUDA component directory");
969                std::fs::write(&path, []).expect("create fake CUDA library");
970                path
971            })
972            .collect()
973    }
974
975    #[test]
976    fn to_i32_fits_small_value() {
977        assert_eq!(to_i32(0), Some(0));
978        assert_eq!(to_i32(42), Some(42));
979        assert_eq!(to_i32(i32::MAX as usize), Some(i32::MAX));
980    }
981
982    #[test]
983    fn to_i32_overflows_returns_none() {
984        assert_eq!(to_i32(i32::MAX as usize + 1), None);
985    }
986
987    #[test]
988    fn to_col_major_2x3_row_major() {
989        // Row-major [[1,2,3],[4,5,6]] → col-major [1,4,2,5,3,6]
990        let a = array![[1.0_f64, 2.0, 3.0], [4.0, 5.0, 6.0]];
991        let col = to_col_major(&a);
992        assert_eq!(&*col, &[1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
993    }
994
995    #[test]
996    fn to_col_major_identity_roundtrip() {
997        let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
998        let col = to_col_major(&a);
999        assert_eq!(&*col, &[1.0, 0.0, 0.0, 1.0]);
1000    }
1001
1002    #[test]
1003    fn from_col_major_2x3_roundtrip() {
1004        let original = array![[1.0_f64, 2.0, 3.0], [4.0, 5.0, 6.0]];
1005        let col = to_col_major(&original);
1006        let recovered = from_col_major(&col, 2, 3).expect("should succeed");
1007        assert_eq!(recovered, original);
1008    }
1009
1010    #[test]
1011    fn from_col_major_wrong_length_returns_none() {
1012        // 2x3 = 6 elements, but only 5 provided
1013        assert!(from_col_major(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 3).is_none());
1014    }
1015
1016    #[test]
1017    fn from_col_major_inplace_mismatched_buffer_returns_none() {
1018        let mut out = Array2::<f64>::zeros((3, 3));
1019        let short = vec![1.0_f64; 8]; // 9 expected, 8 given
1020        assert!(from_col_major_inplace(&short, &mut out).is_none());
1021    }
1022
1023    #[test]
1024    fn from_col_major_single_element() {
1025        let result = from_col_major(&[7.0], 1, 1).expect("should succeed");
1026        assert_eq!(result[[0, 0]], 7.0);
1027    }
1028
1029    #[cfg(target_os = "linux")]
1030    #[test]
1031    fn mapped_pytorch_stack_is_continued_as_one_complete_stack() {
1032        let temp = tempfile::tempdir().expect("temporary CUDA tree");
1033        let root = temp.path().join("site-packages").join("nvidia");
1034        let stack = fake_nvidia_stack(&root);
1035        let mapped = vec![stack[0].clone(), stack[3].clone()];
1036
1037        let selected = complete_mapped_cuda_stack(&mapped).expect("one mapped NVIDIA root");
1038
1039        assert_eq!(selected.len(), stack.len());
1040        assert!(mapped.iter().all(|path| {
1041            let canonical = path.canonicalize().expect("canonical fake library");
1042            selected.contains(&canonical)
1043        }));
1044    }
1045
1046    #[cfg(target_os = "linux")]
1047    #[test]
1048    fn mapped_mixed_cuda_stacks_are_refused() {
1049        let temp = tempfile::tempdir().expect("temporary CUDA tree");
1050        let first = fake_nvidia_stack(&temp.path().join("first").join("nvidia"));
1051        let second = fake_nvidia_stack(&temp.path().join("second").join("nvidia"));
1052        let mapped = vec![first[0].clone(), second[3].clone()];
1053
1054        let error = complete_mapped_cuda_stack(&mapped)
1055            .expect_err("libraries from two mapped roots must not be mixed");
1056
1057        assert!(error.contains("no single complete stack"));
1058    }
1059
1060    #[cfg(target_os = "linux")]
1061    #[test]
1062    fn mapped_pytorch_stack_with_redundant_system_cudart_is_continued() {
1063        // A process can map a system `libcudart` (pulled onto the default
1064        // loader path by ldconfig) alongside PyTorch's complete pip-wheel
1065        // stack. The whole pip stack is mapped; the extra system cudart is a
1066        // redundant duplicate of a non-compute component, so the pip stack is
1067        // continued rather than the GPU being refused (gam issue #2259).
1068        let temp = tempfile::tempdir().expect("temporary CUDA tree");
1069        let pip = fake_nvidia_stack(&temp.path().join("site-packages").join("nvidia"));
1070
1071        // A stray system cudart from an unrelated toolkit directory (no
1072        // complete stack of its own next to it).
1073        let sys_dir = temp.path().join("usr").join("local").join("cuda").join("lib");
1074        std::fs::create_dir_all(&sys_dir).expect("system lib dir");
1075        let sys_cudart = sys_dir.join("libcudart.so.12");
1076        std::fs::write(&sys_cudart, []).expect("system cudart");
1077
1078        // Everything from the pip stack is mapped, plus the redundant cudart.
1079        let mut mapped = pip.clone();
1080        mapped.push(sys_cudart);
1081
1082        let selected =
1083            complete_mapped_cuda_stack(&mapped).expect("continue the fully-mapped pip stack");
1084
1085        assert_eq!(selected.len(), pip.len());
1086        assert!(pip.iter().all(|path| {
1087            let canonical = path.canonicalize().expect("canonical fake library");
1088            selected.contains(&canonical)
1089        }));
1090    }
1091}