flodl_cli/util/requirements.rs
1//! What a host needs before floDl will build, and the command that
2//! supplies it.
3//!
4//! Each requirement is also checked at its point of use (`util/http.rs`
5//! for curl, `util/archive.rs` for unzip, `flodl-sys/build.rs` for the
6//! vendor headers). Those checks report one missing item at a time, at
7//! the moment it is needed. This module exists so `fdl probe` and
8//! `fdl setup` can report the whole set up front instead; the per-site
9//! checks remain as the backstop when neither was run.
10//!
11//! The set is not absolute. A Docker build needs no C++ compiler and no
12//! vendor headers, since both live in the image, so callers request the
13//! set matching the build path in use.
14
15use std::path::Path;
16
17use crate::util::system;
18
19/// Host tools `fdl` itself shells out to: (probe name, Debian package).
20///
21/// `curl` is special-cased by the caller: `util/http.rs` accepts wget
22/// as well, so either satisfies the requirement.
23const HOST_TOOLS: &[(&str, &str)] = &[("curl", "curl"), ("unzip", "unzip"), ("c++", "g++")];
24
25/// Vendor toolkit headers, as (header relative to an include dir,
26/// package that owns it).
27///
28/// The set covers the shim's whole include chain, not the headers it
29/// names directly: torch's vendor trees pull in more (`cuda_runtime.h`
30/// includes `crt/host_config.h`, which a different package owns;
31/// `ATen/hip` reaches hipsparse and hipblas). Checking only the direct
32/// includes therefore passes while the compile still fails.
33///
34/// Regenerate after a libtorch bump by asking the compiler for the real
35/// dependency set:
36///
37/// ```text
38/// c++ -std=c++17 -M -I . -I <libtorch>/include \
39/// -I <libtorch>/include/torch/csrc/api/include -I <toolkit>/include \
40/// <the -D flags build.rs sets> shim.cpp
41/// ```
42///
43/// Use `-M`, not `-MM`: `-MM` omits system headers, which drops
44/// `nccl.h` (it lives in `/usr/include`, not under `$CUDA_HOME`).
45/// Grepping the vendor tree instead over-reports, listing headers the
46/// chain never reaches.
47///
48/// Map a header to its package inside the vendor dev image. `dpkg -S`
49/// needs a `readlink -f`'d path under ROCm, since `/opt/rocm` is a
50/// versioned symlink; the CUDA image needs the forward lookup
51/// (`dpkg -L` per candidate package) instead.
52///
53/// ROCm has no metapackage covering these: `rocm-dev` supplies only
54/// `hip-dev`.
55pub const ROCM_HEADERS: &[(&str, &str)] = &[
56 ("hip/hip_runtime.h", "hip-dev"),
57 ("rccl/rccl.h", "rccl-dev"),
58 ("hipblas/hipblas.h", "hipblas-dev"),
59 ("hipblas-common/hipblas-common.h", "hipblas-common-dev"),
60 ("hipblaslt/hipblaslt.h", "hipblaslt-dev"),
61 ("hipsolver/hipsolver.h", "hipsolver-dev"),
62 ("hipsparse/hipsparse.h", "hipsparse-dev"),
63];
64
65/// CUDA equivalent. The version placeholders are deliberate: the exact
66/// package name carries the toolkit version and we do not know which
67/// one the user wants.
68pub const CUDA_HEADERS: &[(&str, &str)] = &[
69 ("cuda_runtime.h", "cuda-cudart-dev-<M>-<m>"),
70 ("crt/host_config.h", "cuda-crt-<M>-<m>"),
71 ("cublas_v2.h", "libcublas-dev-<M>-<m>"),
72 ("cusolverDn.h", "libcusolver-dev-<M>-<m>"),
73 ("cusparse.h", "libcusparse-dev-<M>-<m>"),
74 ("nccl.h", "libnccl-dev"),
75];
76
77/// Host tools that are absent, as Debian package names.
78pub fn missing_host_tools() -> Vec<&'static str> {
79 HOST_TOOLS
80 .iter()
81 .filter(|(probe, _)| {
82 if *probe == "curl" {
83 // Either satisfies the download requirement.
84 return !system::has_command("curl") && !system::has_command("wget");
85 }
86 !system::has_command(probe)
87 })
88 .map(|(_, pkg)| *pkg)
89 .collect()
90}
91
92/// Standard include directories the compiler searches by default.
93///
94/// Required, not defensive: some vendor headers install outside the
95/// toolkit root. `nccl.h` ships in `libnccl-dev` at `/usr/include`, so
96/// a toolkit-root-only check reports it missing on hosts where the
97/// build succeeds.
98const SYSTEM_INCLUDE_DIRS: &[&str] = &["/usr/include", "/usr/local/include"];
99
100/// Vendor headers that are absent, as (header, package) pairs.
101///
102/// A header counts as present if it is under `<root>/include` OR any
103/// default system include dir, because that is what the compiler will
104/// do. Header paths in the tables are relative to an include dir, with
105/// no `include/` prefix, precisely so both can be searched.
106///
107/// Pure: the toolkit root is a parameter rather than an env read, so
108/// every arm is testable without mutating process-global state. This
109/// crate's test binary runs in parallel and an env-mutating test only
110/// works if every reader takes the same lock, which they do not.
111pub fn missing_headers<'a>(
112 root: &Path,
113 headers: &'a [(&'a str, &'a str)],
114) -> Vec<&'a (&'a str, &'a str)> {
115 let root_include = root.join("include");
116 headers
117 .iter()
118 .filter(|(h, _)| {
119 if root_include.join(h).exists() {
120 return false;
121 }
122 if SYSTEM_INCLUDE_DIRS
123 .iter()
124 .any(|d| Path::new(d).join(h).exists())
125 {
126 return false;
127 }
128 // The path scan came up empty, which is exactly when its
129 // three-directory view is worth doubting. Ask the compiler
130 // that will do the build, with the same include dir it will
131 // get, before reporting a gap. Unreachable OR unanswerable
132 // (no compiler) both leave it reported.
133 !matches!(header_reachable(h, &[root_include.as_path()]), Some(true))
134 })
135 .collect()
136}
137
138/// Whether the C++ compiler can actually resolve `#include <header>`.
139///
140/// The path scan above knows three directories. The compiler knows every
141/// rule the real build obeys — its own defaults, `CPATH`, multiarch
142/// directories, spec files, whatever a distro did — so it is the second
143/// opinion worth having before telling someone to install something they
144/// already have. Pascal is the case in point: its CUDA headers live in
145/// `/usr/include` with no `/usr/local/cuda` at all, and it compiles
146/// `flodl-sys --features cuda` in 12s.
147///
148/// `None` when there is no compiler to ask, which is not the same answer
149/// as "missing" and must not be collapsed into one: a box without a C++
150/// compiler has a different problem, and [`missing_host_tools`] reports
151/// it.
152///
153/// Cost is one preprocessor invocation, ~30ms, and it is paid only for a
154/// header the path scan already failed to find — the happy path spawns
155/// nothing.
156pub fn header_reachable(header: &str, include_dirs: &[&Path]) -> Option<bool> {
157 use std::io::Write;
158 use std::process::{Command, Stdio};
159
160 let cxx = std::env::var("CXX").unwrap_or_else(|_| "c++".to_string());
161 if !system::has_command(&cxx) {
162 return None;
163 }
164 let mut cmd = Command::new(&cxx);
165 for dir in include_dirs {
166 cmd.arg("-I").arg(dir);
167 }
168 // Preprocess only: resolving the include is the whole question, and
169 // -fsyntax-only would drag in a parse we do not need. The output goes
170 // nowhere via `Stdio::null()` rather than `-o /dev/null`, which is not
171 // a path on Windows: gcc there tries to create a `\dev\` directory,
172 // fails, and the non-zero exit reads as "header missing" for every
173 // header on the box.
174 cmd.args(["-E", "-x", "c++", "-"])
175 .stdin(Stdio::piped())
176 .stdout(Stdio::null())
177 .stderr(Stdio::null());
178 let mut child = cmd.spawn().ok()?;
179 child
180 .stdin
181 .as_mut()?
182 .write_all(format!("#include <{header}>\n").as_bytes())
183 .ok()?;
184 Some(child.wait().ok()?.success())
185}
186
187/// De-duplicated package list for a set of missing headers, in table
188/// order.
189///
190/// `Vec::dedup` is not enough: it only collapses *adjacent* duplicates,
191/// so two headers owned by one package would list it twice unless they
192/// happened to sit next to each other in the table.
193pub fn packages_for(missing: &[&(&str, &str)]) -> Vec<String> {
194 let mut seen = std::collections::HashSet::new();
195 missing
196 .iter()
197 .filter(|(_, p)| seen.insert(*p))
198 .map(|(_, p)| (*p).to_string())
199 .collect()
200}
201
202/// A vendor toolkit gap on THIS box: the headers a `--features
203/// <vendor>` compile will not find, and the line that installs them.
204#[derive(Debug)]
205pub struct ToolkitGap {
206 /// Toolkit root the check ran against.
207 pub root: std::path::PathBuf,
208 /// Missing headers, as printed to the operator.
209 pub headers: Vec<String>,
210 /// The full install line ([`install_hint`] over the owning
211 /// packages; the NVIDIA arm uses the `cuda-toolkit` metapackage
212 /// since the per-header names carry version placeholders).
213 pub install: String,
214}
215
216/// Resolve the toolkit gap for a vendor, `None` when the headers are
217/// all present — or when the vendor has no known toolkit layout, since
218/// guessing one produces a confidently wrong apt command.
219///
220/// The ROCm root comes from `flodl-hw`'s resolution (env chain +
221/// convention, runtime-verified) so this check and the loader path
222/// cannot disagree about where ROCm lives.
223pub fn toolkit_gap(vendor: flodl_hw::GpuVendor) -> Option<ToolkitGap> {
224 use std::path::PathBuf;
225 let (root, headers, metapackages): (PathBuf, _, Option<&[&str]>) = match vendor {
226 flodl_hw::GpuVendor::Amd => (
227 flodl_hw::rocm_runtime_root()
228 .or_else(|| std::env::var("ROCM_PATH").ok().map(PathBuf::from))
229 .unwrap_or_else(|| PathBuf::from("/opt/rocm")),
230 ROCM_HEADERS,
231 None,
232 ),
233 flodl_hw::GpuVendor::Nvidia => (
234 PathBuf::from(
235 std::env::var("CUDA_HOME").unwrap_or_else(|_| "/usr/local/cuda".to_string()),
236 ),
237 CUDA_HEADERS,
238 Some(&["cuda-toolkit", "libnccl-dev"]),
239 ),
240 _ => return None,
241 };
242 let missing = missing_headers(&root, headers);
243 if missing.is_empty() {
244 return None;
245 }
246 let packages: Vec<String> = match metapackages {
247 Some(m) => m.iter().map(|p| p.to_string()).collect(),
248 None => packages_for(&missing),
249 };
250 Some(ToolkitGap {
251 root,
252 headers: missing.iter().map(|(h, _)| h.to_string()).collect(),
253 install: install_hint(&packages),
254 })
255}
256
257/// Debian package name in the RHEL-family spelling.
258///
259/// Both vendors ship the same packages to their Debian and RHEL repos
260/// with identical stems and two dev-suffix conventions, so this is a
261/// transform rather than a second table -- every result was verified by
262/// repoquery against the cuda-rhel9 and rocm rhel9 repositories.
263/// `g++` is the one host tool whose rpm goes by a different name.
264/// Kept in sync by hand with `flodl-sys/build.rs`'s `rpm` closure.
265pub fn rpm_name(deb: &str) -> String {
266 if deb == "g++" {
267 return "gcc-c++".to_string();
268 }
269 match deb.strip_suffix("-dev") {
270 Some(stem) => format!("{stem}-devel"),
271 None => deb.replace("-dev-", "-devel-"),
272 }
273}
274
275/// The install line for a package list, contextual to the platform.
276///
277/// Debian and RHEL-family are spelled out because those are the
278/// platforms cloud hosts use (package names verified on both); the
279/// others get a direction rather than a fabricated command, which is
280/// the honest thing when the names are not verified.
281pub fn install_hint(packages: &[String]) -> String {
282 if packages.is_empty() {
283 return String::new();
284 }
285 if cfg!(target_os = "macos") {
286 format!(
287 "brew install {} (names may differ on macOS)",
288 packages.join(" ")
289 )
290 } else if cfg!(target_os = "windows") {
291 "no native Windows build is supported; use WSL2 \
292 (https://flodl.dev/guide/windows-wsl)"
293 .to_string()
294 } else if crate::util::platform::Platform::detect() == crate::util::platform::Platform::Rhel {
295 let list = packages.iter().map(|p| rpm_name(p)).collect::<Vec<_>>();
296 format!(
297 "sudo dnf install {} (or your distribution's equivalent)",
298 list.join(" ")
299 )
300 } else {
301 format!(
302 "sudo apt install {} (or your distribution's equivalent)",
303 packages.join(" ")
304 )
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use std::path::PathBuf;
312
313 /// A throwaway `<tmp>/include/` tree. Built here rather than
314 /// pointed at a real toolkit: `libtorch/` is a gitignored download,
315 /// so a test that depends on one passes locally and fails in CI.
316 fn scratch_root(tag: &str) -> PathBuf {
317 let root = std::env::temp_dir().join(format!("flodl-req-{tag}-{}", std::process::id()));
318 std::fs::create_dir_all(root.join("include/sub")).unwrap();
319 std::fs::write(root.join("include/present.h"), "").unwrap();
320 std::fs::write(root.join("include/sub/nested.h"), "").unwrap();
321 root
322 }
323
324 #[test]
325 fn missing_headers_lists_only_what_is_absent() {
326 // One entry really exists, so the negative half is proven rather
327 // than vacuously true: a check against a path that can never
328 // exist is green for the wrong reason.
329 let root = scratch_root("absent");
330 let table: &[(&str, &str)] = &[
331 ("present.h", "present-pkg"),
332 ("sub/nested.h", "nested-pkg"),
333 ("nope.h", "absent-pkg"),
334 ];
335 let missing = missing_headers(&root, table);
336 assert_eq!(missing.len(), 1, "{missing:?}");
337 assert_eq!(missing[0].1, "absent-pkg");
338 let _ = std::fs::remove_dir_all(&root);
339 }
340
341 #[test]
342 fn a_system_header_counts_as_present() {
343 // A toolkit-root-only check reports headers that install outside
344 // it as missing, failing a build that in fact compiles: `nccl.h`
345 // ships at /usr/include/nccl.h, not under $CUDA_HOME.
346 let root = scratch_root("sys");
347 let sys_header = SYSTEM_INCLUDE_DIRS
348 .iter()
349 .map(|d| Path::new(d).join("stdio.h"))
350 .find(|p| p.exists());
351 if let Some(h) = sys_header {
352 let name = h.file_name().unwrap().to_str().unwrap();
353 let table: &[(&str, &str)] = &[("stdio.h", "libc6-dev")];
354 assert!(
355 missing_headers(&root, table).is_empty(),
356 "{name} is in a default include dir and must not be reported missing"
357 );
358 }
359 let _ = std::fs::remove_dir_all(&root);
360 }
361
362 #[test]
363 fn packages_are_deduplicated() {
364 // The duplicates are deliberately non-adjacent: adjacent ones
365 // pass under a plain `Vec::dedup`, which does not dedup a table
366 // where one package owns two headers listed apart.
367 let a = ("h1", "pkg");
368 let b = ("h2", "other");
369 let c = ("h3", "pkg");
370 let missing = vec![&a, &b, &c];
371 assert_eq!(packages_for(&missing), vec!["pkg", "other"]);
372 }
373
374 #[test]
375 fn every_header_names_a_package() {
376 // A pair that loses its package would tell the user to install "".
377 assert_eq!(ROCM_HEADERS.len(), 7);
378 for (h, p) in ROCM_HEADERS {
379 assert!(!h.is_empty() && !p.is_empty(), "{h} -> {p}");
380 }
381 for (h, p) in CUDA_HEADERS {
382 assert!(!h.is_empty() && !p.is_empty(), "{h} -> {p}");
383 }
384 }
385
386 #[test]
387 fn install_hint_is_empty_when_nothing_is_missing() {
388 assert!(install_hint(&[]).is_empty());
389 }
390
391 #[test]
392 fn install_hint_names_the_packages() {
393 let h = install_hint(&["curl".into(), "g++".into()]);
394 if cfg!(target_os = "windows") {
395 assert!(h.contains("WSL2"), "{h}");
396 } else {
397 // The compiler package is spelled per family (g++ on
398 // Debian, gcc-c++ on RHEL), so assert either.
399 assert!(h.contains("curl"), "{h}");
400 assert!(h.contains("g++") || h.contains("gcc-c++"), "{h}");
401 }
402 }
403
404 #[test]
405 fn the_compiler_answers_for_headers_the_path_scan_cannot_see() {
406 // A header every C++ toolchain resolves, in no directory this
407 // module lists: only the compiler's own view finds it.
408 match header_reachable("cstdio", &[]) {
409 Some(true) => {}
410 Some(false) => panic!("the compiler could not resolve <cstdio>"),
411 // No compiler here: unanswerable is a distinct third state
412 // and must not be read as present.
413 None => {}
414 }
415 // And it says no to something that does not exist, rather than
416 // waving everything through.
417 if header_reachable("cstdio", &[]) == Some(true) {
418 assert_eq!(
419 header_reachable("flodl_no_such_header_42.h", &[]),
420 Some(false),
421 );
422 }
423 }
424
425 #[test]
426 fn a_header_outside_the_toolkit_root_is_not_reported_missing() {
427 // The pascal shape: nothing under the toolkit root, but the
428 // compiler resolves the header anyway (there, CUDA lives in
429 // /usr/include). Reporting that as a gap tells the operator to
430 // install what they already have.
431 let root = scratch_root("reach");
432 let table: &[(&str, &str)] = &[("cstdio", "libstdc++-dev")];
433 let missing = missing_headers(&root, table);
434 if header_reachable("cstdio", &[]) == Some(true) {
435 assert!(
436 missing.is_empty(),
437 "compiler-visible header reported missing"
438 );
439 }
440 let _ = std::fs::remove_dir_all(&root);
441 }
442
443 #[test]
444 fn rpm_names_match_the_verified_rhel_spellings() {
445 // Every pair was checked by repoquery against the vendors'
446 // rhel9 repositories; unversioned names pass through untouched.
447 for (deb, rpm) in [
448 ("hip-dev", "hip-devel"),
449 ("rccl-dev", "rccl-devel"),
450 ("hipblas-common-dev", "hipblas-common-devel"),
451 ("hipblaslt-dev", "hipblaslt-devel"),
452 ("cuda-cudart-dev-<M>-<m>", "cuda-cudart-devel-<M>-<m>"),
453 ("libcublas-dev-<M>-<m>", "libcublas-devel-<M>-<m>"),
454 ("libnccl-dev", "libnccl-devel"),
455 ("cuda-crt-<M>-<m>", "cuda-crt-<M>-<m>"),
456 ("cuda-toolkit", "cuda-toolkit"),
457 ("g++", "gcc-c++"),
458 ("curl", "curl"),
459 ("unzip", "unzip"),
460 ] {
461 assert_eq!(rpm_name(deb), rpm, "{deb}");
462 }
463 }
464}