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("No NVIDIA GPUs detected.\n\
153             NCCL builds need GPU arch info to set NVCC_GENCODE.\n\
154             Use --archs to specify manually (e.g. --archs \"6.1;12.0\")."
155            .into());
156    }
157
158    // nvcc gencode targets: NVIDIA devices only. (RCCL, the AMD
159    // counterpart, ships prebuilt inside libtorch-rocm and needs no
160    // source build, so there is no AMD arm to add here.)
161    let mut caps: Vec<(u32, u32)> = gpus
162        .iter()
163        .filter_map(|g| Some((g.sm_major()?, g.sm_minor()?)))
164        .collect();
165    caps.sort();
166    caps.dedup();
167    let caps: Vec<String> = caps
168        .iter()
169        .map(|(ma, mi)| format!("{}.{}", ma, mi))
170        .collect();
171
172    println!("  GPUs detected:");
173    for g in &gpus {
174        println!("    [{}] {} ({})", g.index, g.short_name(), g.arch_label());
175    }
176
177    Ok(caps.join(";"))
178}
179
180/// Strip the trailing `-N` patch suffix from an NCCL tag for directory
181/// naming. `v2.27.5-1` -> `v2.27.5`. Leaves tags without a numeric patch
182/// suffix untouched.
183fn version_dir_part(tag: &str) -> String {
184    if let Some((base, rest)) = tag.rsplit_once('-')
185        && !rest.is_empty()
186        && rest.chars().all(|c| c.is_ascii_digit())
187    {
188        return base.to_string();
189    }
190    tag.to_string()
191}
192
193/// Convert "6.1;12.0" -> "-gencode=arch=compute_61,code=sm_61 -gencode=arch=compute_120,code=sm_120"
194/// for the NVCC_GENCODE build arg.
195fn arch_gencode(archs: &str) -> String {
196    archs
197        .split(';')
198        .map(|cap| {
199            let clean = cap.replace('.', "");
200            format!("-gencode=arch=compute_{},code=sm_{}", clean, clean)
201        })
202        .collect::<Vec<_>>()
203        .join(" ")
204}
205
206// ---------------------------------------------------------------------------
207// Entry point
208// ---------------------------------------------------------------------------
209
210pub fn run(opts: BuildOpts) -> Result<(), String> {
211    let ctx = Context::resolve();
212
213    if !docker::has_docker() {
214        return Err("Docker is required for `fdl nccl build`.\n\
215             Install Docker: https://docs.docker.com/engine/install/"
216            .into());
217    }
218
219    let tag = resolve_tag(&ctx, opts.tag)?;
220
221    let archs = match &opts.archs {
222        Some(a) => {
223            println!("  Using specified architectures: {}", a);
224            a.clone()
225        }
226        None => detect_arch_list()?,
227    };
228
229    let arch_dir = system::arch_dir_name(&archs);
230    let gencode = arch_gencode(&archs);
231    let version_short = version_dir_part(&tag);
232    let install_path = ctx.root.join(format!(
233        "libtorch/nccl/builds/{}-{}",
234        version_short, arch_dir
235    ));
236    let image_tag = format!("{}:{}-{}", IMAGE_PREFIX, version_short, arch_dir);
237
238    println!();
239    println!("  NCCL source build");
240    println!("  Tag:      {}", tag);
241    println!("  Archs:    {}", archs);
242    println!("  Gencode:  {}", gencode);
243    println!("  Output:   {}", install_path.display());
244    println!("  Jobs:     {}", opts.max_jobs);
245    println!("  Image:    {}", image_tag);
246    println!();
247
248    if opts.dry_run {
249        println!(
250            "  [dry-run] Would build NCCL {} for {} via Docker.",
251            tag, archs
252        );
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(
288    version: &str,
289    gencode: &str,
290    image_tag: &str,
291    max_jobs: usize,
292) -> Result<(), String> {
293    // Write Dockerfile to temp location.
294    let tmp_dir = std::env::temp_dir();
295    let dockerfile_path = tmp_dir.join("flodl-nccl-builder.Dockerfile");
296    {
297        let mut f = fs::File::create(&dockerfile_path)
298            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
299        f.write_all(DOCKERFILE_CONTENT.as_bytes())
300            .map_err(|e| format!("cannot write Dockerfile: {}", e))?;
301    }
302
303    let status = docker::docker_run(&[
304        "build",
305        "-f",
306        dockerfile_path.to_str().ok_or("temp path not UTF-8")?,
307        "--build-arg",
308        &format!("NCCL_VERSION={}", version),
309        "--build-arg",
310        &format!("NVCC_GENCODE={}", gencode),
311        "--build-arg",
312        &format!("MAX_JOBS={}", max_jobs),
313        "-t",
314        image_tag,
315        ".",
316    ])?;
317
318    let _ = fs::remove_file(&dockerfile_path);
319
320    if !status.success() {
321        return Err(format!(
322            "Docker build failed (exit code {}).\n\
323             Check the output above for errors.\n\
324             You can re-run this command to resume (BuildKit caches NCCL checkout).",
325            status.code().unwrap_or(-1)
326        ));
327    }
328
329    Ok(())
330}
331
332// ---------------------------------------------------------------------------
333// Extract from builder image
334// ---------------------------------------------------------------------------
335
336fn extract_artifacts(image_tag: &str, install_path: &PathBuf) -> Result<(), String> {
337    let container_out = docker::docker_output(&["create", image_tag])?;
338    if !container_out.status.success() {
339        return Err("failed to create container from builder image".into());
340    }
341    let container_id = String::from_utf8_lossy(&container_out.stdout)
342        .trim()
343        .to_string();
344
345    fs::create_dir_all(install_path)
346        .map_err(|e| format!("cannot create {}: {}", install_path.display(), e))?;
347
348    let mut last_err: Option<String> = None;
349    for sub in ["lib", "include"] {
350        let cp_status = docker::docker_run(&[
351            "cp",
352            &format!("{}:/usr/local/nccl/{}", container_id, sub),
353            install_path.to_str().ok_or("install path not UTF-8")?,
354        ])?;
355        if !cp_status.success() {
356            last_err = Some(format!(
357                "failed to extract {} (docker cp exit {})",
358                sub,
359                cp_status.code().unwrap_or(-1)
360            ));
361            break;
362        }
363    }
364
365    let _ = docker::docker_output(&["rm", &container_id]);
366
367    if let Some(e) = last_err {
368        return Err(e);
369    }
370
371    // Sanity check: did the .so land?
372    let lib_dir = install_path.join("lib");
373    if !lib_dir.join("libnccl.so.2").exists() && !lib_dir.join("libnccl.so").exists() {
374        return Err(format!(
375            "libnccl not found under {}.\n\
376             The build may have completed but produced no artifacts.",
377            lib_dir.display()
378        ));
379    }
380
381    Ok(())
382}
383
384// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn arch_gencode_single() {
394        assert_eq!(
395            arch_gencode("12.0"),
396            "-gencode=arch=compute_120,code=sm_120"
397        );
398    }
399
400    #[test]
401    fn arch_gencode_multi() {
402        assert_eq!(
403            arch_gencode("6.1;12.0"),
404            "-gencode=arch=compute_61,code=sm_61 -gencode=arch=compute_120,code=sm_120"
405        );
406    }
407
408    #[test]
409    fn version_dir_strips_patch() {
410        assert_eq!(version_dir_part("v2.27.5-1"), "v2.27.5");
411        assert_eq!(version_dir_part("v2.27.5-12"), "v2.27.5");
412    }
413
414    #[test]
415    fn version_dir_keeps_non_numeric() {
416        assert_eq!(version_dir_part("v2.27.5"), "v2.27.5");
417        assert_eq!(version_dir_part("master"), "master");
418        assert_eq!(version_dir_part("v2.27.5-rc1"), "v2.27.5-rc1");
419    }
420
421    #[test]
422    fn detect_picks_self_id_not_error_message() {
423        // Synthetic blob with PT's error-message version BEFORE NCCL's
424        // self-identification string. The detector must pick the latter
425        // (the `+cuda` suffix is the disambiguator).
426        let tmp = std::env::temp_dir().join("flodl-nccl-detect-test.bin");
427        let blob = b"\
428            ProcessGroupNCCL::shrink requires NCCL version 2.27.0 or later.\x00\
429            padding padding padding\x00\
430            NCCL version 2.27.5+cuda12.8\x00";
431        std::fs::write(&tmp, blob).expect("write fixture");
432        let tag = detect_nccl_tag_from_libtorch_cuda(&tmp);
433        let _ = std::fs::remove_file(&tmp);
434        assert_eq!(tag, Some("v2.27.5-1".to_string()));
435    }
436
437    #[test]
438    fn detect_returns_none_without_self_id() {
439        // Only error-message NCCL strings, no `+cuda`-suffixed self-id.
440        let tmp = std::env::temp_dir().join("flodl-nccl-detect-test-2.bin");
441        let blob = b"Mismatched NCCL version detected\x00NCCL version 2.27.0 or later\x00";
442        std::fs::write(&tmp, blob).expect("write fixture");
443        let tag = detect_nccl_tag_from_libtorch_cuda(&tmp);
444        let _ = std::fs::remove_file(&tmp);
445        assert_eq!(tag, None);
446    }
447}