Skip to main content

flodl_cli/
setup.rs

1//! `fdl setup` -- interactive guided setup wizard.
2//!
3//! Detects hardware, downloads libtorch, optionally builds Docker images.
4
5use crate::context::Context;
6use crate::libtorch::{build, detect, download};
7use crate::util::{docker, prompt, requirements, system};
8
9/// The CPU variant's pointer value, as `download` installs it.
10const CPU_VARIANT: &str = "precompiled/cpu";
11
12#[derive(Default)]
13pub struct SetupOpts {
14    /// Skip all prompts, use auto-detected defaults.
15    pub non_interactive: bool,
16    /// Re-download/rebuild even if libtorch exists.
17    pub force: bool,
18}
19
20/// Which libtorch a macOS host in a Docker-mounted project should get.
21///
22/// The libtorch is bind-mounted into a Linux container, so the host's
23/// Mach-O build cannot load there. What to fetch instead depends on the
24/// host arch, and only one of the two cases has an answer upstream.
25#[derive(Debug, PartialEq, Eq, Clone, Copy)]
26enum MacDockerPlan {
27    /// Not macOS, or not a Docker-mounted project: fetch for the host.
28    HostBuild,
29    /// Intel Mac. The container is linux/amd64 and upstream publishes
30    /// Linux x86_64 libtorch, so the container's build can be fetched.
31    ForceLinuxX86,
32    /// Apple Silicon. The container is linux/arm64 and upstream
33    /// publishes no Linux aarch64 libtorch in any variant, so no forced
34    /// download is correct. Fetch the host build (what the guide's
35    /// symlink step expects at `precompiled/cpu`) and name the gap.
36    HostBuildThenManualArm64,
37}
38
39/// Pure so both macOS arms are checkable from any host: the branch is
40/// unreachable on the machine most of this is developed on, and picking
41/// the wrong one installs a libtorch that cannot load in the container.
42fn macos_docker_plan(os: &str, arch: &str, docker_project: bool) -> MacDockerPlan {
43    if os != "macos" || !docker_project {
44        return MacDockerPlan::HostBuild;
45    }
46    match arch {
47        "aarch64" => MacDockerPlan::HostBuildThenManualArm64,
48        _ => MacDockerPlan::ForceLinuxX86,
49    }
50}
51
52pub fn run(opts: SetupOpts) -> Result<(), String> {
53    println!();
54    println!("  floDl Setup");
55    println!("  ===========");
56    println!();
57    println!("  floDl is a Rust deep learning framework built on libtorch");
58    println!("  (PyTorch's C++ backend). This wizard will help you set up");
59    println!("  your development environment.");
60    println!();
61
62    // ---- Step 1: Detect system ----
63
64    println!("  Step 1: Detecting your system");
65    println!("  -----------------------------");
66    println!();
67
68    let cpu = system::cpu_model().unwrap_or_else(|| "Unknown".into());
69    let threads = system::cpu_threads();
70    let ram_gb = system::ram_total_gb();
71    println!("  CPU:    {} ({} threads, {}GB RAM)", cpu, threads, ram_gb);
72
73    let has_docker = docker::has_docker();
74    let has_cargo = system::has_cargo();
75
76    if has_docker {
77        if let Some(v) = system::docker_version() {
78            println!("  Docker: {}", v);
79        } else {
80            println!("  Docker: available");
81        }
82    } else {
83        println!("  Docker: not found");
84    }
85
86    if has_cargo {
87        println!("  Rust:   available");
88    } else {
89        println!("  Rust:   not found");
90    }
91
92    let survey = flodl_hw::survey();
93    let gpus = &survey.devices;
94    if !gpus.is_empty() {
95        println!();
96        println!("  GPUs:");
97        for g in gpus {
98            println!(
99                "    [{}] {} -- {}, {}GB VRAM",
100                g.index,
101                g.name,
102                g.arch_label(),
103                g.total_memory_mb / 1024
104            );
105        }
106    } else {
107        println!();
108        println!("  GPU:    not detected (CPU-only mode)");
109        // The sweep's findings, not just its device list: an AMD card
110        // with no ROCm runtime is a common first-contact state, and
111        // "CPU-only" with the explanation discarded sends the operator
112        // away thinking the box has nothing — setup is the entry point,
113        // so it says what probe would say.
114        for note in survey.notes.iter().filter(|n| n.kind.explains_absence()) {
115            println!("          {}", note.message);
116        }
117    }
118
119    if !has_docker && !has_cargo {
120        println!();
121        println!("  You need at least one of these to continue:");
122        println!();
123        println!("    Rust:   curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
124        println!("    Docker: https://docs.docker.com/engine/install/");
125        println!();
126        println!("  Install one or both and run 'fdl setup' again.");
127        return Err("no Rust or Docker found".into());
128    }
129
130    // Native prerequisites apply only to a native build: the Docker
131    // path carries its own toolchain in the image. Cargo without Docker
132    // is unambiguously native; with both available the path is not yet
133    // chosen, so it is phrased as a note rather than a blocker.
134    let tools = requirements::missing_host_tools();
135    if !tools.is_empty() && has_cargo {
136        let owned: Vec<String> = tools.iter().map(|t| (*t).to_string()).collect();
137        println!();
138        if has_docker {
139            println!(
140                "  Note: building natively would also need: {}",
141                tools.join(", ")
142            );
143            println!("        {}", requirements::install_hint(&owned));
144            println!("        (not needed if you build in the dev container)");
145        } else {
146            println!("  Native builds need these first: {}", tools.join(", "));
147            println!("    {}", requirements::install_hint(&owned));
148        }
149    }
150
151    // ---- Step 2: libtorch ----
152
153    println!();
154    println!("  Step 2: libtorch");
155    println!("  ----------------");
156    println!();
157    println!("  floDl needs libtorch, PyTorch's C++ library.");
158    println!("  This downloads pre-built binaries (~2GB for CUDA, ~200MB for CPU).");
159    println!();
160
161    let ctx = Context::resolve();
162    let root = &ctx.root;
163
164    if !ctx.is_project {
165        println!("  Not inside a floDl project.");
166        println!(
167            "  libtorch will be installed to: {}",
168            ctx.libtorch_dir().display()
169        );
170        println!();
171    }
172
173    let existing = detect::read_active(root);
174    let mut skip_download = false;
175
176    if !opts.force
177        && let Some(ref info) = existing
178    {
179        // The variant PATH carries the vendor, not `.arch`'s `cuda=`
180        // field: a ROCm build has no CUDA toolkit version and writes
181        // `cuda=none` there, exactly like a CPU build. Reading that
182        // field as "is this a GPU install" labelled every existing
183        // ROCm install CPU-only and re-downloaded over it.
184        match detect::variant_vendor(&info.path) {
185            Some(vendor) => {
186                println!("  Found existing {vendor} libtorch: {}", info.path);
187                if opts.non_interactive {
188                    println!("  Keeping existing installation.");
189                    skip_download = true;
190                } else if !prompt::ask_yn("  Download fresh?", false) {
191                    skip_download = true;
192                }
193                println!();
194            }
195            None => println!("  Found existing CPU libtorch."),
196        }
197    }
198
199    if !skip_download {
200        // Always download CPU variant (useful as fallback).
201        let mounted_docker_project = ctx.is_project && ctx.root.join("Dockerfile").exists();
202        let plan = macos_docker_plan(
203            std::env::consts::OS,
204            std::env::consts::ARCH,
205            mounted_docker_project,
206        );
207        let force_linux = plan == MacDockerPlan::ForceLinuxX86;
208        let apple_silicon_docker = plan == MacDockerPlan::HostBuildThenManualArm64;
209        if force_linux {
210            println!("  macOS + Docker-mounted project: fetching Linux libtorch");
211            println!("  for the container (host arch would not load inside Linux).");
212        }
213        println!("  Downloading CPU libtorch...");
214        let cpu_opts = download::DownloadOpts {
215            variant: download::Variant::Cpu,
216            activate: false, // don't activate CPU if we'll also get CUDA
217            force_linux,
218            ..Default::default()
219        };
220        download::run_with_context(cpu_opts, &ctx)?;
221
222        if apple_silicon_docker {
223            println!();
224            println!("  That is the macOS build, for the host. The dev container is");
225            println!("  linux/arm64 and needs Linux aarch64 libtorch, which PyTorch");
226            println!("  does not publish; it has to be extracted from the PyPI wheel.");
227            println!("  Steps 1 and 2 of the Apple Silicon guide do this:");
228            println!("    https://flodl.dev/guide/mac-apple-silicon");
229            println!("  Until then `fdl build` / `fdl test` will not link.");
230        }
231
232        // The variant table below is CUDA-only, so the capability span
233        // is taken over NVIDIA devices; a non-NVIDIA card contributes
234        // none and leaves this branch inert rather than skewing it.
235        let majors: Vec<u32> = gpus.iter().filter_map(|g| g.sm_major()).collect();
236
237        // AMD libtorch. One build serves one vendor, so ROCm is chosen
238        // only where there is no NVIDIA card to prefer; on a mixed box
239        // the CUDA branch below runs instead.
240        let amd: Vec<_> = gpus
241            .iter()
242            .filter(|g| g.vendor == system::GpuVendor::Amd)
243            .collect();
244        if !amd.is_empty() {
245            let covered = download::rocm_covered(gpus);
246            if !majors.is_empty() {
247                println!();
248                println!("  AMD GPU(s) detected alongside NVIDIA. One libtorch build");
249                println!("  serves one vendor, so the NVIDIA cards are set up here.");
250                println!("  For the AMD cards: fdl libtorch download --rocm 7.0");
251            } else if covered.is_empty() {
252                let names: Vec<String> = amd
253                    .iter()
254                    .map(|g| format!("{} ({})", g.short_name(), g.arch_label()))
255                    .collect();
256                println!();
257                println!(
258                    "  AMD GPU(s) detected ({}) outside the ROCm 7.0",
259                    names.join(", ")
260                );
261                println!("  build's targets, so only CPU libtorch is installed.");
262                println!("  Covered targets: {}", download::rocm_archs());
263            } else {
264                println!();
265                println!("  Downloading ROCm libtorch (rocm7.0 for your AMD GPU)...");
266                let rocm_opts = download::DownloadOpts {
267                    variant: download::Variant::Rocm70,
268                    ..Default::default()
269                };
270                download::run_with_context(rocm_opts, &ctx)?;
271            }
272        }
273
274        // CUDA libtorch
275        if !majors.is_empty() {
276            let lo_major = majors.iter().copied().min().unwrap_or(0);
277            let hi_major = majors.iter().copied().max().unwrap_or(0);
278
279            if lo_major < 7 && hi_major >= 10 {
280                // Mixed architectures -- no single prebuilt covers both
281                println!();
282                println!("  Your GPUs span sm_{}.x to sm_{}.x.", lo_major, hi_major);
283                println!("  No pre-built libtorch covers both architectures.");
284                println!();
285
286                // Check for existing source build
287                let has_source_build = detect::list_variants(root)
288                    .iter()
289                    .any(|v| v.starts_with("builds/"));
290
291                if has_source_build {
292                    println!("  Found existing source build in libtorch/builds/.");
293                } else if opts.non_interactive {
294                    println!("  Downloading cu126 (broadest coverage).");
295                    let cuda_opts = download::DownloadOpts {
296                        variant: download::Variant::Cuda126,
297                        ..Default::default()
298                    };
299                    download::run_with_context(cuda_opts, &ctx)?;
300                } else {
301                    let choice = prompt::ask_choice(
302                        "  Choice",
303                        &[
304                            "Build libtorch from source (2-6 hours, covers all GPUs)",
305                            "Download cu128 (Volta+ only, your older GPU won't work)",
306                            "Download cu126 (pre-Volta only, your newer GPU won't work)",
307                            "Skip for now",
308                        ],
309                        4,
310                    );
311
312                    match choice {
313                        1 => {
314                            println!();
315                            println!("  Starting libtorch source build...");
316                            println!("  This will take 2-6 hours. You can safely Ctrl-C and");
317                            println!("  resume later with: fdl libtorch build");
318                            println!();
319                            build::run(build::BuildOpts::default())?;
320                        }
321                        2 => {
322                            println!("  Downloading cu128...");
323                            let cuda_opts = download::DownloadOpts {
324                                variant: download::Variant::Cuda128,
325                                ..Default::default()
326                            };
327                            download::run_with_context(cuda_opts, &ctx)?;
328                        }
329                        3 => {
330                            println!("  Downloading cu126...");
331                            let cuda_opts = download::DownloadOpts {
332                                variant: download::Variant::Cuda126,
333                                ..Default::default()
334                            };
335                            download::run_with_context(cuda_opts, &ctx)?;
336                        }
337                        _ => {
338                            println!("  Skipping CUDA libtorch. You can download later with:");
339                            println!("    fdl libtorch download --cuda 12.8");
340                            println!("    # or build from source:");
341                            println!("    fdl libtorch build");
342                        }
343                    }
344                }
345            } else if lo_major < 7 {
346                println!();
347                println!("  Downloading CUDA libtorch (cu126 for your pre-Volta GPU)...");
348                let cuda_opts = download::DownloadOpts {
349                    variant: download::Variant::Cuda126,
350                    ..Default::default()
351                };
352                download::run_with_context(cuda_opts, &ctx)?;
353            } else {
354                println!();
355                println!("  Downloading CUDA libtorch (cu128 for your Volta+ GPU)...");
356                let cuda_opts = download::DownloadOpts {
357                    variant: download::Variant::Cuda128,
358                    ..Default::default()
359                };
360                download::run_with_context(cuda_opts, &ctx)?;
361            }
362        }
363
364        // The CPU download above deliberately does not activate, so a
365        // GPU variant fetched after it wins the pointer. When no GPU
366        // variant follows -- a CPU-only box, or an AMD card outside the
367        // ROCm build's gfx list -- nothing ever writes `.active` and
368        // setup finishes with libtorch on disk that `fdl diagnose` then
369        // reports as "no active variant". Claim the pointer for CPU
370        // only if it is still unclaimed, so this can never demote a GPU
371        // variant.
372        if detect::read_active(root).is_none() && detect::is_valid_variant(root, CPU_VARIANT) {
373            detect::set_active(root, CPU_VARIANT)?;
374        }
375    }
376
377    // The active variant, resolved ONCE for every consumer below. Both
378    // the vendor and the warning `variant_vendor` emits on an
379    // unrecognised basename belong to the variant, not to each question
380    // asked about it -- re-deriving per call-site printed the warning
381    // four times.
382    let active = detect::read_active(root);
383    let active_vendor = active
384        .as_ref()
385        .and_then(|info| detect::variant_vendor(&info.path));
386    let active_label = |v: Option<system::GpuVendor>| match v {
387        Some(vendor) => vendor.to_string(),
388        None => "CPU".to_string(),
389    };
390
391    // ---- Step 3: Build environment (project-only) ----
392
393    if !ctx.is_project {
394        // Skip Docker image building when running standalone
395        println!();
396        println!("  Setup complete!");
397        println!("  ===============");
398        println!();
399        if let Some(info) = &active {
400            println!(
401                "  libtorch:  {} ({})",
402                info.path,
403                active_label(active_vendor)
404            );
405            println!("  Location:  {}", ctx.libtorch_dir().display());
406        }
407        println!();
408        println!("  Next steps:");
409        println!("    fdl init my-project  # scaffold a new project");
410        println!("    fdl diagnose         # verify GPU compatibility");
411        println!();
412        return Ok(());
413    }
414
415    println!();
416    println!("  Step 3: Build environment");
417    println!("  -------------------------");
418    println!();
419    println!("  floDl compiles Rust code that links against libtorch.");
420    println!("  You can build with Docker (isolated, reproducible) or");
421    println!("  natively (faster iteration, requires Rust + C++ toolchain).");
422    println!();
423
424    let build_mode = if has_docker && has_cargo {
425        if opts.non_interactive {
426            "docker"
427        } else {
428            let choice = prompt::ask_choice(
429                "  Choice",
430                &[
431                    "Docker (recommended) -- isolated, reproducible builds",
432                    "Native -- faster iteration, requires C++ compiler on host",
433                    "Both -- set up Docker and show native instructions",
434                ],
435                1,
436            );
437            match choice {
438                1 => "docker",
439                2 => "native",
440                3 => "both",
441                _ => "docker",
442            }
443        }
444    } else if has_docker {
445        if opts.non_interactive {
446            "docker"
447        } else {
448            println!("  Docker is available. Rust is not installed on this machine.");
449            println!("  Docker is the easiest way to get started (no Rust install needed).");
450            println!();
451            if prompt::ask_yn("  Set up Docker build environment?", true) {
452                "docker"
453            } else {
454                // User declined Docker but has no Rust either. Show the
455                // Rust install pointers and offer one chance to flip back
456                // to Docker before settling on a "none" build mode.
457                println!();
458                println!("  No worries. To build flodl natively you need Rust on the host:");
459                println!();
460                println!("    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
461                println!();
462                println!("  More: https://www.rust-lang.org/tools/install");
463                println!("  Then re-run `fdl setup` and the native path will be picked up.");
464                println!();
465                if prompt::ask_yn("  Or use Docker after all?", false) {
466                    "docker"
467                } else {
468                    "none"
469                }
470            }
471        }
472    } else {
473        println!("  Rust is available. Docker is not installed.");
474        println!("  You can build natively (requires C++ compiler on the host).");
475        println!();
476        "native"
477    };
478
479    // Build Docker images
480    if build_mode == "docker" || build_mode == "both" {
481        println!();
482        println!("  Building Docker images...");
483
484        // Create cargo cache dirs
485        let _ = std::fs::create_dir_all(".cargo-cache");
486        let _ = std::fs::create_dir_all(".cargo-git");
487
488        let status = docker::compose_run(".", &["build", "dev"])?;
489        if !status.success() {
490            println!("  Warning: CPU Docker image build failed.");
491        }
492
493        // GPU image, when there is hardware AND a GPU libtorch to link
494        // against. The compose service is SELECTED from the variant's
495        // vendor rather than hardcoded: a CUDA image and a ROCm image are
496        // genuinely different artifacts (different base, different device
497        // nodes), so building `cuda` on an AMD box builds the wrong one.
498        if let Some(vendor) = active_vendor.filter(|_| !gpus.is_empty()) {
499            let service = crate::run::resolve_docker_service(crate::run::LOGICAL_GPU_SERVICE, root);
500            let _ = std::fs::create_dir_all(format!(".cargo-cache-{service}"));
501            let _ = std::fs::create_dir_all(format!(".cargo-git-{service}"));
502
503            let status = docker::compose_run(".", &["build", &service])?;
504            if !status.success() {
505                println!("  Warning: {vendor} Docker image build failed.");
506            }
507        }
508
509        println!("  Docker images ready.");
510    }
511
512    // ---- Summary ----
513
514    println!();
515    println!("  Setup complete!");
516    println!("  ===============");
517    println!();
518
519    // Show active libtorch
520    if let Some(info) = &active {
521        println!(
522            "  libtorch:  {} ({})",
523            info.path,
524            active_label(active_vendor)
525        );
526    }
527
528    let gpu_ready = !gpus.is_empty() && active_vendor.is_some();
529
530    // Docker instructions
531    if build_mode == "docker" || build_mode == "both" {
532        println!();
533        println!("  Build with Docker:");
534        if gpu_ready {
535            println!("    fdl gpu-test        # run GPU tests");
536            println!("    fdl gpu-build       # compile for the GPU");
537            println!("    fdl gpu-shell       # interactive shell");
538        } else {
539            println!("    fdl test             # run tests");
540            println!("    fdl build            # compile");
541            println!("    fdl shell            # interactive shell");
542        }
543    }
544
545    // Native instructions
546    if (build_mode == "native" || build_mode == "both")
547        && let Some(info) = &active
548    {
549        let lt_path = format!("libtorch/{}", info.path);
550        println!();
551        println!("  Build natively:");
552        println!("    export LIBTORCH_PATH=\"{}\"", lt_path);
553        for line in detect::ld_library_path_lines(active_vendor, "$LIBTORCH_PATH/lib") {
554            println!("    {line}");
555        }
556        match active_vendor.filter(|_| gpu_ready) {
557            Some(vendor) => println!("    cargo test --features {}", vendor.cargo_feature()),
558            None => println!("    cargo test"),
559        }
560    }
561
562    // No-build-environment fallback: only reachable from the
563    // docker-only-no-cargo branch where the user declined Docker
564    // twice. The Rust install pointers were already printed during
565    // Step 3; the summary just re-anchors the next move so the
566    // user doesn't drop into the trailing "Other commands" block
567    // without context.
568    if build_mode == "none" {
569        println!();
570        println!("  No build environment configured.");
571        println!("  Install Rust (link above) for native builds, or re-run `fdl setup`");
572        println!("  and pick Docker. libtorch is already in place either way.");
573    }
574
575    println!();
576    println!("  Other commands:");
577    println!("    fdl diagnose         # verify GPU compatibility");
578    println!("    fdl init my-project  # scaffold a new project");
579    println!();
580
581    if !opts.non_interactive {
582        crate::util::install_prompt::offer_global_install();
583    }
584
585    Ok(())
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    // The macOS arms never execute on the Linux dev box or on the Linux
593    // CI legs, and the Apple Silicon one is the case where a wrong answer
594    // installs a libtorch that cannot load inside the container.
595
596    #[test]
597    fn apple_silicon_docker_never_forces_an_x86_download() {
598        // Upstream publishes no Linux aarch64 libtorch, so forcing Linux
599        // here fetches x86_64 into a linux/arm64 container's bind-mount.
600        assert_eq!(
601            macos_docker_plan("macos", "aarch64", true),
602            MacDockerPlan::HostBuildThenManualArm64
603        );
604    }
605
606    #[test]
607    fn intel_mac_docker_forces_the_linux_build() {
608        assert_eq!(
609            macos_docker_plan("macos", "x86_64", true),
610            MacDockerPlan::ForceLinuxX86
611        );
612    }
613
614    #[test]
615    fn a_mac_without_a_docker_project_builds_for_the_host() {
616        for arch in ["aarch64", "x86_64"] {
617            assert_eq!(
618                macos_docker_plan("macos", arch, false),
619                MacDockerPlan::HostBuild,
620                "{arch} native"
621            );
622        }
623    }
624
625    #[test]
626    fn non_macos_hosts_are_unaffected() {
627        for (os, arch) in [
628            ("linux", "x86_64"),
629            ("linux", "aarch64"),
630            ("windows", "x86_64"),
631        ] {
632            assert_eq!(
633                macos_docker_plan(os, arch, true),
634                MacDockerPlan::HostBuild,
635                "{os}/{arch}"
636            );
637        }
638    }
639}