use std::fs;
use std::path::{Path, PathBuf};
use crate::util::system::GpuInfo;
use flodl_hw::GpuVendor;
#[derive(Debug)]
pub struct LibtorchInfo {
pub path: String,
pub torch_version: Option<String>,
pub cuda_version: Option<String>,
pub archs: Option<String>,
pub source: Option<String>,
}
pub fn read_active(root: &Path) -> Option<LibtorchInfo> {
let lt_dir = root.join("libtorch");
let pointer = match std::env::var("FDL_LIBTORCH_CASE") {
Ok(case) if !case.trim().is_empty() => {
let case = case.trim();
let case_file = lt_dir.join(format!(".active.{case}"));
if !case_file.exists() {
eprintln!(
"fdl: FDL_LIBTORCH_CASE={case} but `{}` does not exist. \
Create it with `fdl libtorch use <variant> --as {case}` \
or hand-write the variant path (e.g. \
`precompiled/cu128`).",
case_file.display(),
);
return None;
}
case_file
}
_ => lt_dir.join(".active"),
};
read_active_from(&pointer, <_dir)
}
pub fn read_active_from(pointer: &Path, libtorch_root: &Path) -> Option<LibtorchInfo> {
let active = fs::read_to_string(pointer).ok()?;
let path = active.trim().to_string();
if path.is_empty() {
return None;
}
let arch_dir = libtorch_root.join(&path);
Some(libtorch_info_from_dir(path, &arch_dir))
}
pub(crate) fn libtorch_info_from_dir(path: String, arch_dir: &Path) -> LibtorchInfo {
let mut info = LibtorchInfo {
path,
torch_version: None,
cuda_version: None,
archs: None,
source: None,
};
if let Ok(content) = fs::read_to_string(arch_dir.join(".arch")) {
parse_arch_into(&content, &mut info);
}
info
}
fn parse_arch_into(content: &str, info: &mut LibtorchInfo) {
for line in content.lines() {
if let Some(val) = line.strip_prefix("torch=") {
info.torch_version = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("cuda=") {
info.cuda_version = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("archs=") {
info.archs = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("source=") {
info.source = Some(val.to_string());
}
}
}
pub(crate) fn arch_coverage(
info: &LibtorchInfo,
gpus: &[GpuInfo],
issues: &mut Vec<String>,
) -> Vec<(u8, bool)> {
let mut archs_match = Vec::new();
if let Some(archs) = &info.archs {
for g in gpus {
let ok = g.covered_by(archs);
archs_match.push((g.index, ok));
if !ok {
issues.push(format!(
"GPU {} ({}, {}) not covered by libtorch archs `{}`. \
Rebuild libtorch with this arch or activate a \
compatible variant.",
g.index,
g.short_name(),
g.arch_label(),
archs
));
}
}
} else {
issues.push(
"libtorch is present but `.arch` metadata is missing — cannot \
verify GPU compatibility. Place an `.arch` file in the variant \
directory (cuda=, torch=, archs=, source=)."
.into(),
);
}
archs_match
}
pub fn variant_vendor(variant: &str) -> Option<GpuVendor> {
match flodl_hw::classify_variant_label(variant) {
flodl_hw::VariantClass::Cpu => None,
flodl_hw::VariantClass::Vendor(v) => Some(v),
flodl_hw::VariantClass::Unknown => {
eprintln!(
"fdl: libtorch variant {variant:?} does not match a known naming \
convention (cpu / cu<N> / sm<N> / rocm<N> / gfx<N>); assuming it is \
an NVIDIA build. Rename it to match, or pass the feature explicitly."
);
Some(GpuVendor::Nvidia)
}
}
}
pub fn ld_library_path_lines(vendor: Option<GpuVendor>, libtorch_lib: &str) -> Vec<String> {
let tail = "${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}";
match vendor {
Some(GpuVendor::Amd) => {
let libdir = flodl_hw::rocm_runtime_lib_dir()
.and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "lib".to_string());
vec![
"export ROCM_PATH=\"${ROCM_PATH:-/opt/rocm}\"".to_string(),
format!("export LD_LIBRARY_PATH=\"$ROCM_PATH/{libdir}:{libtorch_lib}{tail}\""),
]
}
_ => vec![format!("export LD_LIBRARY_PATH=\"{libtorch_lib}{tail}\"")],
}
}
pub fn active_variant(root: &Path) -> Option<(PathBuf, String)> {
let info = read_active(root)?;
let dir = root.join("libtorch").join(&info.path);
dir.join("lib").is_dir().then_some((dir, info.path))
}
pub fn ld_library_path_value(
vendor: Option<GpuVendor>,
libtorch_lib: &str,
rocm_lib: &str,
) -> String {
match vendor {
Some(GpuVendor::Amd) => {
format!("{}:{libtorch_lib}", rocm_lib.trim_end_matches('/'))
}
_ => libtorch_lib.to_string(),
}
}
pub fn local_rocm_lib_dir() -> String {
match flodl_hw::rocm_runtime_lib_dir() {
Some(dir) => dir.display().to_string(),
None => format!(
"{}/lib",
std::env::var("ROCM_PATH")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| "/opt/rocm".to_string())
.trim_end_matches('/'),
),
}
}
pub fn variant_feature(variant: &str) -> &'static str {
match variant_vendor(variant) {
None => "",
Some(v) => v.cargo_feature(),
}
}
pub fn list_variants(root: &Path) -> Vec<String> {
let mut variants = Vec::new();
let lt_dir = root.join("libtorch");
for subdir in ["precompiled", "builds"] {
let dir = lt_dir.join(subdir);
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
if entry.path().join("lib").is_dir()
&& let Some(name) = entry.file_name().to_str()
{
variants.push(format!("{}/{}", subdir, name));
}
}
}
}
variants.sort();
variants
}
pub fn is_valid_variant(root: &Path, variant: &str) -> bool {
root.join(format!("libtorch/{}/lib", variant)).is_dir()
}
pub fn set_active(root: &Path, variant: &str) -> Result<(), String> {
let lt_dir = root.join("libtorch");
fs::create_dir_all(<_dir).map_err(|e| format!("cannot create libtorch/: {}", e))?;
fs::write(lt_dir.join(".active"), format!("{}\n", variant))
.map_err(|e| format!("cannot write libtorch/.active: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test_env::env_lock;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("fdl-libtorch-resolver-{}-{}", nanos, seq));
fs::create_dir_all(&dir).expect("create scratch");
Self(dir)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn make_root() -> Scratch {
let s = Scratch::new();
let v1 = s.path().join("libtorch/precompiled/v1");
fs::create_dir_all(v1.join("lib")).unwrap();
fs::write(
v1.join(".arch"),
"torch=1.0\ncuda=1.0\narchs=0.0\nsource=precompiled\n",
)
.unwrap();
let v2 = s.path().join("libtorch/builds/v2");
fs::create_dir_all(v2.join("lib")).unwrap();
fs::write(
v2.join(".arch"),
"torch=2.0\ncuda=2.0\narchs=1.0\nsource=build\n",
)
.unwrap();
s
}
#[test]
fn variant_vendor_reads_the_naming_convention() {
for (path, want) in [
("precompiled/cpu", None),
("precompiled/cu128", Some(GpuVendor::Nvidia)),
("precompiled/cu126-pt27", Some(GpuVendor::Nvidia)),
("builds/sm61-sm120", Some(GpuVendor::Nvidia)),
("builds/sm80", Some(GpuVendor::Nvidia)),
("precompiled/rocm63", Some(GpuVendor::Amd)),
("builds/gfx1030-gfx1100", Some(GpuVendor::Amd)),
("builds/gfx942", Some(GpuVendor::Amd)),
] {
assert_eq!(variant_vendor(path), want, "{path}");
}
}
#[test]
fn variant_vendor_requires_a_digit_after_the_prefix() {
assert_eq!(variant_vendor("precompiled/cpu"), None);
assert_eq!(variant_vendor("x/cpu-static"), None);
assert_eq!(variant_vendor("builds/mybuild"), Some(GpuVendor::Nvidia));
assert_eq!(variant_vendor("builds/gfx"), Some(GpuVendor::Nvidia));
}
#[test]
fn ld_recipe_puts_system_rocm_before_libtorch() {
for lib in ["$LIBTORCH_PATH/lib", "/opt/lt/rocm70/lib"] {
let lines = ld_library_path_lines(Some(GpuVendor::Amd), lib);
let ld = lines
.iter()
.find(|l| l.contains("LD_LIBRARY_PATH="))
.expect("recipe must set LD_LIBRARY_PATH");
let rocm = ld
.find("$ROCM_PATH/lib")
.expect("system ROCm must be on the path");
let libtorch = ld.find(lib).expect("libtorch must be on the path");
assert!(rocm < libtorch, "system ROCm must come first, got {ld}");
assert!(
lines.iter().any(|l| l.contains("ROCM_PATH:-/opt/rocm")),
"an unset ROCM_PATH must fall back to the convention: {lines:?}"
);
}
}
#[test]
fn ld_recipe_is_libtorch_only_for_nvidia_and_cpu() {
for vendor in [Some(GpuVendor::Nvidia), None] {
let lines = ld_library_path_lines(vendor, "$LIBTORCH_PATH/lib");
assert_eq!(lines.len(), 1, "{vendor:?}");
assert!(!lines[0].contains("rocm"), "{vendor:?}: {}", lines[0]);
assert!(lines[0].contains("$LIBTORCH_PATH/lib"), "{}", lines[0]);
}
}
#[test]
fn ld_recipe_preserves_an_existing_ld_library_path() {
for vendor in [Some(GpuVendor::Amd), Some(GpuVendor::Nvidia), None] {
let lines = ld_library_path_lines(vendor, "/opt/lt/lib");
let ld = lines
.iter()
.find(|l| l.contains("LD_LIBRARY_PATH="))
.unwrap();
assert!(
ld.contains("${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"),
"{vendor:?}: {ld}"
);
}
}
#[test]
fn variant_feature_maps_to_the_cargo_feature() {
assert_eq!(variant_feature("precompiled/cpu"), "");
assert_eq!(variant_feature("precompiled/cu128"), "cuda");
assert_eq!(variant_feature("builds/gfx1030"), "rocm");
}
#[test]
fn read_active_default_pointer() {
let _guard = env_lock();
unsafe {
std::env::remove_var("FDL_LIBTORCH_CASE");
}
let root = make_root();
fs::write(root.path().join("libtorch/.active"), "precompiled/v1\n").unwrap();
let info = read_active(root.path()).expect("read_active");
assert_eq!(info.path, "precompiled/v1");
assert_eq!(info.torch_version.as_deref(), Some("1.0"));
}
#[test]
fn fdl_libtorch_case_selects_alternate_pointer() {
let _guard = env_lock();
let root = make_root();
fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
fs::write(root.path().join("libtorch/.active.alt"), "precompiled/v1\n").unwrap();
unsafe {
std::env::set_var("FDL_LIBTORCH_CASE", "alt");
}
let info = read_active(root.path()).expect("read_active");
unsafe {
std::env::remove_var("FDL_LIBTORCH_CASE");
}
assert_eq!(info.path, "precompiled/v1");
assert_eq!(info.torch_version.as_deref(), Some("1.0"));
}
#[test]
fn fdl_libtorch_case_missing_file_returns_none_loudly() {
let _guard = env_lock();
let root = make_root();
fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
unsafe {
std::env::set_var("FDL_LIBTORCH_CASE", "nonexistent");
}
let info = read_active(root.path());
unsafe {
std::env::remove_var("FDL_LIBTORCH_CASE");
}
assert!(
info.is_none(),
"explicit case with missing file must not silently fall back to .active"
);
}
#[test]
fn read_active_from_resolves_pointer_directly() {
let _guard = env_lock();
let root = make_root();
let pointer = root.path().join("libtorch/.active.alt");
fs::write(&pointer, "builds/v2\n").unwrap();
let info =
read_active_from(&pointer, &root.path().join("libtorch")).expect("read_active_from");
assert_eq!(info.path, "builds/v2");
assert_eq!(info.archs.as_deref(), Some("1.0"));
}
}
pub fn unmet_loader_requirements(variant_dir: &Path) -> Vec<String> {
let core = variant_dir.join("lib/libtorch_cpu.so");
if !core.is_file() {
return Vec::new();
}
let Ok(out) = std::process::Command::new("ldd").arg(&core).output() else {
return Vec::new();
};
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
parse_unmet_versions(&text)
}
pub(crate) fn parse_unmet_versions(ldd_output: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for line in ldd_output.lines() {
if !line.contains("not found") {
continue;
}
let Some(rest) = line.split("version `").nth(1) else {
continue;
};
let Some(sym) = rest.split('\'').next() else {
continue;
};
if !seen.iter().any(|s| s == sym) {
seen.push(sym.to_string());
}
}
seen
}
#[cfg(test)]
mod loader_tests {
use super::parse_unmet_versions;
#[test]
fn it_reads_the_versions_the_loader_could_not_satisfy() {
let real = "\
/lt/libtorch_cpu.so: /lib64/libm.so.6: version `GLIBC_2.35' not found (required by /lt/libtorch_cpu.so)
/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libtorch_cpu.so)
/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libc10.so)
\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
";
assert_eq!(
parse_unmet_versions(real),
vec!["GLIBC_2.35".to_string(), "GLIBCXX_3.4.30".to_string()],
"de-duplicated, in first-seen order",
);
}
#[test]
fn a_satisfied_load_reports_nothing() {
let ok = "\
\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
\tlibtorch_cpu.so => /lt/libtorch_cpu.so (0x00007f0e88000000)
\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
";
assert!(parse_unmet_versions(ok).is_empty());
assert!(parse_unmet_versions("").is_empty());
}
}