flodl_cli/libtorch/detect.rs
1//! libtorch installation detection and .arch metadata parsing.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::util::system::GpuInfo;
7use flodl_hw::GpuVendor;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13/// Metadata about an installed libtorch variant (from `.arch` file).
14#[derive(Debug)]
15pub struct LibtorchInfo {
16 /// Relative path from project root (e.g. "precompiled/cu128", "builds/sm61-sm120").
17 pub path: String,
18 pub torch_version: Option<String>,
19 pub cuda_version: Option<String>,
20 pub archs: Option<String>,
21 pub source: Option<String>,
22}
23
24// ---------------------------------------------------------------------------
25// Detection
26// ---------------------------------------------------------------------------
27
28/// Read the active libtorch variant from `<root>/libtorch/.active` and
29/// parse its `.arch` metadata.
30///
31/// On heterogeneous rigs (multiple hosts sharing the same checkout via
32/// NFS / virtiofs / S3-FUSE) a single `.active` file can't represent
33/// "PT 2.10 on the Blackwell host AND PT 2.7 on the Pascal host" at
34/// the same time. The `FDL_LIBTORCH_CASE` env var selects an
35/// alternative pointer file `libtorch/.active.<case>`; the file's
36/// content is read identically to `.active`.
37///
38/// Setting `FDL_LIBTORCH_CASE=<case>` with no corresponding pointer
39/// file is a hard misconfiguration: this function logs the missing
40/// file to stderr and returns `None` (callers surface this as "libtorch
41/// not configured") rather than silently falling back to `.active`,
42/// which would otherwise mask the user's explicit selection.
43pub fn read_active(root: &Path) -> Option<LibtorchInfo> {
44 let lt_dir = root.join("libtorch");
45 let pointer = match std::env::var("FDL_LIBTORCH_CASE") {
46 Ok(case) if !case.trim().is_empty() => {
47 let case = case.trim();
48 let case_file = lt_dir.join(format!(".active.{case}"));
49 if !case_file.exists() {
50 eprintln!(
51 "fdl: FDL_LIBTORCH_CASE={case} but `{}` does not exist. \
52 Create it with `fdl libtorch use <variant> --as {case}` \
53 or hand-write the variant path (e.g. \
54 `precompiled/cu128`).",
55 case_file.display(),
56 );
57 return None;
58 }
59 case_file
60 }
61 _ => lt_dir.join(".active"),
62 };
63 read_active_from(&pointer, <_dir)
64}
65
66/// Read any `.active*` pointer file and resolve its content (a
67/// relative path like `precompiled/cu128` or `builds/sm61-sm120`)
68/// against `libtorch_root` to produce a [`LibtorchInfo`].
69///
70/// Used by [`read_active`] (default `.active`), by callers that have
71/// the pointer file path directly (e.g. cluster.yml's per-host
72/// `arch:` naming a case pointer, resolving to
73/// `…/libtorch/.active.<case>`), and by tests
74/// that need to validate a pointer-file shape without setting the
75/// `FDL_LIBTORCH_CASE` env var.
76pub fn read_active_from(pointer: &Path, libtorch_root: &Path) -> Option<LibtorchInfo> {
77 let active = fs::read_to_string(pointer).ok()?;
78 let path = active.trim().to_string();
79 if path.is_empty() {
80 return None;
81 }
82 let arch_dir = libtorch_root.join(&path);
83 Some(libtorch_info_from_dir(path, &arch_dir))
84}
85
86/// Build a [`LibtorchInfo`] for a variant directory: `path` is the string
87/// recorded in the info (a relative variant path like `precompiled/cu128`
88/// or an absolute directory), `arch_dir` is the directory whose `.arch`
89/// file supplies the metadata. The four metadata fields stay `None` when
90/// the `.arch` file is absent or unreadable. One home for the parse that
91/// `read_active_from`, `run::resolve_libtorch_at`, and probe's
92/// `check_libtorch*` all used to copy inline.
93pub(crate) fn libtorch_info_from_dir(path: String, arch_dir: &Path) -> LibtorchInfo {
94 let mut info = LibtorchInfo {
95 path,
96 torch_version: None,
97 cuda_version: None,
98 archs: None,
99 source: None,
100 };
101 if let Ok(content) = fs::read_to_string(arch_dir.join(".arch")) {
102 parse_arch_into(&content, &mut info);
103 }
104 info
105}
106
107/// Fill a [`LibtorchInfo`]'s metadata fields from `.arch` file content
108/// (`torch=` / `cuda=` / `archs=` / `source=` lines; unknown lines ignored).
109fn parse_arch_into(content: &str, info: &mut LibtorchInfo) {
110 for line in content.lines() {
111 if let Some(val) = line.strip_prefix("torch=") {
112 info.torch_version = Some(val.to_string());
113 } else if let Some(val) = line.strip_prefix("cuda=") {
114 info.cuda_version = Some(val.to_string());
115 } else if let Some(val) = line.strip_prefix("archs=") {
116 info.archs = Some(val.to_string());
117 } else if let Some(val) = line.strip_prefix("source=") {
118 info.source = Some(val.to_string());
119 }
120 }
121}
122
123/// Record per-GPU arch coverage for a resolved variant and push a loud,
124/// actionable issue for every GPU the libtorch build does not cover — or a
125/// single issue when the variant carries no `.arch` metadata. Returns
126/// `(gpu_index, covered)` pairs in GPU order. One home for the coverage
127/// loop probe's three `check_libtorch*` paths used to copy inline (with
128/// drifted wording).
129pub(crate) fn arch_coverage(
130 info: &LibtorchInfo,
131 gpus: &[GpuInfo],
132 issues: &mut Vec<String>,
133) -> Vec<(u8, bool)> {
134 let mut archs_match = Vec::new();
135 if let Some(archs) = &info.archs {
136 for g in gpus {
137 let ok = g.covered_by(archs);
138 archs_match.push((g.index, ok));
139 if !ok {
140 issues.push(format!(
141 "GPU {} ({}, {}) not covered by libtorch archs `{}`. \
142 Rebuild libtorch with this arch or activate a \
143 compatible variant.",
144 g.index,
145 g.short_name(),
146 g.arch_label(),
147 archs
148 ));
149 }
150 }
151 } else {
152 issues.push(
153 "libtorch is present but `.arch` metadata is missing — cannot \
154 verify GPU compatibility. Place an `.arch` file in the variant \
155 directory (cuda=, torch=, archs=, source=)."
156 .into(),
157 );
158 }
159 archs_match
160}
161
162/// Which GPU stack a libtorch variant path targets, from its basename.
163///
164/// `None` means a CPU-only variant. The variant path (`precompiled/cu128`,
165/// `builds/sm61-sm120`, `precompiled/cpu`) is the single source of truth
166/// here -- no `.arch` metadata file is required -- because the cluster
167/// `arch:` field names exactly this path and must resolve without
168/// reading the remote host's filesystem.
169///
170/// | Basename starts with | Target |
171/// |---|---|
172/// | `cpu` | CPU-only |
173/// | `cu<digit>` (`cu128`, `cu126-pt27`) or `sm<digit>` (`sm61-sm120`) | NVIDIA |
174/// | `rocm<digit>` or `gfx<digit>` (`gfx1030-gfx1100`) | AMD |
175///
176/// An unrecognised basename **warns and is treated as NVIDIA**. That
177/// preserves the pre-multi-vendor behaviour exactly, which matters
178/// because a user may well have a hand-named CUDA variant
179/// (`builds/mybuild`) that works today; hard-erroring would break a
180/// running setup for the sake of a naming convention. The warning is
181/// the point: the old code made the same assumption in silence, and an
182/// unrecognised basename on an AMD box would otherwise be cross-built
183/// for NVIDIA without a word.
184pub fn variant_vendor(variant: &str) -> Option<GpuVendor> {
185 // The naming convention has ONE home (flodl-hw, where the join
186 // admission gate also reads it); the warn-and-assume-NVIDIA
187 // fallback is this router's policy, not the convention's.
188 match flodl_hw::classify_variant_label(variant) {
189 flodl_hw::VariantClass::Cpu => None,
190 flodl_hw::VariantClass::Vendor(v) => Some(v),
191 flodl_hw::VariantClass::Unknown => {
192 eprintln!(
193 "fdl: libtorch variant {variant:?} does not match a known naming \
194 convention (cpu / cu<N> / sm<N> / rocm<N> / gfx<N>); assuming it is \
195 an NVIDIA build. Rename it to match, or pass the feature explicitly."
196 );
197 Some(GpuVendor::Nvidia)
198 }
199 }
200}
201
202/// The `export` lines a native-build recipe prints for a variant's
203/// vendor, in order.
204///
205/// **On ROCm the system runtime goes FIRST**, ahead of libtorch's own
206/// `lib/`. Same D1a ordering `Dockerfile.rocm` and the cluster pre-build
207/// carry, and for the same reason: libtorch-rocm bundles the entire
208/// userspace ROCm stack (libamdhip64, libhsa-runtime64, libamd_comgr,
209/// and the kernel-interface-coupled libdrm / libnuma), so with libtorch
210/// first that bundle wins over the host's, and when it disagrees with
211/// the host's amdkfd driver the process segfaults at its FIRST GPU op.
212/// A recipe printed the other way round IS that configuration, handed
213/// to the user to paste.
214///
215/// `$ROCM_PATH` is honored (these recipes run on the LOCAL host, so its
216/// env is the right authority) with `/opt/rocm` as the convention
217/// default. A path that does not exist is skipped by the loader, so the
218/// prefix costs nothing where there is no system ROCm. The cluster
219/// pre-build deliberately does NOT do this: the path it builds names a
220/// REMOTE host, where the controller's `$ROCM_PATH` would be the wrong
221/// machine's answer.
222///
223/// `libtorch_lib` is how the recipe spells the libtorch lib directory:
224/// an absolute path for the standalone installer, `$LIBTORCH_PATH/lib`
225/// where the recipe just exported that variable.
226///
227/// One home on purpose. Three sites print this recipe (`fdl setup`,
228/// `fdl libtorch download`, `fdl libtorch build`) and each grew its own
229/// copy; two of them had the order backwards, which is not a cosmetic
230/// drift but the segfault configuration.
231pub fn ld_library_path_lines(vendor: Option<GpuVendor>, libtorch_lib: &str) -> Vec<String> {
232 let tail = "${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}";
233 match vendor {
234 Some(GpuVendor::Amd) => {
235 // `lib` vs `lib64` is a distro property (RHEL/SUSE use
236 // lib64), and a recipe naming the wrong one is a path the
237 // loader silently skips — the segfault configuration again.
238 // The recipe runs on THIS host, so probe the actual layout
239 // and keep the `$ROCM_PATH` indirection for the root.
240 let libdir = flodl_hw::rocm_runtime_lib_dir()
241 .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
242 .unwrap_or_else(|| "lib".to_string());
243 vec![
244 "export ROCM_PATH=\"${ROCM_PATH:-/opt/rocm}\"".to_string(),
245 format!("export LD_LIBRARY_PATH=\"$ROCM_PATH/{libdir}:{libtorch_lib}{tail}\""),
246 ]
247 }
248 _ => vec![format!("export LD_LIBRARY_PATH=\"{libtorch_lib}{tail}\"")],
249 }
250}
251
252/// This box's active libtorch as `(variant directory, variant label)`,
253/// anchored on `root` (a project root, or the global one).
254///
255/// The directory rather than its `lib/`: a build wants `LIBTORCH_PATH`
256/// (headers included) and a child process wants `lib/`, so one value
257/// serves both and neither caller has to guess which it was handed.
258pub fn active_variant(root: &Path) -> Option<(PathBuf, String)> {
259 let info = read_active(root)?;
260 let dir = root.join("libtorch").join(&info.path);
261 dir.join("lib").is_dir().then_some((dir, info.path))
262}
263
264/// `LD_LIBRARY_PATH` VALUE for running against `libtorch_lib`, in the
265/// order the loader must see it. The sibling of
266/// [`ld_library_path_lines`], which prints the same ordering as a shell
267/// recipe; this one is for setting on a child process.
268///
269/// `rocm_lib` is the system runtime's LIBRARY directory, passed rather
270/// than resolved here because the two callers describe different
271/// filesystems: a locally spawned child gets this box's resolved
272/// directory ([`local_rocm_lib_dir`]), while a path composed for a
273/// REMOTE host must use the convention (`/opt/rocm/lib`) since our own
274/// environment says nothing about theirs.
275///
276/// **On ROCm the system runtime must come FIRST.** libtorch-rocm bundles
277/// the entire userspace ROCm stack, so with libtorch first that bundle
278/// wins over the host's, and when it disagrees with the host's amdkfd
279/// driver the process segfaults at its FIRST GPU OP — a failure that
280/// looks nothing like a library-path problem. A path that does not exist
281/// is skipped by the loader, so prefixing costs nothing on a box without
282/// ROCm.
283pub fn ld_library_path_value(
284 vendor: Option<GpuVendor>,
285 libtorch_lib: &str,
286 rocm_lib: &str,
287) -> String {
288 match vendor {
289 Some(GpuVendor::Amd) => {
290 format!("{}:{libtorch_lib}", rocm_lib.trim_end_matches('/'))
291 }
292 _ => libtorch_lib.to_string(),
293 }
294}
295
296/// The system ROCm runtime's library directory on THIS box, for
297/// [`ld_library_path_value`]'s local callers.
298///
299/// `flodl-hw` resolves it properly (`$ROCM_PATH` / `$HIP_PATH` /
300/// `$HSA_PATH` / `/opt/rocm`, probing `lib` and `lib64` for the actual
301/// runtime): detection and the loader path MUST agree, or a box passes
302/// the GPU gate on the runtime detection found and then segfaults on
303/// the path a weaker resolution composed. Falls back to the
304/// `$ROCM_PATH`-or-convention spelling when no runtime is found — the
305/// loader skips a missing path, so the prefix stays harmless.
306pub fn local_rocm_lib_dir() -> String {
307 match flodl_hw::rocm_runtime_lib_dir() {
308 Some(dir) => dir.display().to_string(),
309 None => format!(
310 "{}/lib",
311 std::env::var("ROCM_PATH")
312 .ok()
313 .filter(|v| !v.trim().is_empty())
314 .unwrap_or_else(|| "/opt/rocm".to_string())
315 .trim_end_matches('/'),
316 ),
317 }
318}
319
320/// The cargo feature a variant needs, or `""` for a CPU-only variant.
321pub fn variant_feature(variant: &str) -> &'static str {
322 match variant_vendor(variant) {
323 None => "",
324 Some(v) => v.cargo_feature(),
325 }
326}
327
328/// List all installed libtorch variants under `<root>/libtorch/`.
329///
330/// Scans `precompiled/` and `builds/` subdirectories.
331pub fn list_variants(root: &Path) -> Vec<String> {
332 let mut variants = Vec::new();
333 let lt_dir = root.join("libtorch");
334
335 for subdir in ["precompiled", "builds"] {
336 let dir = lt_dir.join(subdir);
337 if let Ok(entries) = fs::read_dir(&dir) {
338 for entry in entries.flatten() {
339 if entry.path().join("lib").is_dir()
340 && let Some(name) = entry.file_name().to_str()
341 {
342 variants.push(format!("{}/{}", subdir, name));
343 }
344 }
345 }
346 }
347
348 variants.sort();
349 variants
350}
351
352/// Check whether a libtorch variant directory looks valid (has lib/).
353pub fn is_valid_variant(root: &Path, variant: &str) -> bool {
354 root.join(format!("libtorch/{}/lib", variant)).is_dir()
355}
356
357/// Set the active libtorch variant by writing `<root>/libtorch/.active`.
358pub fn set_active(root: &Path, variant: &str) -> Result<(), String> {
359 let lt_dir = root.join("libtorch");
360 fs::create_dir_all(<_dir).map_err(|e| format!("cannot create libtorch/: {}", e))?;
361 fs::write(lt_dir.join(".active"), format!("{}\n", variant))
362 .map_err(|e| format!("cannot write libtorch/.active: {}", e))
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::util::test_env::env_lock;
369 use std::path::PathBuf;
370 use std::sync::atomic::{AtomicU64, Ordering};
371 use std::time::{SystemTime, UNIX_EPOCH};
372
373 // Per-process counter so concurrent test binaries don't collide
374 // on the unique-suffix algorithm.
375 static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
376
377 /// Hand-rolled scratch dir under the system temp dir + RAII
378 /// cleanup. flodl-cli keeps external deps minimal (the serde
379 /// ecosystem only — no utility crates like `tempfile`) so we
380 /// cannot pull in `tempfile`.
381 struct Scratch(PathBuf);
382 impl Scratch {
383 fn new() -> Self {
384 let nanos = SystemTime::now()
385 .duration_since(UNIX_EPOCH)
386 .map(|d| d.as_nanos())
387 .unwrap_or(0);
388 let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
389 let dir = std::env::temp_dir().join(format!("fdl-libtorch-resolver-{}-{}", nanos, seq));
390 fs::create_dir_all(&dir).expect("create scratch");
391 Self(dir)
392 }
393 fn path(&self) -> &std::path::Path {
394 &self.0
395 }
396 }
397 impl Drop for Scratch {
398 fn drop(&mut self) {
399 let _ = fs::remove_dir_all(&self.0);
400 }
401 }
402
403 /// Build a fake project root with two synthetic libtorch variants
404 /// (`precompiled/v1` and `builds/v2`), each with a `lib/` dir and
405 /// `.arch` metadata. The names are deliberately abstract — the
406 /// resolver doesn't care about variant naming, just the
407 /// `<kind>/<name>` shape it reads from the pointer file.
408 fn make_root() -> Scratch {
409 let s = Scratch::new();
410 let v1 = s.path().join("libtorch/precompiled/v1");
411 fs::create_dir_all(v1.join("lib")).unwrap();
412 fs::write(
413 v1.join(".arch"),
414 "torch=1.0\ncuda=1.0\narchs=0.0\nsource=precompiled\n",
415 )
416 .unwrap();
417 let v2 = s.path().join("libtorch/builds/v2");
418 fs::create_dir_all(v2.join("lib")).unwrap();
419 fs::write(
420 v2.join(".arch"),
421 "torch=2.0\ncuda=2.0\narchs=1.0\nsource=build\n",
422 )
423 .unwrap();
424 s
425 }
426
427 #[test]
428 fn variant_vendor_reads_the_naming_convention() {
429 for (path, want) in [
430 ("precompiled/cpu", None),
431 ("precompiled/cu128", Some(GpuVendor::Nvidia)),
432 ("precompiled/cu126-pt27", Some(GpuVendor::Nvidia)),
433 ("builds/sm61-sm120", Some(GpuVendor::Nvidia)),
434 ("builds/sm80", Some(GpuVendor::Nvidia)),
435 ("precompiled/rocm63", Some(GpuVendor::Amd)),
436 ("builds/gfx1030-gfx1100", Some(GpuVendor::Amd)),
437 ("builds/gfx942", Some(GpuVendor::Amd)),
438 ] {
439 assert_eq!(variant_vendor(path), want, "{path}");
440 }
441 }
442
443 #[test]
444 fn variant_vendor_requires_a_digit_after_the_prefix() {
445 // `cpu` must not read as a `cu`-something, and a bare `gfx`
446 // directory is not an arch.
447 assert_eq!(variant_vendor("precompiled/cpu"), None);
448 assert_eq!(variant_vendor("x/cpu-static"), None);
449 // Unrecognised names warn and fall back to NVIDIA rather than
450 // breaking a hand-named CUDA build that works today.
451 assert_eq!(variant_vendor("builds/mybuild"), Some(GpuVendor::Nvidia));
452 assert_eq!(variant_vendor("builds/gfx"), Some(GpuVendor::Nvidia));
453 }
454
455 #[test]
456 fn ld_recipe_puts_system_rocm_before_libtorch() {
457 // D1a. The ORDER is the whole point: the other way round is the
458 // configuration that segfaults at the first GPU op, and these
459 // lines are pasted verbatim by whoever ran the command.
460 for lib in ["$LIBTORCH_PATH/lib", "/opt/lt/rocm70/lib"] {
461 let lines = ld_library_path_lines(Some(GpuVendor::Amd), lib);
462 let ld = lines
463 .iter()
464 .find(|l| l.contains("LD_LIBRARY_PATH="))
465 .expect("recipe must set LD_LIBRARY_PATH");
466 let rocm = ld
467 .find("$ROCM_PATH/lib")
468 .expect("system ROCm must be on the path");
469 let libtorch = ld.find(lib).expect("libtorch must be on the path");
470 assert!(rocm < libtorch, "system ROCm must come first, got {ld}");
471 assert!(
472 lines.iter().any(|l| l.contains("ROCM_PATH:-/opt/rocm")),
473 "an unset ROCM_PATH must fall back to the convention: {lines:?}"
474 );
475 }
476 }
477
478 #[test]
479 fn ld_recipe_is_libtorch_only_for_nvidia_and_cpu() {
480 for vendor in [Some(GpuVendor::Nvidia), None] {
481 let lines = ld_library_path_lines(vendor, "$LIBTORCH_PATH/lib");
482 assert_eq!(lines.len(), 1, "{vendor:?}");
483 assert!(!lines[0].contains("rocm"), "{vendor:?}: {}", lines[0]);
484 assert!(lines[0].contains("$LIBTORCH_PATH/lib"), "{}", lines[0]);
485 }
486 }
487
488 #[test]
489 fn ld_recipe_preserves_an_existing_ld_library_path() {
490 // The `:+` guard keeps a user's existing value and avoids the
491 // trailing colon that would otherwise put CWD on the loader path.
492 for vendor in [Some(GpuVendor::Amd), Some(GpuVendor::Nvidia), None] {
493 let lines = ld_library_path_lines(vendor, "/opt/lt/lib");
494 let ld = lines
495 .iter()
496 .find(|l| l.contains("LD_LIBRARY_PATH="))
497 .unwrap();
498 assert!(
499 ld.contains("${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"),
500 "{vendor:?}: {ld}"
501 );
502 }
503 }
504
505 #[test]
506 fn variant_feature_maps_to_the_cargo_feature() {
507 assert_eq!(variant_feature("precompiled/cpu"), "");
508 assert_eq!(variant_feature("precompiled/cu128"), "cuda");
509 assert_eq!(variant_feature("builds/gfx1030"), "rocm");
510 }
511
512 #[test]
513 fn read_active_default_pointer() {
514 let _guard = env_lock();
515 // SAFETY: serialized via env_lock().
516 unsafe {
517 std::env::remove_var("FDL_LIBTORCH_CASE");
518 }
519 let root = make_root();
520 fs::write(root.path().join("libtorch/.active"), "precompiled/v1\n").unwrap();
521 let info = read_active(root.path()).expect("read_active");
522 assert_eq!(info.path, "precompiled/v1");
523 assert_eq!(info.torch_version.as_deref(), Some("1.0"));
524 }
525
526 #[test]
527 fn fdl_libtorch_case_selects_alternate_pointer() {
528 let _guard = env_lock();
529 let root = make_root();
530 fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
531 fs::write(root.path().join("libtorch/.active.alt"), "precompiled/v1\n").unwrap();
532 // SAFETY: serialized via env_lock().
533 unsafe {
534 std::env::set_var("FDL_LIBTORCH_CASE", "alt");
535 }
536 let info = read_active(root.path()).expect("read_active");
537 // SAFETY: serialized via env_lock().
538 unsafe {
539 std::env::remove_var("FDL_LIBTORCH_CASE");
540 }
541 assert_eq!(info.path, "precompiled/v1");
542 assert_eq!(info.torch_version.as_deref(), Some("1.0"));
543 }
544
545 #[test]
546 fn fdl_libtorch_case_missing_file_returns_none_loudly() {
547 let _guard = env_lock();
548 let root = make_root();
549 fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
550 // No `.active.nonexistent` file.
551 // SAFETY: serialized via env_lock().
552 unsafe {
553 std::env::set_var("FDL_LIBTORCH_CASE", "nonexistent");
554 }
555 let info = read_active(root.path());
556 // SAFETY: serialized via env_lock().
557 unsafe {
558 std::env::remove_var("FDL_LIBTORCH_CASE");
559 }
560 assert!(
561 info.is_none(),
562 "explicit case with missing file must not silently fall back to .active"
563 );
564 }
565
566 #[test]
567 fn read_active_from_resolves_pointer_directly() {
568 let _guard = env_lock();
569 let root = make_root();
570 let pointer = root.path().join("libtorch/.active.alt");
571 fs::write(&pointer, "builds/v2\n").unwrap();
572 let info =
573 read_active_from(&pointer, &root.path().join("libtorch")).expect("read_active_from");
574 assert_eq!(info.path, "builds/v2");
575 assert_eq!(info.archs.as_deref(), Some("1.0"));
576 }
577}
578
579/// Unmet dynamic-linker requirements of a libtorch variant on THIS host,
580/// as the loader itself reports them.
581///
582/// A libtorch archive is built against some baseline C library, and the
583/// baseline is not the same across variants: measured on 2.10.0, the cpu
584/// and cu128 trees need `GLIBC_2.29` / `GLIBCXX_3.4.26` while the rocm7.0
585/// tree needs `GLIBC_2.35` / `GLIBCXX_3.4.30`. RHEL 9 ships glibc 2.34
586/// and cannot be upgraded past it, so that last combination cannot run
587/// there at all — and without this check the operator finds out after a
588/// download, a compile and a link, from a loader error naming symbol
589/// versions rather than the actual problem.
590///
591/// Asks `ldd`, so it answers by the same rules the real load obeys
592/// instead of a table of baselines that would rot at the next release.
593/// An empty vector means "nothing unmet", which is also what a missing
594/// `ldd` returns: this reports a problem it can prove, never a doubt.
595pub fn unmet_loader_requirements(variant_dir: &Path) -> Vec<String> {
596 let core = variant_dir.join("lib/libtorch_cpu.so");
597 if !core.is_file() {
598 return Vec::new();
599 }
600 let Ok(out) = std::process::Command::new("ldd").arg(&core).output() else {
601 return Vec::new();
602 };
603 let text =
604 String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
605 parse_unmet_versions(&text)
606}
607
608/// The symbol versions an `ldd` run reported as missing, de-duplicated
609/// in first-seen order. Pure so the parse is testable against real
610/// loader output rather than only on a host that happens to fail.
611pub(crate) fn parse_unmet_versions(ldd_output: &str) -> Vec<String> {
612 let mut seen: Vec<String> = Vec::new();
613 for line in ldd_output.lines() {
614 // `... version `GLIBC_2.35' not found (required by ...)`
615 if !line.contains("not found") {
616 continue;
617 }
618 let Some(rest) = line.split("version `").nth(1) else {
619 continue;
620 };
621 let Some(sym) = rest.split('\'').next() else {
622 continue;
623 };
624 if !seen.iter().any(|s| s == sym) {
625 seen.push(sym.to_string());
626 }
627 }
628 seen
629}
630
631#[cfg(test)]
632mod loader_tests {
633 use super::parse_unmet_versions;
634
635 /// Real `ldd` output, captured 2026-08-07 from the rocm7.0 variant
636 /// on rockylinux:9 — the pair CI hit.
637 #[test]
638 fn it_reads_the_versions_the_loader_could_not_satisfy() {
639 let real = "\
640/lt/libtorch_cpu.so: /lib64/libm.so.6: version `GLIBC_2.35' not found (required by /lt/libtorch_cpu.so)
641/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libtorch_cpu.so)
642/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libc10.so)
643\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
644\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
645";
646 assert_eq!(
647 parse_unmet_versions(real),
648 vec!["GLIBC_2.35".to_string(), "GLIBCXX_3.4.30".to_string()],
649 "de-duplicated, in first-seen order",
650 );
651 }
652
653 /// A host that CAN load it says nothing, and neither do we: this
654 /// reports a problem it can prove, never a doubt.
655 #[test]
656 fn a_satisfied_load_reports_nothing() {
657 let ok = "\
658\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
659\tlibtorch_cpu.so => /lt/libtorch_cpu.so (0x00007f0e88000000)
660\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
661";
662 assert!(parse_unmet_versions(ok).is_empty());
663 assert!(parse_unmet_versions("").is_empty());
664 }
665}