flodl_cli/prebuild.rs
1//! Pre-flight build for cluster commands.
2//!
3//! Heterogeneous-rig pain: source lives on a shared mount (NFS /
4//! virtiofs / S3-FUSE) so editing on the controller is visible to
5//! remote hosts, but each host needs its own libtorch-linked binary
6//! (cu128 for a Blackwell host, cu126-pt27 for a Pascal host). Without
7//! pre-flight, the first `fdl @cluster <cmd>` after an edit hits stale
8//! remote binaries — confusing runtime errors or worse, silent wrong
9//! behaviour.
10//!
11//! This module runs `cargo build` LOCALLY on the controller, once per
12//! remote host, into per-`(host, arch)` `target/cluster/<host>/<arch>/`
13//! directories with the right libtorch bind-mounted. The shared mount
14//! delivers the resulting binary to the remote, which execs it directly
15//! (no cargo, no rust toolchain on remote). Per-`(host, arch)` target
16//! dirs isolate cargo's fingerprint cache so a libtorch swap on one host
17//! doesn't invalidate anyone else's incremental build -- and so changing
18//! a host's own `arch:` builds fresh rather than reusing a binary linked
19//! against the old libtorch (in docker mode the container's
20//! `LIBTORCH_PATH` is a fixed mount point, invisible to cargo's cache).
21//!
22//! Convention: command name == binary name. `fdl @cluster ddp-bench`
23//! builds `--bin ddp-bench`. Features derive from the host's libtorch
24//! `.arch` (cuda=12.x → `--features cuda`, cuda=none → no features).
25//!
26//! Builds run in parallel across hosts (per-host target dirs ⇒ zero
27//! contention). All builds run to completion — there is no early abort:
28//! every host is joined and every failure is collected, then surfaced
29//! together so the user sees every host's diagnostic in one place.
30
31use std::collections::BTreeMap;
32use std::path::Path;
33use std::process::{Command, Stdio};
34use std::sync::{Arc, Mutex};
35use std::thread;
36
37use serde::{Deserialize, Serialize};
38
39use crate::cluster::apply_worker_ssh_opts;
40use crate::config::{ClusterConfig, ClusterWorker};
41
42/// Env var carrying the per-host pre-flight build envelope (a JSON
43/// map) from fdl-cli's prebuild phase to flodl's launcher. The
44/// launcher reads it on the controller side just before fan-out and
45/// substitutes the direct-binary form for each host whose entry is
46/// present.
47///
48/// Map shape: `{ "<host-name>": { "bin": "<relative path under
49/// worker.path>", "ld_library_path": "<absolute LD_LIBRARY_PATH>" }, ...
50/// }`. Hosts absent from the map fall back to the launcher's existing
51/// `fdl <cmd>` re-entry on the remote.
52pub const ENV_PREBUILD_PER_HOST: &str = "FLODL_INTERNAL_PREBUILD_PER_HOST";
53
54/// Pre-fan-out readiness probe for every host, run BEFORE any build and
55/// in BOTH prebuild and `--no-prebuild` modes (mount-readiness is
56/// orthogonal to binary freshness). One ssh per remote host: always
57/// verifies the shared `data_path` is mounted + readable; when
58/// `prebuilding`, also runs the controller-vs-remote ABI gate (arch /
59/// libc / `pkill`). The controller is checked LOCALLY (no ssh — it is
60/// the build/dispatch host, so ABI is trivially satisfied).
61///
62/// A definitive failure aborts before fan-out with a per-host message:
63/// an ABI mismatch, or a missing EXPLICIT `data_path` (a declared shared
64/// mount that isn't there is a launch-breaking misconfig, matching
65/// `fdl probe`'s stance). Missing convention-default paths and
66/// unreachable hosts only warn. Running before the (multi-minute) builds
67/// means a bad host fails fast without wasting a build on a good one.
68///
69/// `controller_host` is the local hostname (its worker entry, if any, is
70/// covered by the local check and skipped from the ssh sweep).
71pub fn preflight_hosts(
72 cluster: &ClusterConfig,
73 controller_host: &str,
74 prebuilding: bool,
75) -> Result<(), String> {
76 // Controller: local shared-mount check (no ssh). Uses the controller
77 // block's `data_path`; a same-host worker entry is skipped below.
78 {
79 let dp = cluster.controller.effective_data_path().to_string();
80 let explicit = cluster.controller.data_path.is_some();
81 let dir_ok = Path::new(&dp).is_dir();
82 let read_ok = dir_ok && std::fs::read_dir(&dp).is_ok();
83 if let Some(w) =
84 check_remote_data_path(controller_host, &dp, explicit, dir_ok, read_ok)?
85 {
86 eprintln!("fdl: {w}");
87 }
88 }
89
90 // Remote workers: one ssh each, in parallel (probe latency stays flat
91 // regardless of host count).
92 let remotes: Vec<ClusterWorker> = cluster
93 .workers
94 .iter()
95 .filter(|w| w.host != controller_host)
96 .cloned()
97 .collect();
98 if remotes.is_empty() {
99 return Ok(());
100 }
101
102 let warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
103 let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
104 let mut handles = Vec::with_capacity(remotes.len());
105 for worker in remotes {
106 let warnings = Arc::clone(&warnings);
107 let errors = Arc::clone(&errors);
108 handles.push(thread::spawn(move || {
109 match preflight_one_host(&worker, prebuilding) {
110 Ok(ws) => warnings.lock().unwrap().extend(ws),
111 Err(e) => errors.lock().unwrap().push(e),
112 }
113 }));
114 }
115 for h in handles {
116 let _ = h.join();
117 }
118
119 for w in warnings.lock().unwrap().iter() {
120 eprintln!("fdl: {w}");
121 }
122 let errs = Arc::try_unwrap(errors)
123 .map_err(|_| "internal: preflight error collector still referenced".to_string())?
124 .into_inner()
125 .map_err(|e| format!("internal: preflight errors mutex poisoned: {e}"))?;
126 if !errs.is_empty() {
127 return Err(format!(
128 "pre-flight host check failed on {} host(s):\n {}",
129 errs.len(),
130 errs.join("\n "),
131 ));
132 }
133 Ok(())
134}
135
136/// Run pre-flight builds for every remote host in `cluster`. The
137/// controller itself is skipped — its build is handled by the normal
138/// dispatch path (`cargo run` in Docker against the local `.active`).
139///
140/// `cmd_name` is both the fdl command and the cargo `--bin` target.
141/// `controller_host` is the local hostname (skipped from the remotes).
142///
143/// Each per-host build runs in a Docker compose service (`cuda` when
144/// the host's libtorch advertises a CUDA version in `.arch`, `dev`
145/// otherwise). The build's env is overridden so:
146/// - `LIBTORCH_HOST_PATH` points at the resolved host libtorch dir
147/// - `CARGO_TARGET_DIR` points at `target/cluster/<host>/<arch>/`
148///
149/// Returns `Ok(())` on universal success. Returns `Err(combined_msg)`
150/// listing every host that failed (with its stderr tail) on any
151/// failure. Builds running when a failure surfaces complete to natural
152/// stopping — cargo's per-crate granularity means cancelling mid-build
153/// would leave the per-host target dir in a half-baked state.
154pub fn prebuild_remotes(
155 project_root: &Path,
156 cmd_cwd: &Path,
157 cluster: &ClusterConfig,
158 cmd_name: &str,
159 controller_host: &str,
160) -> Result<(), String> {
161 // Skip only the worker whose `host` LABEL equals the controller's local
162 // hostname — that entry is the controller building locally and sharing its
163 // cargo target dir, so it needs no remote step. This compares the logical
164 // host label, NOT resolved IPs, deliberately: a container or VM on the same
165 // physical machine (e.g. an `ssh.target: 127.0.0.1:2222` worker) is a
166 // DISTINCT build target with its own arch/libtorch and must get its own
167 // per-host build. Canonicalizing by IP would wrongly fold such a worker
168 // into the controller and break heterogeneous same-box rigs.
169 let remotes: Vec<&ClusterWorker> = cluster
170 .workers
171 .iter()
172 .filter(|w| w.host != controller_host)
173 .collect();
174 if remotes.is_empty() {
175 return Ok(());
176 }
177
178 // Whether the controller runs builds inside Docker. Sourced from
179 // the `controller.docker:` field in cluster.yml; `None` when
180 // absent (native-Rust controllers get the bare cargo invocation).
181 let controller_docker_svc: Option<String> = cluster.controller.docker.clone();
182
183 eprintln!(
184 "fdl: pre-flight build for {} remote worker(s): {}",
185 remotes.len(),
186 remotes.iter().map(|w| w.host.as_str()).collect::<Vec<_>>().join(", "),
187 );
188
189 // Controller's view of the shared project root (required field
190 // per validator).
191 let controller_path: std::path::PathBuf =
192 std::path::PathBuf::from(&cluster.controller.path);
193
194 let project_root = Arc::new(project_root.to_path_buf());
195 let cmd_cwd = Arc::new(cmd_cwd.to_path_buf());
196 let cmd_name = Arc::new(cmd_name.to_string());
197 let controller_path = Arc::new(controller_path);
198 let controller_docker_svc = Arc::new(controller_docker_svc);
199 let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
200 let envelope: Arc<Mutex<BTreeMap<String, PerHostEnvelope>>> =
201 Arc::new(Mutex::new(BTreeMap::new()));
202
203 // Cold shared cargo registry hazard: the per-(host,arch) target dirs are
204 // distinct, but every build shares one `~/.cargo/registry`. Running them
205 // all in parallel from cold races two `cargo build`s to unpack the same
206 // not-yet-cached crate into that registry — observed on the first build of
207 // a new cluster binary as `error: failed to unpack package serde_derive`.
208 // So build the FIRST remote serially: it fully populates the shared
209 // registry (download + unpack of every dep), after which the rest run in
210 // parallel finding all deps already unpacked (they still compile into
211 // their own target dirs). Steady-state (registry warm) this is ~free — the
212 // serial build is a cache hit; only a genuinely cold build pays for it.
213 {
214 let first: &ClusterWorker = remotes[0];
215 if remotes.len() > 1 {
216 eprintln!(
217 "fdl: pre-flight warming shared cargo registry via {} (serial) before parallel builds",
218 first.host,
219 );
220 }
221 match prebuild_one_worker(
222 &project_root, &cmd_cwd, &controller_path,
223 first, &cmd_name,
224 controller_docker_svc.as_deref(),
225 ) {
226 Ok(env_entry) => {
227 eprintln!("fdl: pre-flight OK ({})", first.host);
228 envelope.lock().unwrap().insert(first.host.clone(), env_entry);
229 }
230 Err(e) => {
231 eprintln!("fdl: pre-flight FAILED ({}): {}", first.host, e);
232 errors.lock().unwrap().push(format!("{}: {}", first.host, e));
233 }
234 }
235 }
236
237 // Remaining workers: parallel, registry now warm.
238 let mut handles = Vec::with_capacity(remotes.len().saturating_sub(1));
239 for worker in &remotes[1..] {
240 let worker = (*worker).clone();
241 let project_root = Arc::clone(&project_root);
242 let cmd_cwd = Arc::clone(&cmd_cwd);
243 let cmd_name = Arc::clone(&cmd_name);
244 let controller_path = Arc::clone(&controller_path);
245 let controller_docker_svc = Arc::clone(&controller_docker_svc);
246 let errors = Arc::clone(&errors);
247 let envelope = Arc::clone(&envelope);
248 handles.push(thread::spawn(move || {
249 match prebuild_one_worker(
250 &project_root, &cmd_cwd, &controller_path,
251 &worker, &cmd_name,
252 controller_docker_svc.as_deref(),
253 ) {
254 Ok(env_entry) => {
255 eprintln!("fdl: pre-flight OK ({})", worker.host);
256 envelope.lock().unwrap().insert(worker.host.clone(), env_entry);
257 }
258 Err(e) => {
259 eprintln!("fdl: pre-flight FAILED ({}): {}", worker.host, e);
260 errors.lock().unwrap().push(format!("{}: {}", worker.host, e));
261 }
262 }
263 }));
264 }
265 for h in handles {
266 let _ = h.join();
267 }
268
269 let errs = Arc::try_unwrap(errors)
270 .map_err(|_| "internal: error collector still has outstanding refs".to_string())?
271 .into_inner()
272 .map_err(|e| format!("internal: errors mutex poisoned: {e}"))?;
273 if !errs.is_empty() {
274 return Err(format!(
275 "pre-flight build failed on {} host(s):\n {}",
276 errs.len(),
277 errs.join("\n "),
278 ));
279 }
280
281 // Emit the per-host envelope so the flodl launcher's remote
282 // dispatch can substitute the direct-binary form for each host
283 // (skipping the `fdl <cmd>` re-entry — no cargo on remote).
284 let env_map = Arc::try_unwrap(envelope)
285 .map_err(|_| "internal: envelope still has outstanding refs".to_string())?
286 .into_inner()
287 .map_err(|e| format!("internal: envelope mutex poisoned: {e}"))?;
288 let json = serde_json::to_string(&env_map)
289 .map_err(|e| format!("internal: serialize prebuild envelope: {e}"))?;
290 // SAFETY: main has not spawned threads at this point in dispatch.
291 unsafe { std::env::set_var(ENV_PREBUILD_PER_HOST, json); }
292 Ok(())
293}
294
295/// Per-host pre-flight build artifact descriptor — exactly what the
296/// launcher needs to substitute the remote dispatch with a direct
297/// binary exec. Mirrors `flodl::distributed::launcher::PerHostPrebuild`
298/// on the consumer side; the two structs share an on-the-wire JSON
299/// schema but are independent types because the crates can't share
300/// declarations without a circular dep.
301#[derive(Clone, Debug, Serialize, Deserialize)]
302pub struct PerHostEnvelope {
303 /// Path to the compiled binary, relative to the host's project
304 /// checkout (`worker.path`). e.g.
305 /// `target/cluster/node-b/precompiled-cu128/release/train`.
306 pub bin: String,
307 /// Absolute path the launcher should set as `LD_LIBRARY_PATH` so
308 /// the binary finds its libtorch at runtime. e.g.
309 /// `/home/me/rdl/libtorch/builds/sm61-sm120/lib`. The launcher may
310 /// append host-specific extras (e.g. `:/usr/local/lib` for bare-
311 /// metal libnccl) via `worker.env: { LD_LIBRARY_PATH: ... }`.
312 pub ld_library_path: String,
313 /// Subdirectory under the host's project checkout to `cd` into
314 /// before exec — the relative offset of the command's filesystem
315 /// cwd from `project_root`. Mirrors the cwd the controller-side
316 /// build used (e.g. `ddp-bench` for `fdl ddp-bench`). Empty string
317 /// means execute from `worker.path` directly. Relative-path defaults
318 /// the binary expects (e.g. `--data-dir data`, `--output runs/`)
319 /// only resolve correctly when the remote cwd matches.
320 #[serde(default, skip_serializing_if = "String::is_empty")]
321 pub cwd_subpath: String,
322}
323
324/// Build `cmd_name` for one worker. Picks docker service + cargo
325/// features from the host's libtorch metadata. Returns a
326/// [`PerHostEnvelope`] describing where the resulting binary lives
327/// (so the launcher can substitute it on the remote-dispatch path)
328/// and what `LD_LIBRARY_PATH` the remote should set.
329///
330/// `controller_path` is the controller's view of the shared project
331/// root. The libtorch convention says the variant lives at
332/// `<controller_path>/libtorch/<worker.arch>` for the build (controller
333/// view) and `<worker.path>/libtorch/<worker.arch>` for the runtime
334/// (remote view). Both point at the same physical libtorch via the
335/// shared mount; the two paths differ only when controller and remote
336/// see the project at different filesystem locations.
337/// Outcome of the ABI-compatibility check between the controller's
338/// build environment and a remote host.
339#[derive(Debug, PartialEq)]
340enum AbiCheck {
341 /// Compatible (arch matches, remote is glibc). `warning` is `Some`
342 /// for a soft glibc-version skew note the caller should surface.
343 Ok { warning: Option<String> },
344 /// Definitively incompatible — the prebuilt binary cannot exec on
345 /// the remote. Hard error before fan-out.
346 Incompatible(String),
347}
348
349/// Compare the controller build environment against a remote host's
350/// `uname -m` + `ldd --version` output. Pure — no I/O — so it is
351/// directly unit-tested; [`preflight_one_host`] supplies the live inputs.
352///
353/// - **arch** must match exactly: an x86-64 binary is `Exec format
354/// error` on aarch64. Hard error.
355/// - **libc flavor**: the flodl build images are glibc-based, so a musl
356/// (Alpine) remote cannot run the glibc-linked binary. Hard error.
357/// - **glibc version**: a soft warning only. It often works, fails only
358/// when the remote glibc is OLDER than the build env's (`GLIBC_2.XX
359/// not found`), and that error at least names glibc — far less
360/// cryptic than the two hard cases. We do not parse/order versions
361/// here (that needs the build-container glibc too); we just flag when
362/// the remote's reported glibc line is worth the operator's eye.
363fn check_remote_abi(
364 host: &str,
365 controller_arch: &str,
366 remote_uname_m: &str,
367 remote_ldd: &str,
368) -> AbiCheck {
369 let remote_arch = remote_uname_m.trim();
370 if remote_arch.is_empty() {
371 // Couldn't read arch — treat as indeterminate, not a mismatch
372 // (see preflight_one_host's unreachable-is-a-warning discipline).
373 return AbiCheck::Ok {
374 warning: Some(format!(
375 "host {host:?}: could not read remote `uname -m`; skipping \
376 ABI pre-check (a real mismatch would surface at fan-out)"
377 )),
378 };
379 }
380 if remote_arch != controller_arch {
381 return AbiCheck::Incompatible(format!(
382 "host {host:?}: CPU arch mismatch — the pre-built binary is \
383 {controller_arch} (controller build env) but the remote is \
384 {remote_arch}. A cross-arch binary cannot exec (`Exec format \
385 error`). Run same-arch hosts, or build per-arch."
386 ));
387 }
388 let ldd_lc = remote_ldd.to_ascii_lowercase();
389 if ldd_lc.contains("musl") {
390 return AbiCheck::Incompatible(format!(
391 "host {host:?}: remote uses musl libc, but the pre-built binary \
392 is glibc-linked (flodl build images are glibc-based) and cannot \
393 run there. Match the libc (glibc remote), or build on the remote."
394 ));
395 }
396 // Soft glibc note: surface the remote's reported version line when we
397 // could read one, so a later `GLIBC_… not found` is unsurprising. No
398 // hard gate — build on the OLDEST-glibc host to be safe.
399 let looks_glibc = ldd_lc.contains("glibc") || ldd_lc.contains("gnu libc");
400 let warning = remote_ldd
401 .lines()
402 .next()
403 .map(str::trim)
404 .filter(|l| !l.is_empty() && looks_glibc)
405 .map(|l| {
406 format!(
407 "host {host:?}: remote glibc reports `{l}`. If the run later \
408 fails with `GLIBC_… not found`, the remote glibc is older \
409 than the controller build env — build on the oldest-glibc \
410 host."
411 )
412 });
413 AbiCheck::Ok { warning }
414}
415
416/// Evaluate a host's shared-data-path readiness from probe results.
417/// `dir_ok` = the path exists and is a directory; `read_ok` = it is
418/// readable/listable. `explicit` = the host (or cluster.yml) declared
419/// `data_path:` rather than falling back to the convention default.
420///
421/// Pure — no I/O — so it is directly unit-tested; the local (controller)
422/// and remote (ssh) paths in [`preflight_hosts`] / [`preflight_one_host`]
423/// supply `dir_ok` / `read_ok`. Mirrors `probe::check_data_path`'s
424/// policy: a missing EXPLICIT path is launch-breaking (`Err`); a missing
425/// CONVENTION-default is a warning (users without shared storage are
426/// fine); a present-but-unreadable path is always an error.
427fn check_remote_data_path(
428 host: &str,
429 path: &str,
430 explicit: bool,
431 dir_ok: bool,
432 read_ok: bool,
433) -> Result<Option<String>, String> {
434 if !dir_ok {
435 if explicit {
436 return Err(format!(
437 "host {host:?}: shared data path `{path}` does not exist (or is \
438 not a directory). flodl assumes a shared filesystem (NAS / SMB \
439 / virtiofs / SSHFS) mounted at the same logical path on every \
440 node — training reads data and writes checkpoints there. Mount \
441 it, or correct `data_path:` in cluster.yml."
442 ));
443 }
444 return Ok(Some(format!(
445 "host {host:?}: convention shared-data path `{path}` not present (no \
446 `data_path:` declared). Ignore if you don't use shared storage; \
447 otherwise set `data_path:` per host or mount `{path}`."
448 )));
449 }
450 if !read_ok {
451 return Err(format!(
452 "host {host:?}: shared data path `{path}` exists but is not readable \
453 by the remote user. Check mount permissions / uid mapping."
454 ));
455 }
456 Ok(None)
457}
458
459/// One remote host's pre-fan-out readiness probe over a single ssh.
460///
461/// ALWAYS checks the shared `data_path` is mounted + readable. When
462/// `prebuilding`, ALSO gathers `uname -m` + `ldd --version` + `pkill`
463/// availability and runs the [`check_remote_abi`] gate — the ABI check
464/// only matters when a controller-built binary is shipped to the remote
465/// (the prebuild path); under `--no-prebuild` the remote re-enters
466/// `fdl <cmd>` and builds/runs natively, so arch/libc can't mismatch.
467/// Returns collected warnings on success, `Err(msg)` on a definitive
468/// failure (ABI mismatch, or a missing EXPLICIT data_path).
469///
470/// UNREACHABLE-IS-A-WARNING: if the probe ssh itself fails (blip, host
471/// down), we return warnings and proceed — a transient probe failure
472/// must never make things worse than the status quo; the real fan-out
473/// ssh surfaces a genuine connectivity error anyway. The probe only ever
474/// ADDS loud errors for definitive mismatches / missing declared mounts.
475fn preflight_one_host(worker: &ClusterWorker, prebuilding: bool) -> Result<Vec<String>, String> {
476 let target = worker
477 .ssh
478 .as_ref()
479 .and_then(|s| s.target.as_deref())
480 .unwrap_or(&worker.host);
481 let dp = worker.effective_data_path().to_string();
482 let dp_explicit = worker.data_path.is_some();
483
484 // One round-trip. data_path tests always; the ABI block (uname / ldd
485 // banner — its version line lands on stderr for glibc, stdout for
486 // some, so capture both — plus a `pkill` availability probe) only
487 // when prebuilding. All sentinel-delimited.
488 let mut script = format!(
489 "if [ -d {q} ]; then echo __FLODL_DP_DIR__=1; else echo __FLODL_DP_DIR__=0; fi; \
490 if [ -r {q} ]; then echo __FLODL_DP_READ__=1; else echo __FLODL_DP_READ__=0; fi",
491 q = posix_quote(&dp),
492 );
493 if prebuilding {
494 script.push_str(
495 "; echo __FLODL_ABI__; uname -m; echo __FLODL_LDD__; \
496 ldd --version 2>&1 | head -1; echo __FLODL_PKILL__; \
497 command -v pkill >/dev/null 2>&1 && echo present || echo absent",
498 );
499 }
500
501 let mut cmd = Command::new("ssh");
502 // User ssh.options first (they win), then flodl's defaults (M17).
503 apply_worker_ssh_opts(&mut cmd, worker);
504 cmd.args(["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
505 cmd.arg(target);
506 cmd.arg(&script);
507 let output = match cmd.output() {
508 Ok(o) => o,
509 Err(e) => {
510 return Ok(vec![format!(
511 "host {:?}: remote pre-check ssh spawn failed ({e}); skipping \
512 (a real mismatch / missing mount would surface at fan-out)",
513 worker.host,
514 )]);
515 }
516 };
517 if !output.status.success() {
518 return Ok(vec![format!(
519 "host {:?}: remote pre-check ssh exited {}; skipping (a real \
520 mismatch / missing mount would surface at fan-out)",
521 worker.host, output.status,
522 )]);
523 }
524 let stdout = String::from_utf8_lossy(&output.stdout);
525
526 let mut warnings = Vec::new();
527 // data_path verdict (always). Exact-sentinel match: `=0` never
528 // matches `=1`, so a plain `contains` is unambiguous.
529 let dir_ok = stdout.contains("__FLODL_DP_DIR__=1");
530 let read_ok = stdout.contains("__FLODL_DP_READ__=1");
531 if let Some(w) = check_remote_data_path(&worker.host, &dp, dp_explicit, dir_ok, read_ok)? {
532 warnings.push(w);
533 }
534
535 // ABI verdict (prebuild path only).
536 if prebuilding {
537 let abi_part = stdout
538 .split_once("__FLODL_ABI__")
539 .map(|(_, r)| r)
540 .unwrap_or("");
541 let (uname_m, rest) = abi_part
542 .split_once("__FLODL_LDD__")
543 .unwrap_or((abi_part, ""));
544 let (ldd, pkill_field) = rest
545 .split_once("__FLODL_PKILL__")
546 .unwrap_or((rest, ""));
547 let controller_arch = std::env::consts::ARCH;
548 match check_remote_abi(&worker.host, controller_arch, uname_m, ldd) {
549 AbiCheck::Ok { warning } => warnings.extend(warning),
550 AbiCheck::Incompatible(msg) => return Err(msg),
551 }
552 // Only warn on an EXPLICIT "absent" — an empty/garbled field
553 // (older remote, parse hiccup) is indeterminate and must not
554 // false-warn, mirroring the empty-arch discipline in
555 // `check_remote_abi`.
556 if pkill_field.trim() == "absent" {
557 warnings.push(format!(
558 "host {:?}: `pkill` not found on the remote — belt-and-braces \
559 orphan cleanup is disabled; teardown relies solely on the \
560 per-host trap wrapper (kills by known pid, no external tool). \
561 Install procps if you want pre-spawn stale-orphan clearing.",
562 worker.host,
563 ));
564 }
565 }
566 Ok(warnings)
567}
568
569/// Collapse a libtorch `arch:` subpath (`precompiled/cu128`,
570/// `builds/sm61-sm120`) into a single path segment for use in the
571/// per-`(host, arch)` cargo target dir. Only the path separators need
572/// folding (`/` and, defensively, `\`); everything else in a libtorch
573/// variant name is already a safe path atom.
574fn arch_slug(arch: &str) -> String {
575 arch.chars()
576 .map(|c| if c == '/' || c == '\\' { '-' } else { c })
577 .collect()
578}
579
580fn prebuild_one_worker(
581 project_root: &Path,
582 cmd_cwd: &Path,
583 controller_path: &Path,
584 worker: &ClusterWorker,
585 cmd_name: &str,
586 controller_docker_svc: Option<&str>,
587) -> Result<PerHostEnvelope, String> {
588 let arch = worker.arch.as_ref().ok_or_else(|| {
589 format!(
590 "host {:?} has no `arch:` set in cluster.yml — \
591 pre-flight build needs the libtorch variant subpath \
592 (e.g. `arch: precompiled/cu128` or `arch: builds/sm61-sm120`)",
593 worker.host,
594 )
595 })?;
596 // Controller-side libtorch variant dir, resolved via convention.
597 let controller_variant_dir = controller_path.join("libtorch").join(arch);
598 if !controller_variant_dir.join("lib").is_dir() {
599 return Err(format!(
600 "host {:?}: controller-side libtorch at `{}` (resolved from \
601 `<controller_path>/libtorch/<arch>`) does not look like a \
602 valid libtorch install (missing `lib/`?)",
603 worker.host,
604 controller_variant_dir.display(),
605 ));
606 }
607 let host_path = controller_variant_dir.display().to_string();
608 // ABI + shared-mount pre-checks ran earlier in `preflight_hosts`
609 // (one ssh per host, before any build), so by the time we reach the
610 // multi-minute build the host is already known compatible + mounted.
611 // Derive features + docker service from the YAML-declared `arch:`
612 // basename — single source of truth, no `.arch` metadata file
613 // required. `cpu` is the only non-CUDA variant by convention; every
614 // other basename (`cuNN`, `sm<NN>-sm<NN>`, etc.) is a GPU build.
615 // `arch:` basename → cargo --features (`cuda` for GPU variants, none
616 // for `cpu`). The compose SERVICE is `controller.docker` (above),
617 // not arch-derived — the controller's toolchain builds every host.
618 let (features_arg, _) = features_and_service_from_arch(arch);
619 let cuda_version_for_image = cuda_version_from_arch(arch);
620 // Key the target dir by host AND arch. Host alone is not enough: the
621 // `arch:` field selects the libtorch variant, but in docker mode that
622 // variant is bind-mounted onto the FIXED container path
623 // (`/usr/local/libtorch`, `Dockerfile` ENV LIBTORCH_PATH), so
624 // `LIBTORCH_PATH` inside the container never changes when `arch:`
625 // does. Change a host's `arch:` to a different torch/CUDA build in the
626 // same feature category (e.g. `precompiled/cu118` -> `precompiled/cu128`,
627 // both `--features cuda`) and nothing cargo fingerprints changes:
628 // cargo would reuse the stale binary linked against the OLD libtorch,
629 // which then execs at fan-out with the NEW `LD_LIBRARY_PATH` -> ABI
630 // mismatch (`undefined symbol`) or silently-wrong kernels. An env-only
631 // nudge can't fix it: the mount path is fixed, so build.rs re-emits the
632 // same `-L` string and cargo never relinks. A distinct target dir per
633 // (host, arch) is the only thing that forces the rebuild. `arch` is a
634 // libtorch subpath (`precompiled/cu128`, `builds/sm61-sm120`); slugify
635 // its `/` so it is a single path segment.
636 let target_dir_relative =
637 format!("target/cluster/{}/{}", worker.host, arch_slug(arch));
638
639 // Two execution modes — docker-backed (controller has `docker:`
640 // set in cluster.yml) or native cargo on the host filesystem.
641 //
642 // Docker mode: the project root mounts at `/workspace`; cwd +
643 // CARGO_TARGET_DIR are in the `/workspace/...` namespace. Docker
644 // mode is gated on `controller.docker` being set, and that value
645 // IS the build service — the controller owns one build toolchain
646 // and compiles every host's binary in it (per-host libtorch comes
647 // from LIBTORCH_HOST_PATH, per-host features from `arch:`). The
648 // service must be a superset toolchain (`cuda` builds both CUDA and
649 // CPU binaries); a service that lacks the CUDA toolkit fails loudly
650 // at cargo build for a CUDA-arch worker, naming the chosen service.
651 //
652 // Native mode: cwd is the cmd's filesystem cwd, CARGO_TARGET_DIR
653 // is the same project-root-relative path on the host, and
654 // LIBTORCH_PATH is set directly on the cargo process (no Docker
655 // bind-mount indirection).
656 let (sh_cmd, cwd_for_spawn, extra_envs): (String, &Path, Vec<(&str, String)>) =
657 if let Some(docker_svc) = controller_docker_svc {
658 // Docker-backed build.
659 let target_dir_in_container = format!("/workspace/{target_dir_relative}");
660 let sub_path = cmd_cwd
661 .strip_prefix(project_root)
662 .map(|p| p.to_string_lossy().into_owned())
663 .unwrap_or_default();
664 let cwd_in_container = if sub_path.is_empty() {
665 "/workspace".to_string()
666 } else {
667 format!("/workspace/{sub_path}")
668 };
669 let build_cmd = if features_arg.is_empty() {
670 format!(
671 "cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --bin {bin}",
672 cwd = posix_quote(&cwd_in_container),
673 tgt = posix_quote(&target_dir_in_container),
674 bin = posix_quote(cmd_name),
675 )
676 } else {
677 format!(
678 "cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --features {feat} --bin {bin}",
679 cwd = posix_quote(&cwd_in_container),
680 tgt = posix_quote(&target_dir_in_container),
681 feat = posix_quote(features_arg),
682 bin = posix_quote(cmd_name),
683 )
684 };
685 let docker_cmd = format!(
686 "docker compose run --rm {svc} bash -c {inner}",
687 svc = docker_svc,
688 inner = posix_quote(&build_cmd),
689 );
690 (docker_cmd, project_root, vec![
691 ("LIBTORCH_HOST_PATH", host_path.clone()),
692 ("LIBTORCH_CPU_PATH", "./libtorch/precompiled/cpu".into()),
693 ])
694 } else {
695 // Native build (no docker on controller).
696 let target_dir_abs = project_root.join(&target_dir_relative);
697 let bash_cmd = if features_arg.is_empty() {
698 format!(
699 "cargo build --release --bin {bin}",
700 bin = posix_quote(cmd_name),
701 )
702 } else {
703 format!(
704 "cargo build --release --features {feat} --bin {bin}",
705 feat = posix_quote(features_arg),
706 bin = posix_quote(cmd_name),
707 )
708 };
709 (bash_cmd, cmd_cwd, vec![
710 ("LIBTORCH_PATH", host_path.clone()),
711 (
712 "CARGO_TARGET_DIR",
713 target_dir_abs.to_string_lossy().into_owned(),
714 ),
715 ])
716 };
717
718 let mut cmd = Command::new("sh");
719 cmd.args(["-c", &sh_cmd])
720 .current_dir(cwd_for_spawn)
721 .stdout(Stdio::inherit())
722 .stderr(Stdio::inherit())
723 .stdin(Stdio::null());
724 for (k, v) in &extra_envs {
725 cmd.env(k, v);
726 }
727
728 if let Some(cuda_version) = &cuda_version_for_image {
729 let normalised = if cuda_version.matches('.').count() < 2 {
730 format!("{cuda_version}.0")
731 } else {
732 cuda_version.clone()
733 };
734 let cuda_tag = normalised
735 .splitn(3, '.')
736 .take(2)
737 .collect::<Vec<_>>()
738 .join(".");
739 cmd.env("CUDA_VERSION", &normalised);
740 cmd.env("CUDA_TAG", &cuda_tag);
741 }
742
743 let status = cmd
744 .status()
745 .map_err(|e| format!("spawn `{sh_cmd}`: {e}"))?;
746 if !status.success() {
747 return Err(format!(
748 "cargo build exited {} (libtorch={host_path}, target={target_dir_relative}, \
749 features={feat})",
750 status.code().unwrap_or(-1),
751 feat = if features_arg.is_empty() { "(none)" } else { features_arg },
752 ));
753 }
754 // Runtime LD_LIBRARY_PATH uses the REMOTE-side view: the rank
755 // exec's the binary on the remote, where libtorch is at
756 // `<worker.path>/libtorch/<arch>/lib` per the convention.
757 let runtime_lib = format!(
758 "{path}/libtorch/{arch}/lib",
759 path = worker.path.trim_end_matches('/'),
760 );
761 let _ = host_path; // controller-side path used only for the build above
762 // cwd_subpath: the cmd's filesystem cwd relative to project_root.
763 // For `fdl ddp-bench` invoked from the repo, cmd_cwd is
764 // `<repo>/ddp-bench`, so subpath is `ddp-bench`. The remote
765 // launcher uses this to cd into the matching subdir before exec.
766 let cwd_subpath = cmd_cwd
767 .strip_prefix(project_root)
768 .map(|p| p.to_string_lossy().into_owned())
769 .unwrap_or_default();
770 Ok(PerHostEnvelope {
771 bin: format!("{target_dir_relative}/release/{cmd_name}"),
772 ld_library_path: runtime_lib,
773 cwd_subpath,
774 })
775}
776
777/// Pick cargo features + docker compose service from the host's
778/// libtorch `.arch` metadata. `cuda=12.x` → (`cuda`, `cuda`); anything
779/// else → (`""`, `dev`).
780/// Derive `(cargo --features arg, docker-compose service name)` from
781/// the YAML `arch:` path basename. The yml `arch:` IS the single
782/// source of truth (no `.arch` metadata file required) — `cpu` is the
783/// only non-CUDA convention; everything else is a GPU variant.
784fn features_and_service_from_arch(arch: &str) -> (&'static str, &'static str) {
785 let basename = std::path::Path::new(arch)
786 .file_name()
787 .and_then(|n| n.to_str())
788 .unwrap_or("");
789 if basename == "cpu" {
790 ("", "dev")
791 } else {
792 ("cuda", "cuda")
793 }
794}
795
796/// Extract a CUDA major.minor string from a `precompiled/cuNN` arch
797/// path basename (e.g. `cu128` → `"12.8"`). Returns `None` for source
798/// builds (`builds/sm…`) where the arch alone does not encode a CUDA
799/// version — the caller falls back to the `CUDA_VERSION` env var (or
800/// docker-compose's own default) for the toolkit image tag.
801fn cuda_version_from_arch(arch: &str) -> Option<String> {
802 let basename = std::path::Path::new(arch)
803 .file_name()
804 .and_then(|n| n.to_str())
805 .unwrap_or("");
806 let rest = basename.strip_prefix("cu")?;
807 if rest.len() < 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
808 return None;
809 }
810 let major = &rest[..rest.len() - 1];
811 let minor = &rest[rest.len() - 1..];
812 Some(format!("{major}.{minor}"))
813}
814
815use crate::util::shell::posix_quote;
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn abi_arch_mismatch_is_hard_incompatible() {
823 let r = check_remote_abi("gv", "x86_64", "aarch64", "ldd (GNU libc) 2.31");
824 assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
825 }
826
827 #[test]
828 fn data_path_present_and_readable_is_clean() {
829 assert!(check_remote_data_path("h", "/flodl/data", true, true, true)
830 .expect("ok")
831 .is_none());
832 }
833
834 #[test]
835 fn data_path_missing_explicit_is_hard_error() {
836 let err = check_remote_data_path("h", "/mnt/nas", true, false, false)
837 .expect_err("explicit missing must be a hard error");
838 assert!(err.contains("does not exist"), "err: {err}");
839 assert!(err.contains("/mnt/nas"), "err: {err}");
840 }
841
842 #[test]
843 fn data_path_missing_convention_default_is_warning() {
844 // explicit=false -> convention default /flodl/data not present ->
845 // warning (Ok), not a launch-breaking error.
846 let w = check_remote_data_path("h", "/flodl/data", false, false, false)
847 .expect("convention-default missing must be Ok(warning)")
848 .expect("should carry a warning");
849 assert!(w.contains("convention"), "warn: {w}");
850 }
851
852 #[test]
853 fn data_path_present_but_unreadable_is_hard_error() {
854 let err = check_remote_data_path("h", "/flodl/data", false, true, false)
855 .expect_err("present-but-unreadable must be a hard error regardless of explicit");
856 assert!(err.contains("not readable"), "err: {err}");
857 }
858
859 #[test]
860 fn abi_musl_is_hard_incompatible_on_matching_arch() {
861 let r = check_remote_abi("alp", "x86_64", "x86_64", "musl libc (x86_64)\nVersion 1.2.4");
862 assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("musl")));
863 }
864
865 #[test]
866 fn abi_matching_arch_glibc_ok_with_version_note() {
867 let r = check_remote_abi(
868 "w", "x86_64", "x86_64",
869 "ldd (Ubuntu GLIBC 2.35-0ubuntu3.4) 2.35",
870 );
871 match r {
872 AbiCheck::Ok { warning: Some(w) } => {
873 assert!(w.contains("2.35"), "warning should quote the reported line: {w}");
874 }
875 other => panic!("expected Ok+warning, got {other:?}"),
876 }
877 }
878
879 #[test]
880 fn abi_empty_uname_is_indeterminate_not_mismatch() {
881 let r = check_remote_abi("w", "x86_64", "", "");
882 assert!(matches!(r, AbiCheck::Ok { warning: Some(_) }));
883 }
884
885 #[test]
886 fn abi_arch_checked_before_musl() {
887 let r = check_remote_abi("x", "x86_64", "aarch64", "musl libc");
888 assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
889 }
890
891 #[test]
892 fn abi_matching_arch_no_ldd_ok_no_warning() {
893 let r = check_remote_abi("w", "x86_64", "x86_64", "");
894 assert_eq!(r, AbiCheck::Ok { warning: None });
895 }
896
897 #[test]
898 fn features_and_service_precompiled_cuda_picks_cuda() {
899 assert_eq!(
900 features_and_service_from_arch("precompiled/cu128"),
901 ("cuda", "cuda")
902 );
903 }
904
905 #[test]
906 fn features_and_service_precompiled_cpu_picks_dev() {
907 assert_eq!(
908 features_and_service_from_arch("precompiled/cpu"),
909 ("", "dev")
910 );
911 }
912
913 #[test]
914 fn features_and_service_source_build_picks_cuda() {
915 // Source builds under `builds/<gpu-arch>` are CUDA by
916 // convention; only `cpu` basename is non-CUDA.
917 assert_eq!(
918 features_and_service_from_arch("builds/sm61-sm120"),
919 ("cuda", "cuda")
920 );
921 assert_eq!(
922 features_and_service_from_arch("builds/sm80"),
923 ("cuda", "cuda")
924 );
925 }
926
927 #[test]
928 fn cuda_version_from_arch_extracts_precompiled_version() {
929 assert_eq!(cuda_version_from_arch("precompiled/cu128"), Some("12.8".into()));
930 assert_eq!(cuda_version_from_arch("precompiled/cu126"), Some("12.6".into()));
931 assert_eq!(cuda_version_from_arch("precompiled/cu118"), Some("11.8".into()));
932 }
933
934 #[test]
935 fn cuda_version_from_arch_none_for_source_builds_and_cpu() {
936 assert_eq!(cuda_version_from_arch("builds/sm61-sm120"), None);
937 assert_eq!(cuda_version_from_arch("builds/sm80"), None);
938 assert_eq!(cuda_version_from_arch("precompiled/cpu"), None);
939 }
940
941 #[test]
942 fn arch_slug_folds_path_separators_only() {
943 // A change of `arch:` must yield a DISTINCT slug so the per-host
944 // target dir keys on it — otherwise a docker-mode arch swap reuses
945 // a stale binary (M24). Different variants -> different slugs.
946 assert_eq!(arch_slug("precompiled/cu128"), "precompiled-cu128");
947 assert_eq!(arch_slug("precompiled/cu118"), "precompiled-cu118");
948 assert_ne!(arch_slug("precompiled/cu128"), arch_slug("precompiled/cu118"));
949 assert_eq!(arch_slug("builds/sm61-sm120"), "builds-sm61-sm120");
950 // Single-segment archs pass through unchanged.
951 assert_eq!(arch_slug("cpu"), "cpu");
952 }
953
954 #[test]
955 fn posix_quote_round_trips_safe_strings() {
956 assert_eq!(posix_quote("ddp-bench"), "ddp-bench");
957 assert_eq!(posix_quote("target/cluster/exa"), "target/cluster/exa");
958 assert_eq!(posix_quote(""), "''");
959 }
960
961 #[test]
962 fn posix_quote_wraps_unsafe_strings() {
963 assert_eq!(posix_quote("a b"), "'a b'");
964 assert_eq!(posix_quote("it's"), "'it'\\''s'");
965 }
966
967 #[test]
968 fn envelope_serializes_to_stable_json() {
969 let mut env = BTreeMap::new();
970 env.insert(
971 "host-b".to_string(),
972 PerHostEnvelope {
973 bin: "target/cluster/host-b/release/bench".into(),
974 ld_library_path: "/opt/lt-b/lib".into(),
975 cwd_subpath: String::new(),
976 },
977 );
978 env.insert(
979 "host-a".to_string(),
980 PerHostEnvelope {
981 bin: "target/cluster/host-a/release/bench".into(),
982 ld_library_path: "/opt/lt-a/lib".into(),
983 cwd_subpath: String::new(),
984 },
985 );
986 let json = serde_json::to_string(&env).unwrap();
987 // BTreeMap iterates in sorted key order ⇒ stable JSON output
988 // regardless of insertion order.
989 assert_eq!(
990 json,
991 r#"{"host-a":{"bin":"target/cluster/host-a/release/bench","ld_library_path":"/opt/lt-a/lib"},"host-b":{"bin":"target/cluster/host-b/release/bench","ld_library_path":"/opt/lt-b/lib"}}"#,
992 );
993 }
994
995 #[test]
996 fn envelope_round_trips_through_serde() {
997 let mut env = BTreeMap::new();
998 env.insert(
999 "h1".to_string(),
1000 PerHostEnvelope {
1001 bin: "t/c/h1/release/x".into(),
1002 ld_library_path: "/opt/lt/lib".into(),
1003 cwd_subpath: "ddp-bench".into(),
1004 },
1005 );
1006 let json = serde_json::to_string(&env).unwrap();
1007 let back: BTreeMap<String, PerHostEnvelope> =
1008 serde_json::from_str(&json).unwrap();
1009 assert_eq!(back.len(), 1);
1010 let e = back.get("h1").unwrap();
1011 assert_eq!(e.bin, "t/c/h1/release/x");
1012 assert_eq!(e.ld_library_path, "/opt/lt/lib");
1013 }
1014}