1use 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#[derive(Default)]
33pub enum BuildBackend {
34 #[default]
36 Auto,
37 Docker,
39 Native,
41}
42
43pub struct BuildOpts {
44 pub archs: Option<String>,
47 pub max_jobs: usize,
49 pub dry_run: bool,
51 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
66fn 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 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
101struct 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
148fn 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 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
212pub fn run(opts: BuildOpts) -> Result<(), String> {
217 let ctx = Context::resolve();
218
219 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 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 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 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 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
303fn 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 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 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 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
379fn 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 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 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 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 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 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 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 let dst_lib = Path::new(install_path).join("lib");
491 bundle_system_cudnn(&dst_lib);
492
493 Ok(())
494}
495
496fn 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 if !name.starts_with("libcudnn") {
523 continue;
524 }
525 if !name.contains(".so") {
526 continue;
527 }
528 let dst = dst_lib.join(name);
529 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 #[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 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}