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