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(
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
179fn 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
191fn 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
207pub 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
283fn build_docker(version: &str, gencode: &str, image_tag: &str, max_jobs: usize) -> Result<(), String> {
288 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
327fn 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 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#[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 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 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}