1use 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#[derive(Default)]
38pub enum BuildBackend {
39 #[default]
41 Auto,
42 Docker,
44 Native,
46}
47
48pub struct BuildOpts {
49 pub archs: Option<String>,
52 pub max_jobs: usize,
54 pub dry_run: bool,
56 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
71fn 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 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
107struct 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
164fn 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 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
226pub fn run(opts: BuildOpts) -> Result<(), String> {
231 let ctx = Context::resolve();
232
233 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 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 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 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 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 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
323fn 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 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 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 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
397fn 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 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 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 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 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 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 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 let dst_lib = Path::new(install_path).join("lib");
521 bundle_system_cudnn(&dst_lib);
522
523 Ok(())
524}
525
526fn 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 if !name.starts_with("libcudnn") {
553 continue;
554 }
555 if !name.contains(".so") {
556 continue;
557 }
558 let dst = dst_lib.join(name);
559 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 #[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 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}