Skip to main content

flodl_cli/nccl/
build.rs

1//! `fdl nccl build` -- compile libnccl from NVIDIA's NCCL source.
2//!
3//! Why this exists: libtorch wheels statically link NCCL into
4//! `libtorch_cuda.so`. On heterogeneous-arch rigs (Pascal sm_61 + Blackwell
5//! sm_120, etc.), that static NCCL can corrupt the shared CUmodule loader
6//! state during cross-host bootstrap, which surfaces as
7//! `cudaErrorNoKernelImageForDevice` on otherwise-working kernels. Building
8//! libnccl as a standalone `.so` and `LD_PRELOAD`-ing it gives NCCL its own
9//! CUmodule scope and sidesteps the issue.
10//!
11//! Output layout (mirrors `fdl libtorch build`):
12//!
13//! ```text
14//! libtorch/nccl/builds/v<version>-<archs>/
15//!     include/nccl.h
16//!     lib/libnccl.so          -> libnccl.so.2
17//!     lib/libnccl.so.2        -> libnccl.so.<X.Y.Z>
18//!     lib/libnccl.so.<X.Y.Z>
19//!     lib/libnccl_static.a
20//!     lib/pkgconfig/
21//! ```
22
23use std::fs;
24use std::io::Write;
25use std::path::PathBuf;
26
27use crate::context::Context;
28use crate::libtorch::detect as libtorch_detect;
29use crate::util::docker;
30use crate::util::system;
31
32const DOCKERFILE_CONTENT: &str = include_str!("../../assets/Dockerfile.nccl.source");
33const IMAGE_PREFIX: &str = "flodl-nccl-build";
34
35pub struct BuildOpts {
36    /// NCCL git tag (e.g. "v2.27.5-1"). None = infer from the active
37    /// libtorch's bundled NCCL version (the version cross-rank handshake
38    /// requires us to match).
39    pub tag: Option<String>,
40    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
41    /// None = auto-detect from local GPUs.
42    pub archs: Option<String>,
43    /// Override MAX_JOBS for compilation. Default: 6.
44    pub max_jobs: usize,
45    /// Print what would happen without building.
46    pub dry_run: bool,
47}
48
49impl Default for BuildOpts {
50    fn default() -> Self {
51        Self {
52            tag: None,
53            archs: None,
54            max_jobs: 6,
55            dry_run: false,
56        }
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Auto-detect NCCL version from active libtorch
62// ---------------------------------------------------------------------------
63
64/// Scan a libtorch_cuda.so file for NCCL's self-identification string and
65/// return a build tag like `v2.27.5-1`. Looks for `NCCL version <X.Y.Z>`
66/// immediately followed by `+cuda` (NCCL's runtime format is
67/// `NCCL version 2.27.5+cuda12.8`). The `+cuda` suffix disambiguates from
68/// PyTorch's error-message strings that mention NCCL versions as version
69/// floors (`NCCL version 2.27.0 or later`). The `-1` patch suffix is
70/// NCCL's default tag revision for a given X.Y.Z release; override with
71/// `--tag` if you need a later revision (`-2`, `-3`, ...).
72fn detect_nccl_tag_from_libtorch_cuda(path: &std::path::Path) -> Option<String> {
73    let bytes = fs::read(path).ok()?;
74    let needle = b"NCCL version ";
75    let mut idx = 0;
76    while idx + needle.len() < bytes.len() {
77        if &bytes[idx..idx + needle.len()] != needle {
78            idx += 1;
79            continue;
80        }
81        let after = &bytes[idx + needle.len()..];
82        let end = after
83            .iter()
84            .position(|&b| !(b.is_ascii_digit() || b == b'.'))
85            .unwrap_or(after.len());
86        if end > 0 && after.get(end) == Some(&b'+') {
87            let version = std::str::from_utf8(&after[..end]).ok()?;
88            if !version.is_empty() {
89                return Some(format!("v{}-1", version));
90            }
91        }
92        idx += needle.len();
93    }
94    None
95}
96
97fn resolve_tag(ctx: &Context, override_tag: Option<String>) -> Result<String, String> {
98    if let Some(tag) = override_tag {
99        if tag.trim().is_empty() {
100            return Err(
101                "--tag cannot be empty. Pass a valid NCCL git tag (e.g. v2.27.5-1) \
102                 or omit --tag to infer from the active libtorch."
103                    .into(),
104            );
105        }
106        return Ok(tag);
107    }
108
109    let active = libtorch_detect::read_active(&ctx.root).ok_or_else(|| {
110        "No active libtorch variant; cannot infer NCCL version.\n\
111         Either activate one with `fdl libtorch activate <variant>` \
112         or pass --tag explicitly (e.g. --tag v2.27.5-1)."
113            .to_string()
114    })?;
115
116    let libtorch_cuda = ctx
117        .root
118        .join("libtorch")
119        .join(&active.path)
120        .join("lib")
121        .join("libtorch_cuda.so");
122    if !libtorch_cuda.exists() {
123        return Err(format!(
124            "Active libtorch variant {} has no libtorch_cuda.so at {}.\n\
125             Pass --tag explicitly or fix the libtorch installation.",
126            active.path,
127            libtorch_cuda.display()
128        ));
129    }
130
131    let tag = detect_nccl_tag_from_libtorch_cuda(&libtorch_cuda).ok_or_else(|| {
132        format!(
133            "Could not detect bundled NCCL version in {}.\n\
134             Pass --tag explicitly (e.g. --tag v2.27.5-1).",
135            libtorch_cuda.display()
136        )
137    })?;
138    println!(
139        "  Inferred NCCL tag from libtorch ({}): {}",
140        active.path, tag
141    );
142    Ok(tag)
143}
144
145// ---------------------------------------------------------------------------
146// Auto-detect GPU architectures
147// ---------------------------------------------------------------------------
148
149fn detect_arch_list() -> Result<String, String> {
150    let gpus = system::detect_gpus();
151    if gpus.is_empty() {
152        return Err(
153            "No NVIDIA GPUs detected.\n\
154             NCCL builds need GPU arch info to set NVCC_GENCODE.\n\
155             Use --archs to specify manually (e.g. --archs \"6.1;12.0\")."
156                .into(),
157        );
158    }
159
160    let mut caps: Vec<(u32, u32)> = gpus
161        .iter()
162        .map(|g| (g.sm_major, g.sm_minor))
163        .collect();
164    caps.sort();
165    caps.dedup();
166    let caps: Vec<String> = caps.iter().map(|(ma, mi)| format!("{}.{}", ma, mi)).collect();
167
168    println!("  GPUs detected:");
169    for g in &gpus {
170        println!(
171            "    [{}] {} (sm_{}{})",
172            g.index, g.short_name(), g.sm_major, g.sm_minor
173        );
174    }
175
176    Ok(caps.join(";"))
177}
178
179/// Strip the trailing `-N` patch suffix from an NCCL tag for directory
180/// naming. `v2.27.5-1` -> `v2.27.5`. Leaves tags without a numeric patch
181/// suffix untouched.
182fn version_dir_part(tag: &str) -> String {
183    if let Some((base, rest)) = tag.rsplit_once('-') {
184        if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
185            return base.to_string();
186        }
187    }
188    tag.to_string()
189}
190
191/// Convert "6.1;12.0" -> "-gencode=arch=compute_61,code=sm_61 -gencode=arch=compute_120,code=sm_120"
192/// for the NVCC_GENCODE build arg.
193fn arch_gencode(archs: &str) -> String {
194    archs
195        .split(';')
196        .map(|cap| {
197            let clean = cap.replace('.', "");
198            format!(
199                "-gencode=arch=compute_{},code=sm_{}",
200                clean, clean
201            )
202        })
203        .collect::<Vec<_>>()
204        .join(" ")
205}
206
207// ---------------------------------------------------------------------------
208// Entry point
209// ---------------------------------------------------------------------------
210
211pub fn run(opts: BuildOpts) -> Result<(), String> {
212    let ctx = Context::resolve();
213
214    if !docker::has_docker() {
215        return Err(
216            "Docker is required for `fdl nccl build`.\n\
217             Install Docker: https://docs.docker.com/engine/install/"
218                .into(),
219        );
220    }
221
222    let tag = resolve_tag(&ctx, opts.tag)?;
223
224    let archs = match &opts.archs {
225        Some(a) => {
226            println!("  Using specified architectures: {}", a);
227            a.clone()
228        }
229        None => detect_arch_list()?,
230    };
231
232    let arch_dir = system::arch_dir_name(&archs);
233    let gencode = arch_gencode(&archs);
234    let version_short = version_dir_part(&tag);
235    let install_path = ctx.root.join(format!(
236        "libtorch/nccl/builds/{}-{}",
237        version_short, arch_dir
238    ));
239    let image_tag = format!("{}:{}-{}", IMAGE_PREFIX, version_short, arch_dir);
240
241    println!();
242    println!("  NCCL source build");
243    println!("  Tag:      {}", tag);
244    println!("  Archs:    {}", archs);
245    println!("  Gencode:  {}", gencode);
246    println!("  Output:   {}", install_path.display());
247    println!("  Jobs:     {}", opts.max_jobs);
248    println!("  Image:    {}", image_tag);
249    println!();
250
251    if opts.dry_run {
252        println!("  [dry-run] Would build NCCL {} for {} via Docker.", tag, archs);
253        println!("  This typically takes 5-15 minutes.");
254        return Ok(());
255    }
256
257    println!("  Building (5-15 min, mostly nvcc compile time)...");
258    println!();
259
260    build_docker(&tag, &gencode, &image_tag, opts.max_jobs)?;
261
262    println!();
263    println!("  Extracting build artifacts...");
264    extract_artifacts(&image_tag, &install_path)?;
265
266    println!();
267    println!("  ================================================");
268    println!("  NCCL {} (source build) complete!", tag);
269    println!("  Archs:  {}", archs);
270    println!("  Path:   {}", install_path.display());
271    println!("  ================================================");
272    println!();
273    println!("  Wire into a cluster worker via:");
274    println!("    worker.env:");
275    println!(
276        "      LD_PRELOAD: {}/lib/libnccl.so.2",
277        install_path.display()
278    );
279
280    Ok(())
281}
282
283// ---------------------------------------------------------------------------
284// Docker build
285// ---------------------------------------------------------------------------
286
287fn build_docker(version: &str, gencode: &str, image_tag: &str, max_jobs: usize) -> Result<(), String> {
288    // Write Dockerfile to temp location.
289    let tmp_dir = std::env::temp_dir();
290    let dockerfile_path = tmp_dir.join("flodl-nccl-builder.Dockerfile");
291    {
292        let mut f = fs::File::create(&dockerfile_path)
293            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
294        f.write_all(DOCKERFILE_CONTENT.as_bytes())
295            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
296    }
297
298    let status = docker::docker_run(&[
299        "build",
300        "-f",
301        dockerfile_path.to_str().ok_or("temp path not UTF-8")?,
302        "--build-arg",
303        &format!("NCCL_VERSION={}", version),
304        "--build-arg",
305        &format!("NVCC_GENCODE={}", gencode),
306        "--build-arg",
307        &format!("MAX_JOBS={}", max_jobs),
308        "-t",
309        image_tag,
310        ".",
311    ])?;
312
313    let _ = fs::remove_file(&dockerfile_path);
314
315    if !status.success() {
316        return Err(format!(
317            "Docker build failed (exit code {}).\n\
318             Check the output above for errors.\n\
319             You can re-run this command to resume (BuildKit caches NCCL checkout).",
320            status.code().unwrap_or(-1)
321        ));
322    }
323
324    Ok(())
325}
326
327// ---------------------------------------------------------------------------
328// Extract from builder image
329// ---------------------------------------------------------------------------
330
331fn extract_artifacts(image_tag: &str, install_path: &PathBuf) -> Result<(), String> {
332    let container_out = docker::docker_output(&["create", image_tag])?;
333    if !container_out.status.success() {
334        return Err("failed to create container from builder image".into());
335    }
336    let container_id = String::from_utf8_lossy(&container_out.stdout)
337        .trim()
338        .to_string();
339
340    fs::create_dir_all(install_path)
341        .map_err(|e| format!("cannot create {}: {}", install_path.display(), e))?;
342
343    let mut last_err: Option<String> = None;
344    for sub in ["lib", "include"] {
345        let cp_status = docker::docker_run(&[
346            "cp",
347            &format!("{}:/usr/local/nccl/{}", container_id, sub),
348            install_path
349                .to_str()
350                .ok_or("install path not UTF-8")?,
351        ])?;
352        if !cp_status.success() {
353            last_err = Some(format!(
354                "failed to extract {} (docker cp exit {})",
355                sub,
356                cp_status.code().unwrap_or(-1)
357            ));
358            break;
359        }
360    }
361
362    let _ = docker::docker_output(&["rm", &container_id]);
363
364    if let Some(e) = last_err {
365        return Err(e);
366    }
367
368    // Sanity check: did the .so land?
369    let lib_dir = install_path.join("lib");
370    if !lib_dir.join("libnccl.so.2").exists() && !lib_dir.join("libnccl.so").exists() {
371        return Err(format!(
372            "libnccl not found under {}.\n\
373             The build may have completed but produced no artifacts.",
374            lib_dir.display()
375        ));
376    }
377
378    Ok(())
379}
380
381// ---------------------------------------------------------------------------
382// Tests
383// ---------------------------------------------------------------------------
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn arch_gencode_single() {
391        assert_eq!(
392            arch_gencode("12.0"),
393            "-gencode=arch=compute_120,code=sm_120"
394        );
395    }
396
397    #[test]
398    fn arch_gencode_multi() {
399        assert_eq!(
400            arch_gencode("6.1;12.0"),
401            "-gencode=arch=compute_61,code=sm_61 -gencode=arch=compute_120,code=sm_120"
402        );
403    }
404
405    #[test]
406    fn version_dir_strips_patch() {
407        assert_eq!(version_dir_part("v2.27.5-1"), "v2.27.5");
408        assert_eq!(version_dir_part("v2.27.5-12"), "v2.27.5");
409    }
410
411    #[test]
412    fn version_dir_keeps_non_numeric() {
413        assert_eq!(version_dir_part("v2.27.5"), "v2.27.5");
414        assert_eq!(version_dir_part("master"), "master");
415        assert_eq!(version_dir_part("v2.27.5-rc1"), "v2.27.5-rc1");
416    }
417
418    #[test]
419    fn detect_picks_self_id_not_error_message() {
420        // Synthetic blob with PT's error-message version BEFORE NCCL's
421        // self-identification string. The detector must pick the latter
422        // (the `+cuda` suffix is the disambiguator).
423        let tmp = std::env::temp_dir().join("flodl-nccl-detect-test.bin");
424        let blob = b"\
425            ProcessGroupNCCL::shrink requires NCCL version 2.27.0 or later.\x00\
426            padding padding padding\x00\
427            NCCL version 2.27.5+cuda12.8\x00";
428        std::fs::write(&tmp, blob).expect("write fixture");
429        let tag = detect_nccl_tag_from_libtorch_cuda(&tmp);
430        let _ = std::fs::remove_file(&tmp);
431        assert_eq!(tag, Some("v2.27.5-1".to_string()));
432    }
433
434    #[test]
435    fn detect_returns_none_without_self_id() {
436        // Only error-message NCCL strings, no `+cuda`-suffixed self-id.
437        let tmp = std::env::temp_dir().join("flodl-nccl-detect-test-2.bin");
438        let blob = b"Mismatched NCCL version detected\x00NCCL version 2.27.0 or later\x00";
439        std::fs::write(&tmp, blob).expect("write fixture");
440        let tag = detect_nccl_tag_from_libtorch_cuda(&tmp);
441        let _ = std::fs::remove_file(&tmp);
442        assert_eq!(tag, None);
443    }
444}