Skip to main content

flodl_cli/libtorch/
download.rs

1//! `fdl libtorch download` -- download pre-built libtorch.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use super::detect;
7use crate::context::Context;
8use crate::util::archive;
9use crate::util::http;
10use crate::util::system;
11use crate::util::system::GpuVendor;
12
13// ---------------------------------------------------------------------------
14// Constants
15// ---------------------------------------------------------------------------
16
17const LIBTORCH_VERSION: &str = "2.10.0";
18
19/// Pre-built variant metadata.
20struct VariantSpec {
21    /// Label for display (e.g. "CUDA 12.8").
22    label: &'static str,
23    /// Directory name under precompiled/ (e.g. "cu128").
24    dir_name: &'static str,
25    /// Value for .arch `cuda=` field.
26    arch_cuda: &'static str,
27    /// Space-separated compute capabilities covered.
28    arch_archs: &'static str,
29    /// Value for .arch `variant=` field.
30    arch_variant: &'static str,
31}
32
33const CPU_SPEC: VariantSpec = VariantSpec {
34    label: "CPU",
35    dir_name: "cpu",
36    arch_cuda: "none",
37    arch_archs: "cpu",
38    arch_variant: "cpu",
39};
40
41const CU126_SPEC: VariantSpec = VariantSpec {
42    label: "CUDA 12.6",
43    dir_name: "cu126",
44    arch_cuda: "12.6",
45    arch_archs: "5.0 5.2 6.0 6.1 7.0 7.5 8.0 8.6 8.9 9.0",
46    arch_variant: "cu126",
47};
48
49const CU128_SPEC: VariantSpec = VariantSpec {
50    label: "CUDA 12.8",
51    dir_name: "cu128",
52    arch_cuda: "12.8",
53    arch_archs: "7.0 7.5 8.0 8.6 8.9 9.0 12.0",
54    arch_variant: "cu128",
55};
56
57/// gfx targets the ROCm archives ship rocBLAS Tensile kernels for.
58///
59/// Read out of the published archives rather than inferred: both ROCm
60/// buckets of a given libtorch version carry the same set, and a target
61/// is only listed when the archive holds `.hsaco` or `TensileLibrary*`
62/// payload for it. Targets with nothing but MIOpen performance
63/// databases (`gfx900`, `gfx906`) are deliberately absent -- rocBLAS has
64/// no kernels to load for them, so listing one would let the
65/// arch-coverage gate admit a box that dies at its first BLAS call,
66/// which is the death that gate exists to move before the dial.
67///
68/// Verifiable without downloading the archive: its central directory is
69/// reachable with HTTP range requests (a few MB against ~5 GB), and the
70/// host answers 403 without a User-Agent.
71const ROCM_ARCHS: &str = "gfx908 gfx90a gfx942 gfx950 gfx1030 gfx1100 gfx1101 \
72                          gfx1102 gfx1150 gfx1151 gfx1200 gfx1201";
73
74const ROCM70_SPEC: VariantSpec = VariantSpec {
75    label: "ROCm 7.0",
76    // `rocm70` (no dot) matches the cu128 style and satisfies
77    // `detect::variant_vendor`'s `rocm<digit>` rule.
78    dir_name: "rocm70",
79    // `.arch` cuda= is the CUDA toolkit version; an AMD build has none.
80    // The vendor is carried by the variant path, which is what
81    // `variant_vendor` and prebuild's feature derivation read.
82    arch_cuda: "none",
83    arch_archs: ROCM_ARCHS,
84    // Doubles as the URL bucket AND the `+<variant>` filename suffix,
85    // exactly like `cu128` -- PyTorch dropped the `cxx11-abi-` filename
86    // prefix, so the ROCm archives follow the same pattern as CUDA's and
87    // need no special-casing in the URL builder.
88    arch_variant: "rocm7.0",
89};
90
91const ROCM71_SPEC: VariantSpec = VariantSpec {
92    label: "ROCm 7.1",
93    dir_name: "rocm71",
94    arch_cuda: "none",
95    // Identical hardware reach to 7.0: the two buckets ship the same
96    // gfx targets, so this variant exists for runtime matching, not for
97    // coverage.
98    arch_archs: ROCM_ARCHS,
99    arch_variant: "rocm7.1",
100};
101
102// ---------------------------------------------------------------------------
103// Download options
104// ---------------------------------------------------------------------------
105
106pub enum Variant {
107    Cpu,
108    Cuda126,
109    Cuda128,
110    Rocm70,
111    Rocm71,
112    Auto,
113}
114
115pub struct DownloadOpts {
116    pub variant: Variant,
117    pub custom_path: Option<PathBuf>,
118    pub activate: bool,
119    pub dry_run: bool,
120    /// Force the Linux x86_64 build regardless of host OS. Set when the
121    /// libtorch will be consumed inside a Linux Docker container rather
122    /// than linked against host cargo — without this, macOS hosts pick
123    /// `libtorch-macos-arm64-*.zip` (Mach-O dylibs) which then fail to
124    /// load inside the Linux container that bind-mounts the directory.
125    pub force_linux: bool,
126}
127
128impl Default for DownloadOpts {
129    fn default() -> Self {
130        Self {
131            variant: Variant::Auto,
132            custom_path: None,
133            activate: true,
134            dry_run: false,
135            force_linux: false,
136        }
137    }
138}
139
140// ---------------------------------------------------------------------------
141// URL construction
142// ---------------------------------------------------------------------------
143
144/// Absolute `libomp` dependencies in one `otool -L` dump.
145///
146/// Pure, so the parse is testable on any host without a Mach-O to hand.
147/// `otool -L` prints the file name on the first line and one indented
148/// dependency per line after it, each followed by version parens; only
149/// the leading path matters. `@rpath/...` and `@loader_path/...` are
150/// already relative and left alone, as is anything that is not libomp:
151/// this rewrites ONE known upstream defect, not every absolute path.
152fn absolute_libomp_refs(otool_output: &str) -> Vec<String> {
153    otool_output
154        .lines()
155        .skip(1)
156        .filter_map(|l| l.split_whitespace().next())
157        .filter(|p| p.starts_with('/') && p.ends_with("/libomp.dylib"))
158        .map(str::to_string)
159        .collect()
160}
161
162/// Point the macOS archive's own dylibs at the `libomp` it ships with.
163///
164/// Upstream's `libtorch-macos-arm64` BUNDLES `lib/libomp.dylib` and then
165/// has `libtorch_cpu.dylib` depend on it by absolute Homebrew path
166/// (`/opt/homebrew/opt/libomp/lib/libomp.dylib`). On a box without that
167/// Homebrew formula the load fails while the library it wants sits in the
168/// same directory as the dylib asking for it, so a scaffolded project
169/// compiles and dies at launch. `brew install libomp` is the wrong answer:
170/// it installs a second, possibly ABI-divergent copy of something already
171/// present.
172///
173/// `@loader_path/libomp.dylib` rather than `@rpath/...` deliberately: the
174/// bundled copy is a sibling of every dylib referencing it, so
175/// `@loader_path` resolves with no dependence on the referrer carrying a
176/// correct `LC_RPATH`.
177///
178/// Advisory, never fatal: libtorch IS installed at this point, and the
179/// docker path does not care about any of this. But the tools are checked
180/// BEFORE anything is modified, because `install_name_tool` invalidates a
181/// Mach-O signature and arm64 refuses to load an unsigned one -- patching
182/// without being able to re-sign would leave the install worse than it
183/// was found.
184fn relink_bundled_libomp(lib_dir: &Path) {
185    // The gate is capability, not `cfg`: `fdl setup` on Apple Silicon
186    // fetches the LINUX archive for a docker project, which has no
187    // dylibs at all and must not be touched.
188    if !lib_dir.join("libomp.dylib").exists() {
189        return;
190    }
191    let dylibs: Vec<PathBuf> = match fs::read_dir(lib_dir) {
192        Ok(rd) => rd
193            .filter_map(|e| e.ok().map(|e| e.path()))
194            .filter(|p| p.extension().is_some_and(|x| x == "dylib"))
195            .collect(),
196        Err(_) => return,
197    };
198
199    let missing: Vec<&str> = ["otool", "install_name_tool", "codesign"]
200        .into_iter()
201        .filter(|t| !crate::util::system::has_command(t))
202        .collect();
203    if !missing.is_empty() {
204        println!(
205            "  note: cannot relink the bundled libomp ({} not found).\n\
206             \x20       Upstream's libtorch_cpu.dylib asks for libomp at an absolute\n\
207             \x20       Homebrew path, so a NATIVE run may fail to start; the docker\n\
208             \x20       path is unaffected. Install the command line tools with\n\
209             \x20       `xcode-select --install` and re-run this download to fix it.",
210            missing.join(", "),
211        );
212        return;
213    }
214
215    let mut patched = 0usize;
216    for f in &dylibs {
217        let out = match std::process::Command::new("otool")
218            .arg("-L")
219            .arg(f)
220            .output()
221        {
222            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
223            _ => continue,
224        };
225        let refs = absolute_libomp_refs(&out);
226        if refs.is_empty() {
227            continue;
228        }
229        let mut cmd = std::process::Command::new("install_name_tool");
230        for r in &refs {
231            cmd.arg("-change").arg(r).arg("@loader_path/libomp.dylib");
232        }
233        match cmd.arg(f).output() {
234            Ok(o) if o.status.success() => {}
235            other => {
236                println!(
237                    "  note: install_name_tool failed on {}: {}",
238                    f.display(),
239                    match other {
240                        Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
241                        Err(e) => e.to_string(),
242                    },
243                );
244                continue;
245            }
246        }
247        // Ad-hoc re-sign, mandatory on arm64: the edit above invalidated
248        // whatever signature the file carried.
249        match std::process::Command::new("codesign")
250            .args(["-f", "-s", "-"])
251            .arg(f)
252            .output()
253        {
254            Ok(o) if o.status.success() => patched += 1,
255            other => println!(
256                "  warning: {} was relinked but could NOT be re-signed ({}); \
257                 it may fail to load. Re-run this download after \
258                 `xcode-select --install`.",
259                f.display(),
260                match other {
261                    Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
262                    Err(e) => e.to_string(),
263                },
264            ),
265        }
266    }
267    if patched > 0 {
268        println!("  relinked {patched} dylib(s) to the bundled libomp");
269    }
270}
271
272fn download_url(spec: &VariantSpec, force_linux: bool) -> Result<String, String> {
273    // `force_linux` short-circuits host detection: the binary is destined
274    // for a Linux Docker container, so we always want the Linux x86_64
275    // build regardless of what the host is.
276    let (os, arch) = if force_linux {
277        ("linux", "x86_64")
278    } else {
279        (std::env::consts::OS, std::env::consts::ARCH)
280    };
281
282    download_url_for(spec, os, arch)
283}
284
285/// Pure core of [`download_url`]: the host is a parameter rather than a
286/// global read.
287///
288/// Every platform arm is reachable from any test runner as a result, which
289/// is the point. The Windows filename pattern differs from Linux's, this
290/// function claimed in a comment that it did not, and the resulting 404
291/// shipped unnoticed because nothing had ever evaluated the Windows arm on
292/// a Windows host. Host-as-parameter is what makes that testable without
293/// one.
294fn download_url_for(spec: &VariantSpec, os: &str, arch: &str) -> Result<String, String> {
295    match (os, arch) {
296        ("linux", "x86_64") => {}
297        ("macos", "aarch64") => {
298            // `cuda=none` stopped meaning "CPU build" when a second
299            // vendor arrived: a ROCm spec carries it too, and without
300            // the second clause it resolves to the macOS CPU archive
301            // and installs it under a ROCm directory name.
302            if spec.arch_cuda != "none" || spec.arch_variant.starts_with("rocm") {
303                return Err("macOS only supports CPU libtorch".into());
304            }
305        }
306        ("macos", _) => {
307            return Err(format!(
308                "macOS libtorch requires Apple Silicon (arm64), got {}.\n\
309                 macOS x86_64 was dropped after PyTorch 2.2.",
310                arch
311            ));
312        }
313        ("windows", "x86_64") => {
314            // PyTorch publishes no ROCm build for Windows: the `rocm7.0`
315            // bucket carries Linux archives only.
316            if spec.arch_variant.starts_with("rocm") {
317                return Err(format!(
318                    "{} libtorch is not available for Windows.\n\
319                     PyTorch publishes ROCm builds for Linux only.",
320                    spec.label
321                ));
322            }
323        }
324        _ => {
325            return Err(format!(
326                "Unsupported platform: {} {}.\n\
327                 libtorch is available for Linux x86_64, macOS arm64, and Windows x86_64.",
328                os, arch
329            ));
330        }
331    }
332
333    // macOS ARM has a different filename pattern
334    if os == "macos" {
335        return Ok(format!(
336            "https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-{}.zip",
337            LIBTORCH_VERSION
338        ));
339    }
340
341    // Linux and Windows share the bucket layout but NOT the filename:
342    // Windows archives carry a `-win-` infix. PyTorch also publishes a
343    // `-debug-` Windows variant (built against the debug CRT); we fetch the
344    // release one, which is what a release-mode consumer must link against.
345    let infix = if os == "windows" { "win-" } else { "" };
346    let filename = format!(
347        "libtorch-{}shared-with-deps-{}%2B{}.zip",
348        infix, LIBTORCH_VERSION, spec.arch_variant
349    );
350
351    let bucket = spec.arch_variant; // "cpu", "cu126", "cu128", "rocm7.0"
352    Ok(format!(
353        "https://download.pytorch.org/libtorch/{}/{}",
354        bucket, filename
355    ))
356}
357
358// ---------------------------------------------------------------------------
359// Auto-detection
360// ---------------------------------------------------------------------------
361
362fn auto_detect_variant() -> &'static VariantSpec {
363    let survey = flodl_hw::survey();
364    if survey.devices.is_empty() {
365        // Say WHY before routing to CPU: the sweep deliberately reports
366        // no device for an AMD card with no ROCm runtime, and that
367        // finding names the fix — discarding it turns a provisioning
368        // step ("install ROCm, then re-run") into a silent wrong
369        // variant.
370        for note in survey.notes.iter().filter(|n| n.kind.explains_absence()) {
371            println!("  {}", note.message);
372        }
373    }
374    variant_for_gpus(&survey.devices)
375}
376
377/// Route a detected GPU set to a libtorch variant.
378///
379/// Pure: the device list is a parameter rather than a probe, so every
380/// vendor and coverage arm is testable without hardware and without the
381/// process-global detection spoof.
382fn variant_for_gpus(gpus: &[system::GpuInfo]) -> &'static VariantSpec {
383    if gpus.is_empty() {
384        println!("  No GPU detected. Using CPU variant.");
385        return &CPU_SPEC;
386    }
387
388    // A libtorch build serves exactly one vendor, so a mixed box has to
389    // pick. NVIDIA wins: in a box holding both, the AMD part is usually
390    // an APU's integrated GPU and the NVIDIA one the training card.
391    let amd: Vec<_> = gpus.iter().filter(|g| g.vendor == GpuVendor::Amd).collect();
392    let has_nvidia = gpus.iter().any(|g| g.vendor == GpuVendor::Nvidia);
393    if !amd.is_empty() {
394        if has_nvidia {
395            println!(
396                "  Both NVIDIA and AMD GPUs detected. One libtorch build serves\n  \
397                 one vendor, so the NVIDIA cards are used and the AMD ones stay\n  \
398                 idle. For the AMD cards instead: fdl libtorch download --rocm 7.0",
399            );
400        } else {
401            return rocm_variant_for(&amd);
402        }
403    }
404
405    // The CUDA variants below are selected on compute capability, which
406    // only NVIDIA devices carry.
407    let majors: Vec<u32> = gpus.iter().filter_map(|g| g.sm_major()).collect();
408    if majors.is_empty() {
409        let other: Vec<String> = gpus
410            .iter()
411            .map(|g| format!("{} ({})", g.short_name(), g.arch_label()))
412            .collect();
413        println!(
414            "  Detected a GPU with no known libtorch variant ({}).\n  \
415             Using the CPU variant.",
416            other.join(", "),
417        );
418        return &CPU_SPEC;
419    }
420    let lo_major = majors.iter().copied().min().unwrap_or(0);
421    let hi_major = majors.iter().copied().max().unwrap_or(0);
422
423    // cu128 requires Volta+ (sm_70+), cu126 supports down to sm_50
424    if lo_major >= 7 {
425        println!("  Detected Volta+ GPU(s). Using cu128.");
426        &CU128_SPEC
427    } else if hi_major >= 10 {
428        // Mixed: old + new GPUs. cu126 covers the old ones, cu128 covers the new.
429        // Default to cu126 which covers more architectures.
430        println!(
431            "  Mixed GPU architectures (sm_{}.x to sm_{}.x).",
432            lo_major, hi_major
433        );
434        println!("  Using cu126 (broadest pre-Volta coverage).");
435        println!("  For all GPUs, consider: fdl libtorch build");
436        &CU126_SPEC
437    } else {
438        println!("  Detected pre-Volta GPU(s). Using cu126.");
439        &CU126_SPEC
440    }
441}
442
443/// AMD devices the ROCm variant ships kernels for.
444///
445/// Exposed so the setup wizard routes on the same coverage list this
446/// module downloads against: two independently-maintained copies is how
447/// the wizard came to skip AMD boxes in silence.
448pub fn rocm_covered(gpus: &[system::GpuInfo]) -> Vec<&system::GpuInfo> {
449    gpus.iter()
450        .filter(|g| g.vendor == GpuVendor::Amd && g.covered_by(ROCM_ARCHS))
451        .collect()
452}
453
454/// The gfx targets the ROCm variants cover, for diagnostics.
455pub fn rocm_archs() -> &'static str {
456    ROCM_ARCHS
457}
458
459/// Pick between the ROCm variant and CPU for a set of AMD devices.
460///
461/// The ROCm archive carries pre-built rocBLAS Tensile kernels for a
462/// fixed gfx list; a target outside it has no kernels, so the variant is
463/// only worth downloading when it covers at least one device present.
464///
465/// Which ROCm bucket is not a hardware question: they cover the same
466/// targets, so this routes to the OLDEST offered one on purpose. The
467/// HIP runtime ordering rule puts the host's own ROCm ahead of the
468/// bundle, and within a major version that ABI grows, so a bundle older
469/// than the host loads while a newer one can fail on a symbol the host
470/// runtime does not have. 7.0 therefore serves every 7.x host, where
471/// 7.1 would drop the 7.0 ones. Picking the newest bundle that the
472/// detected host runtime can satisfy is the better rule and needs the
473/// ROCm version resolver; until then, oldest-serves-most. A host that
474/// wants the exact match asks for it: `fdl libtorch download --rocm 7.1`.
475fn rocm_variant_for(amd: &[&system::GpuInfo]) -> &'static VariantSpec {
476    let (covered, uncovered): (Vec<_>, Vec<_>) = amd.iter().partition(|g| g.covered_by(ROCM_ARCHS));
477
478    let describe = |gs: &[&&system::GpuInfo]| {
479        gs.iter()
480            .map(|g| format!("{} ({})", g.short_name(), g.arch_label()))
481            .collect::<Vec<_>>()
482            .join(", ")
483    };
484
485    if covered.is_empty() {
486        println!(
487            "  Detected AMD GPU(s) ({}) outside the ROCm build's gfx\n  \
488             targets, so the CPU variant is selected.\n  \
489             Covered targets: {}.",
490            describe(&uncovered),
491            ROCM_ARCHS,
492        );
493        return &CPU_SPEC;
494    }
495    if !uncovered.is_empty() {
496        println!(
497            "  Note: {} is not covered by the ROCm build and will be\n  \
498             unusable. Covered targets: {}.",
499            describe(&uncovered),
500            ROCM_ARCHS,
501        );
502    }
503    println!(
504        "  Detected AMD GPU(s) ({}). Using ROCm 7.0.",
505        describe(&covered)
506    );
507    &ROCM70_SPEC
508}
509
510fn resolve_variant(variant: &Variant) -> &'static VariantSpec {
511    match variant {
512        Variant::Cpu => &CPU_SPEC,
513        Variant::Cuda126 => &CU126_SPEC,
514        Variant::Cuda128 => &CU128_SPEC,
515        Variant::Rocm70 => &ROCM70_SPEC,
516        Variant::Rocm71 => &ROCM71_SPEC,
517        Variant::Auto => auto_detect_variant(),
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Core download logic
523// ---------------------------------------------------------------------------
524
525pub fn run(opts: DownloadOpts) -> Result<String, String> {
526    let ctx = Context::resolve();
527    run_with_context(opts, &ctx)
528}
529
530/// Run with an explicit context (used by `setup` which has its own
531/// context).
532///
533/// Returns the variant id it resolved to (`precompiled/<dir>`), because
534/// `Variant::Auto` only decides here: a caller that needs the path or the
535/// label afterwards would otherwise have to re-run the detection, which
536/// re-prints its reasoning and can only agree by accident.
537pub fn run_with_context(opts: DownloadOpts, ctx: &Context) -> Result<String, String> {
538    let spec = resolve_variant(&opts.variant);
539    let url = download_url(spec, opts.force_linux)?;
540
541    // Determine install path
542    let install_path = if let Some(ref p) = opts.custom_path {
543        p.clone()
544    } else {
545        ctx.root
546            .join(format!("libtorch/precompiled/{}", spec.dir_name))
547    };
548
549    let variant_id = format!("precompiled/{}", spec.dir_name);
550
551    println!();
552    println!("  libtorch {} ({})", LIBTORCH_VERSION, spec.label);
553    println!("  URL:  {}", url);
554    println!("  Path: {}", install_path.display());
555
556    if opts.dry_run {
557        println!();
558        println!("  [dry-run] Would download and extract to above path.");
559        return Ok(variant_id);
560    }
561
562    // Check existing installation
563    if install_path.exists() {
564        let build_ver_path = install_path.join("build-version");
565        let existing_ver = fs::read_to_string(&build_ver_path)
566            .ok()
567            .map(|s| s.trim().to_string());
568
569        // build-version may contain variant suffix (e.g. "2.10.0+cpu")
570        let ver_matches = existing_ver.as_deref().is_some_and(|v| {
571            v == LIBTORCH_VERSION || v.starts_with(&format!("{}+", LIBTORCH_VERSION))
572        });
573
574        if ver_matches {
575            println!();
576            println!("  Already installed (version {}).", LIBTORCH_VERSION);
577            return Ok(variant_id);
578        }
579
580        println!();
581        println!(
582            "  Removing existing installation (version: {})...",
583            existing_ver.as_deref().unwrap_or("unknown")
584        );
585        fs::remove_dir_all(&install_path)
586            .map_err(|e| format!("cannot remove {}: {}", install_path.display(), e))?;
587    }
588
589    // Stage BESIDE the destination, not in the system temp dir.
590    //
591    // `std::env::temp_dir()` is `/tmp`, which on a great many Linux
592    // setups is a small RAM-backed tmpfs -- 16 GiB on the rig this was
593    // found on. Staging there needs the archive AND its expansion at
594    // once: ~20 GiB for a ROCm build, ~7 GiB even for CUDA. Blowing it
595    // does not merely fail the download, it fills a tmpfs that the rest
596    // of the system (and every shell's temp files) depends on.
597    //
598    // The destination's own filesystem is the one the user actually
599    // sized for libtorch, and staging there makes the final move a
600    // same-filesystem rename rather than a cross-device copy.
601    let stage_root = install_path
602        .parent()
603        .map(Path::to_path_buf)
604        .unwrap_or_else(|| PathBuf::from("."));
605    fs::create_dir_all(&stage_root)
606        .map_err(|e| format!("cannot create {}: {}", stage_root.display(), e))?;
607    let stage = Staging::new(stage_root.join(format!(".fdl-staging-{}", std::process::id())))?;
608
609    let tmp_zip = stage.path().join(format!("libtorch-{}.zip", spec.dir_name));
610
611    println!();
612    println!("  Downloading...");
613    http::download_file(&url, &tmp_zip)?;
614
615    // Extract (the zip carries a top-level "libtorch/" dir)
616    let tmp_extract = stage.path().join("extract");
617    println!("  Extracting...");
618    archive::extract_zip(&tmp_zip, &tmp_extract)?;
619
620    // Move extracted contents to target path
621    let extracted_lt = tmp_extract.join("libtorch");
622    let source = if extracted_lt.is_dir() {
623        &extracted_lt
624    } else {
625        &tmp_extract
626    };
627
628    fs::create_dir_all(&install_path)
629        .map_err(|e| format!("cannot create {}: {}", install_path.display(), e))?;
630
631    // Move all files from extracted dir to install path. Same
632    // filesystem now, so `move_contents`'s rename path is the one that
633    // fires.
634    move_contents(source, &install_path)?;
635
636    // `stage` cleans itself up on drop, including on the error paths
637    // above -- the predecessor leaked its temp zip and extract dir
638    // whenever anything failed, which on a tmpfs meant a failed
639    // download left gigabytes behind until reboot.
640    drop(stage);
641
642    // Verify
643    let lib_dir = install_path.join("lib");
644    let has_lib = lib_dir.join("libtorch.so").exists()
645        || lib_dir.join("libtorch.dylib").exists()
646        || lib_dir.join("torch.lib").exists();
647
648    if !has_lib {
649        return Err(format!(
650            "libtorch library not found at {}.\n\
651             The archive structure may have changed.\n\
652             Check: ls {}",
653            lib_dir.display(),
654            lib_dir.display()
655        ));
656    }
657
658    relink_bundled_libomp(&lib_dir);
659
660    // Write .arch metadata (always, both project and global)
661    let arch_content = format!(
662        "cuda={}\ntorch={}\narchs={}\nsource=precompiled\nvariant={}\n",
663        spec.arch_cuda, LIBTORCH_VERSION, spec.arch_archs, spec.arch_variant
664    );
665    fs::write(install_path.join(".arch"), arch_content)
666        .map_err(|e| format!("cannot write .arch: {}", e))?;
667
668    if opts.activate {
669        detect::set_active(&ctx.root, &variant_id)?;
670    }
671
672    println!();
673    println!("  ================================================");
674    println!("  libtorch {} ({}) installed", LIBTORCH_VERSION, spec.label);
675    println!("  {}", install_path.display());
676    println!("  ================================================");
677
678    if ctx.is_project {
679        println!();
680        println!("  .arch:   {}/.arch", install_path.display());
681        if opts.activate {
682            println!("  .active: libtorch/.active -> {}", variant_id);
683        }
684        println!();
685        // From the variant PATH, not `.arch`'s `cuda=`: a ROCm build has
686        // no CUDA toolkit version and writes `cuda=none` there exactly
687        // like a CPU build, so reading that field told anyone who had
688        // just installed ROCm libtorch to run the CPU test suite.
689        if detect::variant_vendor(&variant_id).is_some() {
690            println!("  Run 'fdl gpu-test' to verify.");
691        } else {
692            println!("  Run 'fdl test' to verify.");
693        }
694    } else {
695        println!();
696        println!("  Installed to: {}", install_path.display());
697        println!();
698        println!("  To use with tch-rs or flodl, add to your shell profile:");
699        println!();
700        println!("    export LIBTORCH=\"{}\"", install_path.display());
701        // Shared recipe: on a ROCm variant the system runtime has to come
702        // first, and a recipe the user pastes is exactly where getting
703        // that backwards costs a segfault at the first GPU op.
704        let lib = format!("{}/lib", install_path.display());
705        for line in detect::ld_library_path_lines(detect::variant_vendor(&variant_id), &lib) {
706            println!("    {line}");
707        }
708        println!();
709        println!("  Or start a new floDl project:");
710        println!("    fdl init my-project");
711    }
712
713    Ok(variant_id)
714}
715
716// ---------------------------------------------------------------------------
717// Helpers
718// ---------------------------------------------------------------------------
719
720/// Move all files and directories from `src` into `dest`.
721/// A staging directory that removes itself on drop, however we leave.
722///
723/// The point is the failure paths: a download or extract that errors
724/// out used to leave its partial archive and expansion behind, which on
725/// a tmpfs is space nothing reclaims until reboot.
726struct Staging(PathBuf);
727
728impl Staging {
729    fn new(path: PathBuf) -> Result<Self, String> {
730        // A leftover from a crashed run would otherwise merge into this
731        // one; start clean.
732        let _ = fs::remove_dir_all(&path);
733        fs::create_dir_all(&path)
734            .map_err(|e| format!("cannot create staging dir {}: {}", path.display(), e))?;
735        Ok(Self(path))
736    }
737    fn path(&self) -> &Path {
738        &self.0
739    }
740}
741
742impl Drop for Staging {
743    fn drop(&mut self) {
744        let _ = fs::remove_dir_all(&self.0);
745    }
746}
747
748fn move_contents(src: &Path, dest: &Path) -> Result<(), String> {
749    let entries = fs::read_dir(src).map_err(|e| format!("cannot read {}: {}", src.display(), e))?;
750
751    for entry in entries {
752        let entry = entry.map_err(|e| format!("read_dir error: {}", e))?;
753        let from = entry.path();
754        let name = entry.file_name();
755        let to = dest.join(&name);
756
757        // Try rename first (fast, same filesystem). Fall back to copy.
758        if fs::rename(&from, &to).is_err() {
759            if from.is_dir() {
760                copy_dir_recursive(&from, &to)?;
761            } else {
762                fs::copy(&from, &to)
763                    .map_err(|e| format!("copy {} -> {}: {}", from.display(), to.display(), e))?;
764            }
765        }
766    }
767    Ok(())
768}
769
770fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {
771    fs::create_dir_all(dest).map_err(|e| format!("cannot create {}: {}", dest.display(), e))?;
772
773    for entry in fs::read_dir(src).map_err(|e| format!("read {}: {}", src.display(), e))? {
774        let entry = entry.map_err(|e| format!("read_dir error: {}", e))?;
775        let from = entry.path();
776        let to = dest.join(entry.file_name());
777
778        if from.is_dir() {
779            copy_dir_recursive(&from, &to)?;
780        } else {
781            fs::copy(&from, &to)
782                .map_err(|e| format!("copy {} -> {}: {}", from.display(), to.display(), e))?;
783        }
784    }
785    Ok(())
786}
787
788/// Get the current libtorch version constant (for display and checks).
789#[allow(dead_code)]
790pub fn libtorch_version() -> &'static str {
791    LIBTORCH_VERSION
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    // These assert the exact upstream filename grammar, which differs per
799    // OS in ways that are invisible from a Linux dev box. Each expectation
800    // below was confirmed against download.pytorch.org with a bogus-name
801    // control request, not inferred from the neighbouring arms.
802
803    #[test]
804    fn linux_url_has_no_os_infix() {
805        let url = download_url_for(&CU128_SPEC, "linux", "x86_64").unwrap();
806        assert_eq!(
807            url,
808            format!(
809                "https://download.pytorch.org/libtorch/cu128/\
810                 libtorch-shared-with-deps-{LIBTORCH_VERSION}%2Bcu128.zip"
811            )
812        );
813    }
814
815    #[test]
816    fn windows_url_carries_the_win_infix() {
817        // Regression: this arm used to build the Linux filename and 404.
818        let url = download_url_for(&CU128_SPEC, "windows", "x86_64").unwrap();
819        assert!(
820            url.contains("libtorch-win-shared-with-deps-"),
821            "windows archives need the `-win-` infix, got {url}"
822        );
823        assert_eq!(
824            url,
825            format!(
826                "https://download.pytorch.org/libtorch/cu128/\
827                 libtorch-win-shared-with-deps-{LIBTORCH_VERSION}%2Bcu128.zip"
828            )
829        );
830    }
831
832    #[test]
833    fn windows_cpu_url_carries_the_win_infix() {
834        let url = download_url_for(&CPU_SPEC, "windows", "x86_64").unwrap();
835        assert_eq!(
836            url,
837            format!(
838                "https://download.pytorch.org/libtorch/cpu/\
839                 libtorch-win-shared-with-deps-{LIBTORCH_VERSION}%2Bcpu.zip"
840            )
841        );
842    }
843
844    #[test]
845    fn windows_rejects_rocm() {
846        // The ROCm buckets are Linux-only upstream; a `-win-` URL there is
847        // a 404, so refuse before downloading rather than after.
848        for spec in [&ROCM70_SPEC, &ROCM71_SPEC] {
849            let err = download_url_for(spec, "windows", "x86_64").unwrap_err();
850            assert!(err.contains("not available for Windows"), "got {err}");
851        }
852    }
853
854    #[test]
855    fn linux_accepts_rocm() {
856        for spec in [&ROCM70_SPEC, &ROCM71_SPEC] {
857            let url = download_url_for(spec, "linux", "x86_64").unwrap();
858            let bucket = spec.arch_variant;
859            assert_eq!(
860                url,
861                format!(
862                    "https://download.pytorch.org/libtorch/{bucket}/\
863                     libtorch-shared-with-deps-{LIBTORCH_VERSION}%2B{bucket}.zip"
864                )
865            );
866        }
867    }
868
869    #[test]
870    fn the_rocm_variants_differ_only_in_runtime_version() {
871        // Same hardware reach, different bundled HIP runtime: the second
872        // variant exists so a host can match its own ROCm, not so it can
873        // reach a card the other one cannot.
874        assert_eq!(ROCM70_SPEC.arch_archs, ROCM71_SPEC.arch_archs);
875        assert_ne!(ROCM70_SPEC.arch_variant, ROCM71_SPEC.arch_variant);
876        assert_ne!(ROCM70_SPEC.dir_name, ROCM71_SPEC.dir_name);
877        // `variant_vendor` reads the directory basename, so both must
878        // still say AMD to the feature derivation.
879        for spec in [&ROCM70_SPEC, &ROCM71_SPEC] {
880            assert_eq!(
881                detect::variant_vendor(&format!("precompiled/{}", spec.dir_name)),
882                Some(GpuVendor::Amd),
883                "{} must derive the AMD feature",
884                spec.dir_name
885            );
886        }
887    }
888
889    #[test]
890    fn macos_arm_uses_its_own_filename_and_is_cpu_only() {
891        let url = download_url_for(&CPU_SPEC, "macos", "aarch64").unwrap();
892        assert_eq!(
893            url,
894            format!(
895                "https://download.pytorch.org/libtorch/cpu/\
896                 libtorch-macos-arm64-{LIBTORCH_VERSION}.zip"
897            )
898        );
899
900        let err = download_url_for(&CU128_SPEC, "macos", "aarch64").unwrap_err();
901        assert!(err.contains("only supports CPU"), "got {err}");
902    }
903
904    #[test]
905    fn macos_rejects_rocm_rather_than_serving_the_cpu_archive() {
906        // A ROCm spec has no CUDA version either, so the CUDA-shaped
907        // guard passed it through and the macOS filename branch handed
908        // back the CPU archive: a CPU libtorch installed as `rocm70`,
909        // with nothing anywhere saying so.
910        for spec in [&ROCM70_SPEC, &ROCM71_SPEC] {
911            let err = download_url_for(spec, "macos", "aarch64").unwrap_err();
912            assert!(err.contains("only supports CPU"), "got {err}");
913        }
914    }
915
916    #[test]
917    fn macos_intel_is_rejected_with_a_reason() {
918        let err = download_url_for(&CPU_SPEC, "macos", "x86_64").unwrap_err();
919        assert!(err.contains("Apple Silicon"), "got {err}");
920    }
921
922    #[test]
923    fn unsupported_platform_is_rejected() {
924        // linux-aarch64 has no upstream libtorch archive; `fdl libtorch
925        // build` from source is the path there.
926        let err = download_url_for(&CPU_SPEC, "linux", "aarch64").unwrap_err();
927        assert!(err.contains("Unsupported platform"), "got {err}");
928    }
929
930    /// `otool -L` shape as upstream's arm64 archive actually prints it:
931    /// the file name first, then one indented dependency per line with
932    /// trailing version parens.
933    const OTOOL_LIBTORCH_CPU: &str = "\
934libtorch/lib/libtorch_cpu.dylib:
935\t@rpath/libtorch_cpu.dylib (compatibility version 0.0.0, current version 0.0.0)
936\t/opt/homebrew/opt/libomp/lib/libomp.dylib (compatibility version 5.0.0, current version 5.0.0)
937\t@rpath/libc10.dylib (compatibility version 0.0.0, current version 0.0.0)
938\t/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.0)
939\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
940";
941
942    #[test]
943    fn the_absolute_libomp_dependency_is_the_only_one_rewritten() {
944        // Precisely one line qualifies. The self-reference on line 2 and
945        // the two /usr/lib system libraries must NOT be touched: this
946        // fixes one upstream defect, and widening it to "every absolute
947        // path" would repoint libc++ at a sibling that does not exist.
948        assert_eq!(
949            absolute_libomp_refs(OTOOL_LIBTORCH_CPU),
950            vec!["/opt/homebrew/opt/libomp/lib/libomp.dylib".to_string()],
951        );
952    }
953
954    #[test]
955    fn an_already_relative_libomp_is_left_alone() {
956        // Idempotence: the second `fdl libtorch download` over the same
957        // variant must find nothing to do, or it re-signs on every run.
958        let patched = OTOOL_LIBTORCH_CPU.replace(
959            "/opt/homebrew/opt/libomp/lib/libomp.dylib",
960            "@loader_path/libomp.dylib",
961        );
962        assert!(absolute_libomp_refs(&patched).is_empty(), "{patched}");
963        // `@rpath` spelling too, in case upstream fixes it their way.
964        let upstream_fixed = OTOOL_LIBTORCH_CPU.replace("/opt/homebrew/opt/libomp/lib/", "@rpath/");
965        assert!(absolute_libomp_refs(&upstream_fixed).is_empty());
966    }
967
968    #[test]
969    fn a_libomp_at_another_absolute_prefix_still_qualifies() {
970        // The Homebrew prefix is not universal (Intel Macs use
971        // /usr/local, and a custom prefix is legal), so the match is on
972        // the library, not on the directory upstream happened to use.
973        let intel = OTOOL_LIBTORCH_CPU.replace("/opt/homebrew/opt", "/usr/local/opt");
974        assert_eq!(
975            absolute_libomp_refs(&intel),
976            vec!["/usr/local/opt/libomp/lib/libomp.dylib".to_string()],
977        );
978    }
979
980    #[test]
981    fn a_dump_with_no_dependencies_yields_nothing() {
982        assert!(absolute_libomp_refs("").is_empty());
983        assert!(absolute_libomp_refs("libomp.dylib:\n").is_empty());
984    }
985
986    #[test]
987    fn force_linux_ignores_the_host() {
988        // The container is Linux whatever the host is, so the docker path
989        // must never pick up a macOS or Windows filename.
990        let url = download_url(&CU128_SPEC, true).unwrap();
991        assert!(url.contains("libtorch-shared-with-deps-"), "got {url}");
992        assert!(!url.contains("-win-"), "got {url}");
993        assert!(!url.contains("macos"), "got {url}");
994    }
995
996    // Variant routing. Asserted through the pure `variant_for_gpus` so no
997    // arm depends on the host's own hardware.
998
999    fn gpu(vendor: GpuVendor, arch: &str) -> system::GpuInfo {
1000        system::GpuInfo {
1001            index: 0,
1002            vendor,
1003            name: format!("test {arch}"),
1004            arch: flodl_hw::GpuArch::parse(vendor, arch)
1005                .unwrap_or_else(|| panic!("unparsable arch {arch}")),
1006            total_memory_mb: 8192,
1007        }
1008    }
1009
1010    #[test]
1011    fn no_gpu_routes_to_cpu() {
1012        assert_eq!(variant_for_gpus(&[]).arch_variant, "cpu");
1013    }
1014
1015    #[test]
1016    fn a_covered_amd_gpu_routes_to_rocm() {
1017        // The bug this guards: a gfx target the ROCm archive ships kernels
1018        // for was routed to the CPU variant, so an AMD box trained on CPU.
1019        // gfx950 (MI350 class) and gfx1150/gfx1151 (Strix APUs) are in the
1020        // archive and were missing from the covered list.
1021        for arch in [
1022            "gfx908", "gfx90a", "gfx942", "gfx950", "gfx1030", "gfx1100", "gfx1151", "gfx1201",
1023        ] {
1024            let v = variant_for_gpus(&[gpu(GpuVendor::Amd, arch)]);
1025            assert_eq!(v.arch_variant, "rocm7.0", "{arch} should route to ROCm");
1026        }
1027    }
1028
1029    #[test]
1030    fn a_perf_db_only_target_is_not_covered() {
1031        // gfx900 and gfx906 appear in the archive with MIOpen performance
1032        // databases and no rocBLAS kernels at all. Calling that "covered"
1033        // admits a box that dies at its first BLAS call instead of being
1034        // told, here, that CPU is what this build can honestly offer.
1035        for arch in ["gfx900", "gfx906"] {
1036            let v = variant_for_gpus(&[gpu(GpuVendor::Amd, arch)]);
1037            assert_eq!(v.arch_variant, "cpu", "{arch} ships no kernels");
1038            assert!(rocm_covered(&[gpu(GpuVendor::Amd, arch)]).is_empty());
1039        }
1040    }
1041
1042    #[test]
1043    fn auto_never_picks_the_newer_rocm_bundle() {
1044        // Deliberate: the host's ROCm loads ahead of the bundle, so the
1045        // oldest offered bundle is the one that serves every 7.x host.
1046        // Reaching 7.1 is an explicit request, not a detection outcome.
1047        for arch in ["gfx942", "gfx950", "gfx1151"] {
1048            assert_eq!(
1049                variant_for_gpus(&[gpu(GpuVendor::Amd, arch)]).arch_variant,
1050                "rocm7.0"
1051            );
1052        }
1053        assert_eq!(resolve_variant(&Variant::Rocm71).arch_variant, "rocm7.1");
1054    }
1055
1056    #[test]
1057    fn an_uncovered_amd_gpu_routes_to_cpu() {
1058        // No bundled Tensile kernels for this target, so ROCm would build
1059        // but not run. Proves the previous test is not vacuously green.
1060        let v = variant_for_gpus(&[gpu(GpuVendor::Amd, "gfx803")]);
1061        assert_eq!(v.arch_variant, "cpu");
1062    }
1063
1064    #[test]
1065    fn a_partly_covered_amd_set_still_routes_to_rocm() {
1066        let v = variant_for_gpus(&[gpu(GpuVendor::Amd, "gfx942"), gpu(GpuVendor::Amd, "gfx803")]);
1067        assert_eq!(v.arch_variant, "rocm7.0");
1068    }
1069
1070    #[test]
1071    fn a_mixed_vendor_box_routes_to_cuda() {
1072        // One libtorch build serves one vendor; NVIDIA is the pick, and
1073        // the AMD device must not drag the result to ROCm or to CPU.
1074        let v = variant_for_gpus(&[
1075            gpu(GpuVendor::Nvidia, "sm_120"),
1076            gpu(GpuVendor::Amd, "gfx1100"),
1077        ]);
1078        assert_eq!(v.arch_variant, "cu128");
1079    }
1080
1081    #[test]
1082    fn rocm_covered_selects_only_supported_amd_devices() {
1083        // The setup wizard routes on this, so it must not count an NVIDIA
1084        // card nor an AMD target the archive ships no kernels for.
1085        let gpus = vec![
1086            gpu(GpuVendor::Nvidia, "sm_120"),
1087            gpu(GpuVendor::Amd, "gfx942"),
1088            gpu(GpuVendor::Amd, "gfx803"),
1089        ];
1090        let covered = rocm_covered(&gpus);
1091        assert_eq!(covered.len(), 1);
1092        assert_eq!(covered[0].arch_label(), "gfx942");
1093    }
1094
1095    #[test]
1096    fn nvidia_routing_is_unchanged() {
1097        assert_eq!(
1098            variant_for_gpus(&[gpu(GpuVendor::Nvidia, "sm_120")]).arch_variant,
1099            "cu128"
1100        );
1101        assert_eq!(
1102            variant_for_gpus(&[gpu(GpuVendor::Nvidia, "sm_61")]).arch_variant,
1103            "cu126"
1104        );
1105    }
1106}