Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions cuda-bindings/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ fn run() -> Result<(), Box<dyn Error>> {
}

/// 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/<dir>/include` for redistributable and Tegra/sbsa layouts
/// that ship no top-level `include/`).
struct ResolvedToolkit {
Expand Down Expand Up @@ -242,10 +242,10 @@ fn default_cuda_toolkit_candidates() -> &'static [PathBuf] {
}

/// Validates a toolkit root: probes the standard `include/` and the
/// `targets/<dir>/include/` layouts for `cuda.h` (only the directories
/// `targets/<dir>/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()));
Expand All @@ -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<String> = 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::<Vec<_>>()
.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")
));
Expand Down
69 changes: 67 additions & 2 deletions cuda-bindings/tests/toolkit_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]);
Expand Down
14 changes: 10 additions & 4 deletions cuda-bindings/toolkit_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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())
})
}
48 changes: 36 additions & 12 deletions cuda-oxide/crates/cargo-oxide/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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),
Expand All @@ -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());
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -958,7 +961,7 @@ pub(super) fn cuda_toolkit_root(mut get_env: impl FnMut(&str) -> Option<String>)
}

/// 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(
Expand All @@ -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::<Vec<_>>()
.join(", ");
format!(
" {}: missing {missing}",
cuda_h.parent().unwrap().display()
)
})
.collect::<Vec<_>>()
.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()))?;
Expand Down
58 changes: 54 additions & 4 deletions cuda-oxide/crates/cargo-oxide/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -3494,13 +3495,15 @@ 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.
assert!(validate_cuda_toolkit(&root_str, Some("missing-target")).is_err());
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)
Expand All @@ -3517,17 +3520,64 @@ 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}");

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!(
Expand Down