Skip to main content

flodl_cli/libtorch/
build.rs

1//! `fdl libtorch build` -- compile libtorch from PyTorch source.
2//!
3//! Two backends: Docker (isolated, reproducible) or native (faster, requires
4//! CUDA toolkit + build tools on host). Auto-detects available backends and
5//! asks the user when both are present.
6
7use std::fs;
8use std::io::Write;
9use std::path::Path;
10use std::process::{Command, Stdio};
11
12use super::detect;
13use crate::context::Context;
14use crate::util::docker;
15use crate::util::prompt;
16use crate::util::system;
17
18const DOCKERFILE_CONTENT: &str = include_str!("../../assets/Dockerfile.cuda.source");
19const IMAGE_NAME: &str = "flodl-libtorch-builder";
20const LIBTORCH_VERSION: &str = "2.10.0";
21const PYTORCH_VERSION: &str = "v2.10.0";
22
23const PYTHON_DEPS: &[&str] = &[
24    "typing_extensions",
25    "pyyaml",
26    "filelock",
27    "jinja2",
28    "networkx",
29    "sympy",
30    "packaging",
31];
32
33// ---------------------------------------------------------------------------
34// Options
35// ---------------------------------------------------------------------------
36
37#[derive(Default)]
38pub enum BuildBackend {
39    /// Auto-detect: ask user if both available, otherwise use whatever works.
40    #[default]
41    Auto,
42    /// Force Docker build.
43    Docker,
44    /// Force native build (no Docker).
45    Native,
46}
47
48pub struct BuildOpts {
49    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
50    /// None = auto-detect from GPUs.
51    pub archs: Option<String>,
52    /// Override MAX_JOBS for compilation. Default: 6.
53    pub max_jobs: usize,
54    /// Print what would happen without building.
55    pub dry_run: bool,
56    /// Which backend to use.
57    pub backend: BuildBackend,
58}
59
60impl Default for BuildOpts {
61    fn default() -> Self {
62        Self {
63            archs: None,
64            max_jobs: 6,
65            dry_run: false,
66            backend: BuildBackend::Auto,
67        }
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Auto-detect GPU architectures
73// ---------------------------------------------------------------------------
74
75fn detect_arch_list() -> Result<String, String> {
76    let gpus = system::detect_gpus();
77    if gpus.is_empty() {
78        return Err("No NVIDIA GPUs detected.\n\
79             Source builds require GPUs to auto-detect architectures.\n\
80             Use --archs to specify manually (e.g. --archs \"8.6;12.0\")."
81            .into());
82    }
83
84    // Collect unique compute capabilities, sorted numerically. This
85    // list feeds nvcc's TORCH_CUDA_ARCH_LIST, so it is NVIDIA-only by
86    // construction: a non-NVIDIA device contributes no capability and
87    // is skipped rather than defaulted to one.
88    let mut caps: Vec<(u32, u32)> = gpus
89        .iter()
90        .filter_map(|g| Some((g.sm_major()?, g.sm_minor()?)))
91        .collect();
92    caps.sort();
93    caps.dedup();
94    let caps: Vec<String> = caps
95        .iter()
96        .map(|(ma, mi)| format!("{}.{}", ma, mi))
97        .collect();
98
99    println!("  GPUs detected:");
100    for g in &gpus {
101        println!("    [{}] {} ({})", g.index, g.short_name(), g.arch_label());
102    }
103
104    Ok(caps.join(";"))
105}
106
107// ---------------------------------------------------------------------------
108// Native toolchain detection
109// ---------------------------------------------------------------------------
110
111struct NativeTools {
112    nvcc: bool,
113    cmake: bool,
114    python3: bool,
115    git: bool,
116    gcc: bool,
117}
118
119impl NativeTools {
120    fn detect() -> Self {
121        Self {
122            nvcc: has_tool("nvcc"),
123            cmake: has_tool("cmake"),
124            python3: has_tool("python3"),
125            git: has_tool("git"),
126            gcc: has_tool("gcc") || has_tool("cc"),
127        }
128    }
129
130    fn ready(&self) -> bool {
131        self.nvcc && self.cmake && self.python3 && self.git && self.gcc
132    }
133
134    fn missing(&self) -> Vec<&'static str> {
135        let mut m = Vec::new();
136        if !self.nvcc {
137            m.push("nvcc (CUDA toolkit)");
138        }
139        if !self.cmake {
140            m.push("cmake");
141        }
142        if !self.python3 {
143            m.push("python3");
144        }
145        if !self.git {
146            m.push("git");
147        }
148        if !self.gcc {
149            m.push("gcc/cc (C++ compiler)");
150        }
151        m
152    }
153}
154
155fn has_tool(name: &str) -> bool {
156    Command::new(name)
157        .arg("--version")
158        .stdout(Stdio::null())
159        .stderr(Stdio::null())
160        .status()
161        .is_ok_and(|s| s.success())
162}
163
164// ---------------------------------------------------------------------------
165// Backend selection
166// ---------------------------------------------------------------------------
167
168fn select_backend(backend: &BuildBackend) -> Result<&'static str, String> {
169    let has_docker = docker::has_docker();
170    let native = NativeTools::detect();
171
172    match backend {
173        BuildBackend::Docker => {
174            if !has_docker {
175                return Err("Docker was requested but is not available.\n\
176                     Install Docker: https://docs.docker.com/engine/install/"
177                    .into());
178            }
179            Ok("docker")
180        }
181        BuildBackend::Native => {
182            if !native.ready() {
183                let missing = native.missing();
184                return Err(format!(
185                    "Native build was requested but these tools are missing:\n  {}\n\n\
186                     Install them or use --docker instead.",
187                    missing.join("\n  ")
188                ));
189            }
190            Ok("native")
191        }
192        BuildBackend::Auto => {
193            if has_docker && native.ready() {
194                // Both available, ask the user
195                println!();
196                println!("  Both Docker and native toolchains are available.");
197                println!();
198                let choice = prompt::ask_choice(
199                    "  Build method",
200                    &[
201                        "Docker (isolated, reproducible, resumes via layer cache)",
202                        "Native (faster, uses your host CUDA toolkit directly)",
203                    ],
204                    1,
205                );
206                Ok(if choice == 2 { "native" } else { "docker" })
207            } else if has_docker {
208                println!("  Using Docker (native toolchain not complete).");
209                Ok("docker")
210            } else if native.ready() {
211                println!("  Using native build (Docker not available).");
212                Ok("native")
213            } else {
214                let missing = native.missing();
215                Err(format!(
216                    "Cannot build libtorch. Need either:\n\n\
217                     \x20 Docker: https://docs.docker.com/engine/install/\n\n\
218                     Or native tools (missing: {})",
219                    missing.join(", ")
220                ))
221            }
222        }
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Entry point
228// ---------------------------------------------------------------------------
229
230pub fn run(opts: BuildOpts) -> Result<(), String> {
231    let ctx = Context::resolve();
232
233    // Determine architectures
234    let archs = match &opts.archs {
235        Some(a) => {
236            println!("  Using specified architectures: {}", a);
237            a.clone()
238        }
239        None => detect_arch_list()?,
240    };
241
242    let arch_dir = system::arch_dir_name(&archs);
243    let install_path = ctx.root.join(format!("libtorch/builds/{}", arch_dir));
244    let variant_id = format!("builds/{}", arch_dir);
245
246    // Select backend
247    let backend = select_backend(&opts.backend)?;
248
249    println!();
250    println!("  libtorch source build");
251    println!("  Archs:   {}", archs);
252    println!("  Output:  {}", install_path.display());
253    println!("  Jobs:    {}", opts.max_jobs);
254    println!("  Method:  {}", backend);
255    println!();
256
257    if opts.dry_run {
258        println!(
259            "  [dry-run] Would build libtorch from source via {}.",
260            backend
261        );
262        println!("  This typically takes 2-6 hours depending on CPU cores.");
263        return Ok(());
264    }
265
266    println!("  This will take 2-6 hours. You can safely Ctrl-C and resume later.");
267    println!();
268
269    let install_str = install_path.to_str().unwrap_or("libtorch/builds");
270    match backend {
271        "docker" => build_docker(&archs, install_str, opts.max_jobs)?,
272        "native" => build_native(&archs, install_str, &ctx, opts.max_jobs)?,
273        _ => unreachable!(),
274    }
275
276    // Verify
277    let lib_dir = install_path.join("lib");
278    if !lib_dir.join("libtorch.so").exists() && !lib_dir.join("libtorch.dylib").exists() {
279        return Err(format!(
280            "libtorch library not found at {}.\n\
281             The build may have failed silently.",
282            lib_dir.display()
283        ));
284    }
285
286    // Write .arch metadata
287    let arch_spaces = archs.replace(';', " ");
288    let arch_content = format!(
289        "cuda=12.8\ntorch={}\narchs={}\nsource=compiled\n",
290        LIBTORCH_VERSION, arch_spaces
291    );
292    fs::write(install_path.join(".arch"), arch_content)
293        .map_err(|e| format!("cannot write .arch: {}", e))?;
294
295    // Set as active
296    detect::set_active(&ctx.root, &variant_id)?;
297
298    println!();
299    println!("  ================================================");
300    println!("  libtorch {} (source build) complete!", LIBTORCH_VERSION);
301    println!("  Archs:  {}", arch_spaces);
302    println!("  Path:   {}", install_path.display());
303    println!("  Active: {}", variant_id);
304    println!("  ================================================");
305    println!();
306    if ctx.is_project {
307        println!("  Run 'fdl gpu-test' to verify.");
308    } else {
309        println!("  To use, add to your shell profile:");
310        println!("    export LIBTORCH=\"{}\"", install_path.display());
311        // Shared recipe. A source build can be AMD too -- `arch_dir_name`
312        // maps a `gfx…` arch list to a `gfx…` variant directory, which
313        // `variant_vendor` then reads as AMD.
314        let lib = format!("{}/lib", install_path.display());
315        for line in detect::ld_library_path_lines(detect::variant_vendor(&variant_id), &lib) {
316            println!("    {line}");
317        }
318    }
319
320    Ok(())
321}
322
323// ---------------------------------------------------------------------------
324// Docker backend
325// ---------------------------------------------------------------------------
326
327fn build_docker(archs: &str, install_path: &str, max_jobs: usize) -> Result<(), String> {
328    println!("  Docker layer caching means restarting picks up where it left off.");
329    println!();
330
331    // Write Dockerfile to temp location
332    let tmp_dir = std::env::temp_dir();
333    let dockerfile_path = tmp_dir.join("flodl-libtorch-builder.Dockerfile");
334    {
335        let mut f = fs::File::create(&dockerfile_path)
336            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
337        f.write_all(DOCKERFILE_CONTENT.as_bytes())
338            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
339    }
340
341    // Build the Docker image
342    println!("  Building Docker image...");
343    let status = docker::docker_run(&[
344        "build",
345        "-t",
346        IMAGE_NAME,
347        "--build-arg",
348        &format!("TORCH_CUDA_ARCH_LIST={}", archs),
349        "--build-arg",
350        &format!("MAX_JOBS={}", max_jobs),
351        "-f",
352        dockerfile_path.to_str().ok_or("temp path not UTF-8")?,
353        ".",
354    ])?;
355
356    let _ = fs::remove_file(&dockerfile_path);
357
358    if !status.success() {
359        return Err(format!(
360            "Docker build failed (exit code {}).\n\
361             Check the output above for errors.\n\
362             You can re-run this command to resume (Docker caches completed layers).",
363            status.code().unwrap_or(-1)
364        ));
365    }
366
367    // Extract libtorch from the builder image
368    println!();
369    println!("  Extracting libtorch from builder image...");
370
371    let container_out = docker::docker_output(&["create", IMAGE_NAME])?;
372    if !container_out.status.success() {
373        return Err("failed to create container from builder image".into());
374    }
375    let container_id = String::from_utf8_lossy(&container_out.stdout)
376        .trim()
377        .to_string();
378
379    fs::create_dir_all(install_path)
380        .map_err(|e| format!("cannot create {}: {}", install_path, e))?;
381
382    let cp_status = docker::docker_run(&[
383        "cp",
384        &format!("{}:/usr/local/libtorch/.", container_id),
385        install_path,
386    ])?;
387
388    let _ = docker::docker_output(&["rm", &container_id]);
389
390    if !cp_status.success() {
391        return Err("failed to extract libtorch from builder container".into());
392    }
393
394    Ok(())
395}
396
397// ---------------------------------------------------------------------------
398// Native backend
399// ---------------------------------------------------------------------------
400
401fn build_native(
402    archs: &str,
403    install_path: &str,
404    ctx: &Context,
405    max_jobs: usize,
406) -> Result<(), String> {
407    let build_dir = ctx.root.join("libtorch/.build-cache/pytorch");
408
409    // Clone PyTorch if not cached
410    if !build_dir.join(".git").exists() {
411        println!("  Cloning PyTorch {}...", PYTORCH_VERSION);
412        fs::create_dir_all(ctx.root.join("libtorch/.build-cache"))
413            .map_err(|e| format!("cannot create build cache: {}", e))?;
414
415        let status = Command::new("git")
416            .args([
417                "clone",
418                "--depth",
419                "1",
420                "--branch",
421                PYTORCH_VERSION,
422                "--recurse-submodules",
423                "--shallow-submodules",
424                "https://github.com/pytorch/pytorch.git",
425                build_dir.to_str().ok_or("path not UTF-8")?,
426            ])
427            .stdout(Stdio::inherit())
428            .stderr(Stdio::inherit())
429            .status()
430            .map_err(|e| format!("failed to run git: {}", e))?;
431
432        if !status.success() {
433            // Clean up failed clone
434            let _ = fs::remove_dir_all(build_dir);
435            return Err("git clone failed. Check your network connection.".into());
436        }
437    } else {
438        println!("  Using cached PyTorch source at {}", build_dir.display());
439    }
440
441    // Install Python dependencies
442    println!("  Checking Python dependencies...");
443    let pip_status = Command::new("pip3")
444        .args(["install", "--quiet"])
445        .args(PYTHON_DEPS)
446        .stdout(Stdio::inherit())
447        .stderr(Stdio::inherit())
448        .status();
449
450    // Try --break-system-packages if the first attempt fails (Ubuntu 24.04+)
451    if pip_status.is_err() || !pip_status.unwrap().success() {
452        let _ = Command::new("pip3")
453            .args(["install", "--quiet", "--break-system-packages"])
454            .args(PYTHON_DEPS)
455            .stdout(Stdio::inherit())
456            .stderr(Stdio::inherit())
457            .status();
458    }
459
460    // Build libtorch
461    println!(
462        "  Building libtorch (TORCH_CUDA_ARCH_LIST=\"{}\", MAX_JOBS={})...",
463        archs, max_jobs
464    );
465    println!();
466
467    let status = Command::new("python3")
468        .arg("tools/build_libtorch.py")
469        .current_dir(&build_dir)
470        .env("TORCH_CUDA_ARCH_LIST", archs)
471        .env("USE_CUDA", "1")
472        .env("USE_CUDNN", "1")
473        .env("USE_NCCL", "1")
474        .env("USE_DISTRIBUTED", "1")
475        .env("BUILD_SHARED_LIBS", "ON")
476        .env("CMAKE_BUILD_TYPE", "Release")
477        .env("MAX_JOBS", max_jobs.to_string())
478        .env("BUILD_PYTHON", "OFF")
479        .env("BUILD_TEST", "OFF")
480        .env("BUILD_CAFFE2", "OFF")
481        .stdout(Stdio::inherit())
482        .stderr(Stdio::inherit())
483        .status()
484        .map_err(|e| format!("failed to run build_libtorch.py: {}", e))?;
485
486    if !status.success() {
487        return Err(format!(
488            "Native build failed (exit code {}).\n\
489             Check the output above for errors.\n\
490             The PyTorch source is cached at {} -- re-running will skip the clone.",
491            status.code().unwrap_or(-1),
492            build_dir.display()
493        ));
494    }
495
496    // Copy output to install path
497    println!();
498    println!("  Packaging libtorch to {}...", install_path);
499
500    let torch_dir = build_dir.join("torch");
501    fs::create_dir_all(install_path)
502        .map_err(|e| format!("cannot create {}: {}", install_path, e))?;
503
504    for subdir in ["lib", "include", "share"] {
505        let src = torch_dir.join(subdir);
506        let dst = Path::new(install_path).join(subdir);
507        if src.is_dir() {
508            copy_dir_recursive(&src, &dst)?;
509        }
510    }
511
512    // Bundle cuDNN: copy system cuDNN libs into the install path so
513    // deploys to hosts without a system cuDNN install (e.g. a bare
514    // VM guest) can still find libcudnn_graph.so.9 and the
515    // other sub-libs via libtorch's lib dir on LD_LIBRARY_PATH.
516    // Best-effort on the native path: walks common cuDNN install
517    // prefixes and copies the first match it finds. Silently skipped
518    // when system cuDNN isn't present (Docker path bundles them via
519    // Dockerfile.cuda.source).
520    let dst_lib = Path::new(install_path).join("lib");
521    bundle_system_cudnn(&dst_lib);
522
523    Ok(())
524}
525
526/// Locate system cuDNN libs (libcudnn.so.9 + sub-libs) and copy them
527/// into `dst_lib`. Walks common install prefixes; copies every file
528/// matching `libcudnn*.so*` from the first prefix that contains any.
529/// Returns silently (best-effort) — the Docker build bundles via
530/// Dockerfile, so failure here only affects the rarer native path.
531fn bundle_system_cudnn(dst_lib: &Path) {
532    let candidates = [
533        "/usr/lib/x86_64-linux-gnu",
534        "/usr/local/cuda/lib64",
535        "/usr/local/lib",
536    ];
537    for prefix in candidates {
538        let dir = Path::new(prefix);
539        let entries = match fs::read_dir(dir) {
540            Ok(e) => e,
541            Err(_) => continue,
542        };
543        let mut copied = 0usize;
544        for entry in entries.flatten() {
545            let path = entry.path();
546            let name = match path.file_name().and_then(|n| n.to_str()) {
547                Some(n) => n,
548                None => continue,
549            };
550            // Match libcudnn.so* and libcudnn_*.so* but skip static
551            // archives (.a) and headers.
552            if !name.starts_with("libcudnn") {
553                continue;
554            }
555            if !name.contains(".so") {
556                continue;
557            }
558            let dst = dst_lib.join(name);
559            // `fs::copy` resolves symlinks → loses the SONAME chain
560            // PyTorch's loader walks. Re-create symlinks; fall back to
561            // copy for real files.
562            let meta = match fs::symlink_metadata(&path) {
563                Ok(m) => m,
564                Err(_) => continue,
565            };
566            if meta.file_type().is_symlink() {
567                let _ = fs::remove_file(&dst);
568                // Re-create the link on unix (see the SONAME note above).
569                // Non-unix has no unprivileged symlink — and this scan is
570                // Linux-shaped anyway — so degrade to a resolving copy;
571                // the gate is about compiling on Windows, not behavior.
572                #[cfg(unix)]
573                if let Ok(target) = fs::read_link(&path)
574                    && std::os::unix::fs::symlink(&target, &dst).is_ok()
575                {
576                    copied += 1;
577                }
578                #[cfg(not(unix))]
579                if fs::copy(&path, &dst).is_ok() {
580                    copied += 1;
581                }
582            } else if fs::copy(&path, &dst).is_ok() {
583                copied += 1;
584            }
585        }
586        if copied > 0 {
587            println!("  Bundled {copied} cuDNN file(s) from {prefix} into the libtorch install",);
588            return;
589        }
590    }
591    // No cuDNN found on the build host. Not fatal — most users build
592    // with cuDNN present, and the Docker path bundles regardless. Note
593    // the gap so it's visible if a deploy later fails to find cuDNN.
594    println!(
595        "  Note: no system cuDNN detected in /usr/lib/x86_64-linux-gnu, \
596         /usr/local/cuda/lib64, or /usr/local/lib; install path will \
597         rely on the target host providing cuDNN at runtime.",
598    );
599}
600
601fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {
602    fs::create_dir_all(dest).map_err(|e| format!("cannot create {}: {}", dest.display(), e))?;
603
604    for entry in fs::read_dir(src).map_err(|e| format!("read {}: {}", src.display(), e))? {
605        let entry = entry.map_err(|e| format!("read_dir error: {}", e))?;
606        let from = entry.path();
607        let to = dest.join(entry.file_name());
608
609        if from.is_dir() {
610            copy_dir_recursive(&from, &to)?;
611        } else {
612            fs::copy(&from, &to)
613                .map_err(|e| format!("copy {} -> {}: {}", from.display(), to.display(), e))?;
614        }
615    }
616    Ok(())
617}