From 42ad496ae06e34ffec5707b58dacc549e09a6228 Mon Sep 17 00:00:00 2001 From: Chitta Sri Sankara Naga Pavana Saketha Ram Date: Mon, 19 Oct 2026 12:46:58 +0530 Subject: [PATCH] fix(cuda-bindings): require cuRAND headers during toolkit discovery Signed-off-by: Chitta Sri Sankara Naga Pavana Saketha Ram --- cuda-bindings/build.rs | 18 +++-- cuda-bindings/tests/toolkit_target.rs | 69 ++++++++++++++++++- cuda-bindings/toolkit_target.rs | 14 ++-- .../crates/cargo-oxide/src/commands/doctor.rs | 48 +++++++++---- .../crates/cargo-oxide/src/commands/tests.rs | 58 ++++++++++++++-- 5 files changed, 180 insertions(+), 27 deletions(-) diff --git a/cuda-bindings/build.rs b/cuda-bindings/build.rs index d2d567ed1c..aeb9f7f0c7 100644 --- a/cuda-bindings/build.rs +++ b/cuda-bindings/build.rs @@ -133,7 +133,7 @@ fn run() -> Result<(), Box> { } /// A validated CUDA toolkit: its install root and the include directory that -/// actually contains `cuda.h` (`{root}/include` for standard installs, +/// actually contains `cuda.h` and `curand.h` (`{root}/include` for standard installs, /// `{root}/targets//include` for redistributable and Tegra/sbsa layouts /// that ship no top-level `include/`). struct ResolvedToolkit { @@ -242,10 +242,10 @@ fn default_cuda_toolkit_candidates() -> &'static [PathBuf] { } /// Validates a toolkit root: probes the standard `include/` and the -/// `targets//include/` layouts for `cuda.h` (only the directories +/// `targets//include/` layouts for `cuda.h` and `curand.h` (only the directories /// [`build_include_candidates`] yields for the build target's own /// architecture, never all of `targets/*`), then checks the version floor. -/// Returns the version and the include directory that contains `cuda.h`. +/// Returns the version and the include directory containing both headers. fn validate_cuda_toolkit(cuda_toolkit: &Path) -> Result<(u32, PathBuf), String> { if !cuda_toolkit.is_dir() { return Err(format!("{} is not a directory", cuda_toolkit.display())); @@ -255,10 +255,18 @@ fn validate_cuda_toolkit(cuda_toolkit: &Path) -> Result<(u32, PathBuf), String> let Some(include_dir) = select_include_dir(&candidates) else { let probed: Vec = candidates .iter() - .map(|dir| format!(" {}", dir.join("cuda.h").display())) + .map(|dir| { + let missing = REQUIRED_CUDA_HEADERS + .iter() + .copied() + .filter(|header| !dir.join(header).is_file()) + .collect::>() + .join(", "); + format!(" {}: missing {missing}", dir.display()) + }) .collect(); return Err(format!( - "{} does not contain cuda.h. Probed:\n{}", + "{} has no include directory containing both cuda.h and curand.h. Probed:\n{}", cuda_toolkit.display(), probed.join("\n") )); diff --git a/cuda-bindings/tests/toolkit_target.rs b/cuda-bindings/tests/toolkit_target.rs index 560dfc1d4d..6bf37f26b6 100644 --- a/cuda-bindings/tests/toolkit_target.rs +++ b/cuda-bindings/tests/toolkit_target.rs @@ -64,7 +64,7 @@ fn every_candidate_is_a_linux_directory_for_its_own_arch() { } } -/// Builds a throwaway toolkit tree containing a `cuda.h` at each given +/// Builds a throwaway toolkit tree containing both required headers at each given /// relative directory, and returns its root. No temp-dir crate: this package /// takes no new dependencies. fn fake_toolkit(tag: &str, include_dirs: &[&str]) -> std::path::PathBuf { @@ -78,6 +78,7 @@ fn fake_toolkit(tag: &str, include_dirs: &[&str]) -> std::path::PathBuf { let full = root.join(dir); std::fs::create_dir_all(&full).expect("create fake toolkit dir"); std::fs::write(full.join("cuda.h"), b"/* fake */\n").expect("write fake cuda.h"); + std::fs::write(full.join("curand.h"), b"/* fake */\n").expect("write fake curand.h"); } root } @@ -129,6 +130,70 @@ fn top_level_include_wins_over_any_targets_tree() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn incomplete_include_directory_is_rejected() { + for layout in ["include", "targets/sbsa-linux/include"] { + for missing in ["cuda.h", "curand.h"] { + let root = fake_toolkit("incomplete", &[layout]); + std::fs::remove_file(root.join(layout).join(missing)).unwrap(); + let candidates = resolve_toolkit_include_candidates(&root, None, "aarch64", "linux"); + assert!( + select_include_dir(&candidates).is_none(), + "a toolkit missing {missing} in {layout} must not be accepted" + ); + let _ = std::fs::remove_dir_all(&root); + } + } +} + +#[test] +fn incomplete_include_directory_is_skipped_for_a_complete_target() { + let root = fake_toolkit( + "partial-standard", + &["include", "targets/sbsa-linux/include"], + ); + std::fs::remove_file(root.join("include/curand.h")).unwrap(); + let candidates = resolve_toolkit_include_candidates(&root, None, "aarch64", "linux"); + assert_eq!( + select_include_dir(&candidates), + Some(&root.join("targets/sbsa-linux/include")) + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn headers_from_different_include_directories_cannot_form_a_toolkit() { + let root = fake_toolkit("split-headers", &["include", "targets/sbsa-linux/include"]); + std::fs::remove_file(root.join("include/curand.h")).unwrap(); + std::fs::remove_file(root.join("targets/sbsa-linux/include/cuda.h")).unwrap(); + let candidates = resolve_toolkit_include_candidates(&root, None, "aarch64", "linux"); + assert!(select_include_dir(&candidates).is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn incomplete_override_does_not_fall_back_to_complete_host_headers() { + let root = fake_toolkit( + "partial-override", + &["include", "targets/sbsa-linux/include"], + ); + std::fs::remove_file(root.join("targets/sbsa-linux/include/curand.h")).unwrap(); + let candidates = + resolve_toolkit_include_candidates(&root, Some("sbsa-linux"), "x86_64", "linux"); + assert!(select_include_dir(&candidates).is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn required_headers_must_be_files() { + let root = fake_toolkit("header-directory", &["include"]); + std::fs::remove_file(root.join("include/curand.h")).unwrap(); + std::fs::create_dir(root.join("include/curand.h")).unwrap(); + let candidates = resolve_toolkit_include_candidates(&root, None, "x86_64", "linux"); + assert!(select_include_dir(&candidates).is_none()); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn env_override_is_the_sole_include_candidate() { // CUDA_TOOLKIT_TARGET_DIR names one tree by hand, like nvcc's @@ -179,7 +244,7 @@ fn env_override_beats_the_top_level_include() { #[test] fn wrong_env_override_fails_instead_of_falling_back() { // The override is existence-probed, not trusted: a typo must surface as - // the "does not contain cuda.h" error listing what was probed, never as + // the missing-header error listing what was probed, never as // a silent fallback to the top-level include or another architecture's // tree, even on a standard install where `include/` exists. let root = fake_toolkit("override-bad", &["include", "targets/sbsa-linux/include"]); diff --git a/cuda-bindings/toolkit_target.rs b/cuda-bindings/toolkit_target.rs index 545d77c309..ac16f173c1 100644 --- a/cuda-bindings/toolkit_target.rs +++ b/cuda-bindings/toolkit_target.rs @@ -30,7 +30,7 @@ /// aarch64 returns both server and Tegra names because a Rust aarch64 Linux /// triple does not distinguish them -- Jetson builds natively as /// `aarch64-unknown-linux-gnu`, exactly like a Grace server. The callers probe -/// these in order and take the first that actually contains `cuda.h`, so a +/// these in order and take the first that contains both required headers, so a /// single-target install resolves unambiguously either way. `sbsa-linux` is /// listed first so that an install carrying both trees resolves to the server /// directory. @@ -50,7 +50,7 @@ fn toolkit_target_dirs(target_arch: &str, target_os: &str) -> &'static [&'static } } -/// Include directories to probe for `cuda.h`, in priority order. +/// Include directories to probe for `cuda.h` and `curand.h`, in priority order. /// /// When `override_dir` carries a non-blank `CUDA_TOOLKIT_TARGET_DIR` value, /// that names one `targets/` tree by hand, like nvcc's `-target-dir` flag, @@ -86,7 +86,13 @@ fn resolve_toolkit_include_candidates( } } -/// The first candidate that actually contains `cuda.h`. +const REQUIRED_CUDA_HEADERS: [&str; 2] = ["cuda.h", "curand.h"]; + +/// The first candidate containing every header used to generate the bindings. fn select_include_dir(candidates: &[std::path::PathBuf]) -> Option<&std::path::PathBuf> { - candidates.iter().find(|dir| dir.join("cuda.h").is_file()) + candidates.iter().find(|dir| { + REQUIRED_CUDA_HEADERS + .iter() + .all(|header| dir.join(header).is_file()) + }) } diff --git a/cuda-oxide/crates/cargo-oxide/src/commands/doctor.rs b/cuda-oxide/crates/cargo-oxide/src/commands/doctor.rs index 4af245bcb6..19b61c4ef2 100644 --- a/cuda-oxide/crates/cargo-oxide/src/commands/doctor.rs +++ b/cuda-oxide/crates/cargo-oxide/src/commands/doctor.rs @@ -361,7 +361,7 @@ pub(super) fn backend_source_check( /// Validate the development environment. /// /// Checks for: Rust nightly toolchain, `rust-toolchain.toml`, the codegen -/// backend `.so` (informational), CUDA headers (`cuda.h`), CUDA toolkit +/// backend `.so` (informational), CUDA headers (`cuda.h`, `curand.h`), CUDA toolkit /// (`nvcc`, libNVVM, nvJitLink, libdevice), LLVM (`llc`), clang/libclang, /// the NVIDIA driver / GPU (informational), and optionally `cuda-gdb` / /// `compute-sanitizer`. @@ -444,11 +444,11 @@ pub fn doctor(ctx: &Context) { doctor_report_backend_source(ctx); } - // 4. CUDA headers (cuda.h). The host `cuda-bindings` crate cannot build + // 4. CUDA headers (cuda.h and curand.h). The host `cuda-bindings` crate cannot build // without them; cargo-oxide itself deliberately can, which is what makes // this check reachable on a toolkit-less machine instead of dying inside // cuda-bindings' build script (issue #87). - print!("CUDA headers (cuda.h)... "); + print!("CUDA headers (cuda.h, curand.h)... "); let target_dir_override = std::env::var("CUDA_TOOLKIT_TARGET_DIR").ok(); let toolkit_choice = resolve_cuda_toolkit( |key| std::env::var_os(key), @@ -462,7 +462,9 @@ pub fn doctor(ctx: &Context) { ); match ( &toolkit_choice.source, - header_candidates.iter().find(|path| path.is_file()), + header_candidates + .iter() + .find(|path| path.is_file() && path.with_file_name("curand.h").is_file()), ) { (ToolkitSource::Explicit(var), Some(found)) => { println!("✓ {} (via {var})", found.display()); @@ -478,9 +480,10 @@ pub fn doctor(ctx: &Context) { "✗ no usable CUDA toolkit; nearest root probed was `{}`", toolkit_choice.root.display() ); - eprintln!(" Probed for cuda.h:"); + eprintln!(" Probed for cuda.h and curand.h:"); for candidate in &header_candidates { eprintln!(" {}", candidate.display()); + eprintln!(" {}", candidate.with_file_name("curand.h").display()); } // The rejections are the point when more than one toolkit is // installed: a root can be turned down for a missing header or @@ -492,7 +495,7 @@ pub fn doctor(ctx: &Context) { eprintln!(" {reason}"); } } - eprintln!(" Host crates (cuda-bindings) cannot build without cuda.h. Set"); + eprintln!(" Host crates (cuda-bindings) need both cuda.h and curand.h. Set"); eprintln!(" CUDA_TOOLKIT_PATH or CUDA_HOME to a CUDA 13.0+ toolkit install"); eprintln!(" root; when neither is set, these roots are tried in order:"); for candidate in DEFAULT_TOOLKIT_CANDIDATES { @@ -857,7 +860,7 @@ pub(super) struct ToolkitChoice { /// Resolve the CUDA toolkit the way the host `cuda-bindings` build script /// does: each variable in turn, then the fixed candidate list, accepting the -/// first root that carries a `cuda.h` at or above the version floor. +/// first root that carries both required headers and clears the version floor. /// /// Mirrors BY HAND `resolve_cuda_toolkit`, `find_default_cuda_toolkit` and /// `validate_cuda_toolkit` in the shared `cuda-bindings` build script, which @@ -869,8 +872,8 @@ pub(super) struct ToolkitChoice { /// The previous mirror had drifted to "first set variable, else /// `/usr/local/cuda`", which is wrong in three ways that matter on a machine /// with more than one toolkit installed (#1265): there is a candidate list -/// before that last entry, each candidate must actually contain a `cuda.h` -/// new enough to clear the floor, and a variable set to an empty string is an +/// before that last entry, each candidate must contain `cuda.h` and `curand.h` +/// with a CUDA version at or above the floor, and an empty environment variable is an /// error rather than a reason to look elsewhere. Doctor reported a healthy /// toolkit while the build resolved a different one and then failed on a /// header, which is precisely the case doctor exists to explain. @@ -958,7 +961,7 @@ pub(super) fn cuda_toolkit_root(mut get_env: impl FnMut(&str) -> Option) } /// Accept `root` as a toolkit exactly as `validate_cuda_toolkit` does: it -/// must be a directory holding a `cuda.h` in one of the layouts +/// must be a directory holding `cuda.h` and `curand.h` together in one of the layouts /// [`cuda_header_candidates`] lists, and that header's `CUDA_VERSION` must /// clear [`MIN_CUDA_VERSION`]. pub(super) fn validate_cuda_toolkit( @@ -975,8 +978,29 @@ pub(super) fn validate_cuda_toolkit( std::env::consts::ARCH, std::env::consts::OS, ); - let Some(header) = candidates.iter().find(|path| path.is_file()) else { - return Err(format!("{} does not contain cuda.h", root.display())); + let Some(header) = candidates + .iter() + .find(|path| path.is_file() && path.with_file_name("curand.h").is_file()) + else { + let probed = candidates + .iter() + .map(|cuda_h| { + let missing = ["cuda.h", "curand.h"] + .into_iter() + .filter(|header| !cuda_h.with_file_name(header).is_file()) + .collect::>() + .join(", "); + format!( + " {}: missing {missing}", + cuda_h.parent().unwrap().display() + ) + }) + .collect::>() + .join("\n"); + return Err(format!( + "{} has no include directory containing both cuda.h and curand.h. Probed:\n{probed}", + root.display() + )); }; let contents = std::fs::read_to_string(header) .map_err(|error| format!("{} could not be read: {error}", header.display()))?; diff --git a/cuda-oxide/crates/cargo-oxide/src/commands/tests.rs b/cuda-oxide/crates/cargo-oxide/src/commands/tests.rs index 30cb03e83a..171599e7c7 100644 --- a/cuda-oxide/crates/cargo-oxide/src/commands/tests.rs +++ b/cuda-oxide/crates/cargo-oxide/src/commands/tests.rs @@ -3428,6 +3428,7 @@ fn toolkit_resolution_preserves_non_unicode_explicit_paths() { let root = parent.join(std::ffi::OsString::from_vec(b"cuda-\xff".to_vec())); fs::create_dir_all(root.join("include")).unwrap(); fs::write(root.join("include/cuda.h"), "#define CUDA_VERSION 13000\n").unwrap(); + fs::write(root.join("include/curand.h"), "/* cuRAND */\n").unwrap(); let resolve = || { resolve_cuda_toolkit( @@ -3494,6 +3495,7 @@ fn validate_cuda_toolkit_applies_the_build_scripts_acceptance() { let root_str = root.to_str().unwrap().to_string(); fs::write(&cuda_h, "#define CUDA_VERSION 13030\n").unwrap(); + fs::write(root.join("include/curand.h"), "/* cuRAND */\n").unwrap(); assert_eq!(validate_cuda_toolkit(&root_str, None), Ok(13_030)); // A host include directory must never shadow an explicit target tree. @@ -3501,6 +3503,7 @@ fn validate_cuda_toolkit_applies_the_build_scripts_acceptance() { let target = root.join("targets/test-target/include"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("cuda.h"), "#define CUDA_VERSION 13000\n").unwrap(); + fs::write(target.join("curand.h"), "/* cuRAND */\n").unwrap(); assert_eq!( validate_cuda_toolkit(&root_str, Some("test-target")), Ok(13_000) @@ -3517,10 +3520,7 @@ fn validate_cuda_toolkit_applies_the_build_scripts_acceptance() { fs::remove_file(&cuda_h).unwrap(); let headerless = validate_cuda_toolkit(&root_str, None).unwrap_err(); - assert!( - headerless.contains("does not contain cuda.h"), - "{headerless}" - ); + assert!(headerless.contains("missing cuda.h"), "{headerless}"); let absent = validate_cuda_toolkit(format!("{root_str}/nope"), None).unwrap_err(); assert!(absent.contains("is not a directory"), "{absent}"); @@ -3528,6 +3528,56 @@ fn validate_cuda_toolkit_applies_the_build_scripts_acceptance() { fs::remove_dir_all(&root).ok(); } +#[test] +fn discovery_skips_a_toolkit_missing_curand_for_a_complete_toolkit() { + let root = unique_temp_dir("cargo_oxide_toolkit_partial"); + let partial = root.join("partial"); + let complete = root.join("complete"); + for toolkit in [&partial, &complete] { + fs::create_dir_all(toolkit.join("include")).unwrap(); + fs::write( + toolkit.join("include/cuda.h"), + "#define CUDA_VERSION 13030\n", + ) + .unwrap(); + } + fs::write(complete.join("include/curand.h"), "/* cuRAND */\n").unwrap(); + + let choice = resolve_cuda_toolkit( + |_| None, + |candidate| { + let fixture = if candidate == Path::new(DEFAULT_TOOLKIT_CANDIDATES[0]) { + &partial + } else { + &complete + }; + validate_cuda_toolkit(fixture, None) + }, + ); + assert_eq!(choice.source, ToolkitSource::Discovered); + assert_eq!(choice.root, Path::new(DEFAULT_TOOLKIT_CANDIDATES[1])); + assert_eq!(choice.rejected.len(), 1); + assert!(choice.rejected[0].contains("curand.h"), "{choice:?}"); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn explicit_incomplete_toolkit_is_rejected_without_fallback() { + let root = unique_temp_dir("cargo_oxide_toolkit_partial_explicit"); + fs::create_dir_all(root.join("include")).unwrap(); + fs::write(root.join("include/cuda.h"), "#define CUDA_VERSION 13030\n").unwrap(); + + let choice = resolve_cuda_toolkit( + |key| (key == "CUDA_TOOLKIT_PATH").then(|| root.clone().into_os_string()), + |candidate| validate_cuda_toolkit(candidate, None), + ); + assert_eq!(choice.source, ToolkitSource::Unresolved); + assert_eq!(choice.root, root); + assert_eq!(choice.rejected.len(), 1); + assert!(choice.rejected[0].contains("curand.h"), "{choice:?}"); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn cuda_version_from_header_reads_the_define_the_build_script_reads() { assert_eq!(