1use 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 pub tag: Option<String>,
40 pub archs: Option<String>,
43 pub max_jobs: usize,
45 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
60fn 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
145fn 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 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
180fn 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
193fn 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
206pub 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
283fn build_docker(
288 version: &str,
289 gencode: &str,
290 image_tag: &str,
291 max_jobs: usize,
292) -> Result<(), String> {
293 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
332fn 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 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#[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 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 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}