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 crate::context::Context;
7use crate::util::http;
8use crate::util::archive;
9use crate::util::system;
10use super::detect;
11
12// ---------------------------------------------------------------------------
13// Constants
14// ---------------------------------------------------------------------------
15
16const LIBTORCH_VERSION: &str = "2.10.0";
17
18/// Pre-built variant metadata.
19struct VariantSpec {
20    /// Label for display (e.g. "CUDA 12.8").
21    label: &'static str,
22    /// Directory name under precompiled/ (e.g. "cu128").
23    dir_name: &'static str,
24    /// Value for .arch `cuda=` field.
25    arch_cuda: &'static str,
26    /// Space-separated compute capabilities covered.
27    arch_archs: &'static str,
28    /// Value for .arch `variant=` field.
29    arch_variant: &'static str,
30}
31
32const CPU_SPEC: VariantSpec = VariantSpec {
33    label: "CPU",
34    dir_name: "cpu",
35    arch_cuda: "none",
36    arch_archs: "cpu",
37    arch_variant: "cpu",
38};
39
40const CU126_SPEC: VariantSpec = VariantSpec {
41    label: "CUDA 12.6",
42    dir_name: "cu126",
43    arch_cuda: "12.6",
44    arch_archs: "5.0 5.2 6.0 6.1 7.0 7.5 8.0 8.6 8.9 9.0",
45    arch_variant: "cu126",
46};
47
48const CU128_SPEC: VariantSpec = VariantSpec {
49    label: "CUDA 12.8",
50    dir_name: "cu128",
51    arch_cuda: "12.8",
52    arch_archs: "7.0 7.5 8.0 8.6 8.9 9.0 12.0",
53    arch_variant: "cu128",
54};
55
56// ---------------------------------------------------------------------------
57// Download options
58// ---------------------------------------------------------------------------
59
60pub enum Variant {
61    Cpu,
62    Cuda126,
63    Cuda128,
64    Auto,
65}
66
67pub struct DownloadOpts {
68    pub variant: Variant,
69    pub custom_path: Option<PathBuf>,
70    pub activate: bool,
71    pub dry_run: bool,
72    /// Force the Linux x86_64 build regardless of host OS. Set when the
73    /// libtorch will be consumed inside a Linux Docker container rather
74    /// than linked against host cargo — without this, macOS hosts pick
75    /// `libtorch-macos-arm64-*.zip` (Mach-O dylibs) which then fail to
76    /// load inside the Linux container that bind-mounts the directory.
77    pub force_linux: bool,
78}
79
80impl Default for DownloadOpts {
81    fn default() -> Self {
82        Self {
83            variant: Variant::Auto,
84            custom_path: None,
85            activate: true,
86            dry_run: false,
87            force_linux: false,
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// URL construction
94// ---------------------------------------------------------------------------
95
96fn download_url(spec: &VariantSpec, force_linux: bool) -> Result<String, String> {
97    // `force_linux` short-circuits host detection: the binary is destined
98    // for a Linux Docker container, so we always want the Linux x86_64
99    // build regardless of what the host is.
100    let (os, arch) = if force_linux {
101        ("linux", "x86_64")
102    } else {
103        (std::env::consts::OS, std::env::consts::ARCH)
104    };
105
106    match (os, arch) {
107        ("linux", "x86_64") => {}
108        ("macos", "aarch64") => {
109            if spec.arch_cuda != "none" {
110                return Err("macOS only supports CPU libtorch".into());
111            }
112        }
113        ("macos", _) => {
114            return Err(format!(
115                "macOS libtorch requires Apple Silicon (arm64), got {}.\n\
116                 macOS x86_64 was dropped after PyTorch 2.2.",
117                arch
118            ));
119        }
120        ("windows", "x86_64") => {}
121        _ => {
122            return Err(format!(
123                "Unsupported platform: {} {}.\n\
124                 libtorch is available for Linux x86_64, macOS arm64, and Windows x86_64.",
125                os, arch
126            ));
127        }
128    }
129
130    // macOS ARM has a different filename pattern
131    if os == "macos" {
132        return Ok(format!(
133            "https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-{}.zip",
134            LIBTORCH_VERSION
135        ));
136    }
137
138    // Linux and Windows use the same URL pattern
139    let filename = match spec.arch_variant {
140        "cpu" => format!(
141            "libtorch-shared-with-deps-{}%2Bcpu.zip",
142            LIBTORCH_VERSION
143        ),
144        variant => format!(
145            "libtorch-shared-with-deps-{}%2B{}.zip",
146            LIBTORCH_VERSION, variant
147        ),
148    };
149
150    let bucket = spec.arch_variant; // "cpu", "cu126", "cu128"
151    Ok(format!(
152        "https://download.pytorch.org/libtorch/{}/{}",
153        bucket, filename
154    ))
155}
156
157// ---------------------------------------------------------------------------
158// Auto-detection
159// ---------------------------------------------------------------------------
160
161fn auto_detect_variant() -> &'static VariantSpec {
162    let gpus = system::detect_gpus();
163    if gpus.is_empty() {
164        println!("  No NVIDIA GPU detected. Using CPU variant.");
165        return &CPU_SPEC;
166    }
167
168    // Find lowest and highest major compute capability
169    let lo_major = gpus.iter().map(|g| g.sm_major).min().unwrap_or(0);
170    let hi_major = gpus.iter().map(|g| g.sm_major).max().unwrap_or(0);
171
172    // cu128 requires Volta+ (sm_70+), cu126 supports down to sm_50
173    if lo_major >= 7 {
174        println!("  Detected Volta+ GPU(s). Using cu128.");
175        &CU128_SPEC
176    } else if hi_major >= 10 {
177        // Mixed: old + new GPUs. cu126 covers the old ones, cu128 covers the new.
178        // Default to cu126 which covers more architectures.
179        println!(
180            "  Mixed GPU architectures (sm_{}.x to sm_{}.x).",
181            lo_major, hi_major
182        );
183        println!("  Using cu126 (broadest pre-Volta coverage).");
184        println!("  For all GPUs, consider: fdl libtorch build");
185        &CU126_SPEC
186    } else {
187        println!("  Detected pre-Volta GPU(s). Using cu126.");
188        &CU126_SPEC
189    }
190}
191
192fn resolve_variant(variant: &Variant) -> &'static VariantSpec {
193    match variant {
194        Variant::Cpu => &CPU_SPEC,
195        Variant::Cuda126 => &CU126_SPEC,
196        Variant::Cuda128 => &CU128_SPEC,
197        Variant::Auto => auto_detect_variant(),
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Core download logic
203// ---------------------------------------------------------------------------
204
205pub fn run(opts: DownloadOpts) -> Result<(), String> {
206    let ctx = Context::resolve();
207    run_with_context(opts, &ctx)
208}
209
210/// Run with an explicit context (used by `setup` which has its own context).
211pub fn run_with_context(opts: DownloadOpts, ctx: &Context) -> Result<(), String> {
212    let spec = resolve_variant(&opts.variant);
213    let url = download_url(spec, opts.force_linux)?;
214
215    // Determine install path
216    let install_path = if let Some(ref p) = opts.custom_path {
217        p.clone()
218    } else {
219        ctx.root.join(format!("libtorch/precompiled/{}", spec.dir_name))
220    };
221
222    let variant_id = format!("precompiled/{}", spec.dir_name);
223
224    println!();
225    println!("  libtorch {} ({})", LIBTORCH_VERSION, spec.label);
226    println!("  URL:  {}", url);
227    println!("  Path: {}", install_path.display());
228
229    if opts.dry_run {
230        println!();
231        println!("  [dry-run] Would download and extract to above path.");
232        return Ok(());
233    }
234
235    // Check existing installation
236    if install_path.exists() {
237        let build_ver_path = install_path.join("build-version");
238        let existing_ver = fs::read_to_string(&build_ver_path)
239            .ok()
240            .map(|s| s.trim().to_string());
241
242        // build-version may contain variant suffix (e.g. "2.10.0+cpu")
243        let ver_matches = existing_ver.as_deref().is_some_and(|v| {
244            v == LIBTORCH_VERSION || v.starts_with(&format!("{}+", LIBTORCH_VERSION))
245        });
246
247        if ver_matches {
248            println!();
249            println!("  Already installed (version {}).", LIBTORCH_VERSION);
250            return Ok(());
251        }
252
253        println!();
254        println!(
255            "  Removing existing installation (version: {})...",
256            existing_ver.as_deref().unwrap_or("unknown")
257        );
258        fs::remove_dir_all(&install_path)
259            .map_err(|e| format!("cannot remove {}: {}", install_path.display(), e))?;
260    }
261
262    // Download to temp file
263    let tmp_dir = std::env::temp_dir();
264    let tmp_zip = tmp_dir.join(format!("libtorch-{}-{}.zip", spec.dir_name, LIBTORCH_VERSION));
265
266    println!();
267    println!("  Downloading...");
268    http::download_file(&url, &tmp_zip)?;
269
270    // Extract to temp directory (zip contains a top-level "libtorch/" dir)
271    let tmp_extract = tmp_dir.join(format!("libtorch-extract-{}", std::process::id()));
272    println!("  Extracting...");
273    archive::extract_zip(&tmp_zip, &tmp_extract)?;
274
275    // Move extracted contents to target path
276    let extracted_lt = tmp_extract.join("libtorch");
277    let source = if extracted_lt.is_dir() {
278        &extracted_lt
279    } else {
280        &tmp_extract
281    };
282
283    fs::create_dir_all(&install_path)
284        .map_err(|e| format!("cannot create {}: {}", install_path.display(), e))?;
285
286    // Move all files from extracted dir to install path
287    move_contents(source, &install_path)?;
288
289    // Cleanup temp files
290    let _ = fs::remove_file(&tmp_zip);
291    let _ = fs::remove_dir_all(&tmp_extract);
292
293    // Verify
294    let lib_dir = install_path.join("lib");
295    let has_lib = lib_dir.join("libtorch.so").exists()
296        || lib_dir.join("libtorch.dylib").exists()
297        || lib_dir.join("torch.lib").exists();
298
299    if !has_lib {
300        return Err(format!(
301            "libtorch library not found at {}.\n\
302             The archive structure may have changed.\n\
303             Check: ls {}",
304            lib_dir.display(),
305            lib_dir.display()
306        ));
307    }
308
309    // Write .arch metadata (always, both project and global)
310    let arch_content = format!(
311        "cuda={}\ntorch={}\narchs={}\nsource=precompiled\nvariant={}\n",
312        spec.arch_cuda, LIBTORCH_VERSION, spec.arch_archs, spec.arch_variant
313    );
314    fs::write(install_path.join(".arch"), arch_content)
315        .map_err(|e| format!("cannot write .arch: {}", e))?;
316
317    if opts.activate {
318        detect::set_active(&ctx.root, &variant_id)?;
319    }
320
321    println!();
322    println!("  ================================================");
323    println!("  libtorch {} ({}) installed", LIBTORCH_VERSION, spec.label);
324    println!("  {}", install_path.display());
325    println!("  ================================================");
326
327    if ctx.is_project {
328        println!();
329        println!("  .arch:   {}/.arch", install_path.display());
330        if opts.activate {
331            println!("  .active: libtorch/.active -> {}", variant_id);
332        }
333        println!();
334        if spec.arch_cuda != "none" {
335            println!("  Run 'fdl cuda-test' to verify.");
336        } else {
337            println!("  Run 'fdl test' to verify.");
338        }
339    } else {
340        println!();
341        println!("  Installed to: {}", install_path.display());
342        println!();
343        println!("  To use with tch-rs or flodl, add to your shell profile:");
344        println!();
345        println!("    export LIBTORCH=\"{}\"", install_path.display());
346        println!(
347            "    export LD_LIBRARY_PATH=\"{}/lib${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"",
348            install_path.display()
349        );
350        println!();
351        println!("  Or start a new floDl project:");
352        println!("    fdl init my-project");
353    }
354
355    Ok(())
356}
357
358// ---------------------------------------------------------------------------
359// Helpers
360// ---------------------------------------------------------------------------
361
362/// Move all files and directories from `src` into `dest`.
363fn move_contents(src: &Path, dest: &Path) -> Result<(), String> {
364    let entries = fs::read_dir(src)
365        .map_err(|e| format!("cannot read {}: {}", src.display(), e))?;
366
367    for entry in entries {
368        let entry = entry.map_err(|e| format!("read_dir error: {}", e))?;
369        let from = entry.path();
370        let name = entry.file_name();
371        let to = dest.join(&name);
372
373        // Try rename first (fast, same filesystem). Fall back to copy.
374        if fs::rename(&from, &to).is_err() {
375            if from.is_dir() {
376                copy_dir_recursive(&from, &to)?;
377            } else {
378                fs::copy(&from, &to)
379                    .map_err(|e| format!("copy {} -> {}: {}", from.display(), to.display(), e))?;
380            }
381        }
382    }
383    Ok(())
384}
385
386fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {
387    fs::create_dir_all(dest)
388        .map_err(|e| format!("cannot create {}: {}", dest.display(), e))?;
389
390    for entry in fs::read_dir(src).map_err(|e| format!("read {}: {}", src.display(), e))? {
391        let entry = entry.map_err(|e| format!("read_dir error: {}", e))?;
392        let from = entry.path();
393        let to = dest.join(entry.file_name());
394
395        if from.is_dir() {
396            copy_dir_recursive(&from, &to)?;
397        } else {
398            fs::copy(&from, &to)
399                .map_err(|e| format!("copy {} -> {}: {}", from.display(), to.display(), e))?;
400        }
401    }
402    Ok(())
403}
404
405/// Get the current libtorch version constant (for display and checks).
406#[allow(dead_code)]
407pub fn libtorch_version() -> &'static str {
408    LIBTORCH_VERSION
409}