Skip to main content

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}