flodl_cli/config/cluster.rs
1//! DDP + cluster configuration types: DdpConfig, SpeedHint,
2//! TrainingConfig, OutputConfig, ClusterConfig, ClusterController,
3//! LocalDevices, ClusterWorker + the `ClusterConfig` impl block.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// DDP configuration. Maps 1:1 to flodl DdpConfig / DdpRunConfig.
9#[derive(Debug, Clone, Default, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct DdpConfig {
12 pub mode: Option<String>,
13 pub policy: Option<String>,
14 pub backend: Option<String>,
15 /// "auto" or integer.
16 pub anchor: Option<serde_json::Value>,
17 pub max_anchor: Option<u32>,
18 pub overhead_target: Option<f64>,
19 pub divergence_threshold: Option<f64>,
20 /// null (unlimited) or integer.
21 pub max_batch_diff: Option<serde_json::Value>,
22 pub speed_hint: Option<SpeedHint>,
23 pub partition_ratios: Option<Vec<f64>>,
24 /// "auto" or bool.
25 pub progressive: Option<serde_json::Value>,
26 pub max_grad_norm: Option<f64>,
27 pub lr_scale_ratio: Option<f64>,
28 pub snapshot_timeout: Option<u32>,
29 pub checkpoint_every: Option<u32>,
30 pub timeline: Option<bool>,
31}
32
33#[derive(Debug, Clone, Default, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct SpeedHint {
36 pub slow_rank: usize,
37 pub ratio: f64,
38}
39
40/// Training scalars.
41#[derive(Debug, Clone, Default, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TrainingConfig {
44 pub epochs: Option<u32>,
45 pub batch_size: Option<u32>,
46 pub batches_per_epoch: Option<u32>,
47 pub lr: Option<f64>,
48 pub seed: Option<u64>,
49}
50
51/// Output settings.
52#[derive(Debug, Clone, Default, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct OutputConfig {
55 pub dir: Option<String>,
56 pub timeline: Option<bool>,
57 pub monitor: Option<u16>,
58}
59
60// ── Cluster topology (multi-host DDP) ───────────────────────────────────
61
62/// Default port for the controller's rendezvous bind. Overridable via
63/// `cluster.controller.port:` in fdl.cluster.yml.
64pub const DEFAULT_CONTROLLER_PORT: u16 = 1337;
65
66fn default_controller_port() -> u16 {
67 DEFAULT_CONTROLLER_PORT
68}
69
70/// Cluster topology, parsed from the `cluster:` block at the project
71/// root. Two role-separated sub-blocks:
72///
73/// - `controller`: the orchestrator host fdl-cli runs on. Holds the
74/// rendezvous bind point (`host`/`port`) plus the controller-local
75/// fields needed for pre-flight build and the ClusterController TCP
76/// listener. The controller is NOT a NCCL rank.
77/// - `workers`: every rank-carrying host. Each entry binds one or more
78/// global ranks and their CUDA device indices.
79///
80/// This is the controller-side full topology; per-worker slim
81/// envelopes are derived from it and shipped to each node, where the
82/// library reads them via `flodl::distributed::LocalCluster::from_env`.
83/// Launcher-only fields (`ssh:`) are not propagated to the envelope.
84///
85/// The library re-validates after reading; this validation runs earlier
86/// so errors surface before `fdl-cli` opens any SSH connection.
87#[derive(Debug, Clone, Deserialize, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct ClusterConfig {
90 pub controller: ClusterController,
91 pub workers: Vec<ClusterWorker>,
92 /// Cluster-scope env vars exported into every rank child on every
93 /// worker. Mapping `NAME: VALUE` (string→string). Use for tuning
94 /// the launcher itself shouldn't hardcode — e.g. on the Pascal-
95 /// under-VFIO rig, set `NCCL_P2P_DISABLE: "1"` +
96 /// `NCCL_SHM_DISABLE: "1"` so NCCL falls back to socket transport
97 /// (the direct-IPC transports fail under VFIO on consumer Pascal).
98 /// Worker-scope [`ClusterWorker::env`] takes precedence for matching
99 /// keys.
100 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
101 pub env: std::collections::BTreeMap<String, String>,
102 /// Cluster-scope default for the integrated-GPU host-RAM share: the
103 /// fraction of `MemTotal` an APU host's GPU aperture claims (the
104 /// library's `gpu_ram_share` knob). Discrete-GPU hosts ignore it,
105 /// which is what makes a fleet-wide default legal on a mixed fleet;
106 /// the case it exists for is a farm of identical APU boxes, where
107 /// one line here covers walk-ins the controller never enumerates.
108 /// Per-worker [`ClusterWorker::gpu_ram_share`] overrides it, and a
109 /// walk-in's own `join.gpu_ram_share:` overrides both (host truth
110 /// wins). Non-negative fraction of `MemTotal` (above 1.0 is legal
111 /// where the platform under-states the aperture); unset ships
112 /// nothing.
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub gpu_ram_share: Option<f64>,
115}
116
117/// Controller-side cluster config. Holds the rendezvous bind point and
118/// pre-flight build context. The controller is the orchestrator host
119/// fdl-cli runs on; it is NOT a NCCL rank itself.
120///
121/// Rejected fields (via `deny_unknown_fields`): `ranks`,
122/// `local_devices`, `ssh*` (controller is local to fdl-cli),
123/// per-controller `env` (use cluster-scope `env:` instead) — and any
124/// mistyped key.
125#[derive(Debug, Clone, Deserialize, Serialize)]
126#[serde(deny_unknown_fields)]
127pub struct ClusterController {
128 /// Bind address. Used as the rendezvous endpoint workers dial.
129 /// What was `cluster.controller.host` in the pre-Refactor-2 schema.
130 pub host: String,
131 /// Bind port. What was `cluster.controller.port` in the pre-Refactor-2
132 /// schema. Defaults to [`DEFAULT_CONTROLLER_PORT`] when omitted.
133 #[serde(default = "default_controller_port")]
134 pub port: u16,
135 /// Controller's view of the shared project root. Used by the pre-
136 /// flight build phase to drive cargo invocations from the
137 /// controller's filesystem perspective; the remote-side path (each
138 /// worker's [`ClusterWorker::path`]) is used at runtime via SSH.
139 ///
140 /// On homogeneous-mount rigs the controller's view equals every
141 /// worker's view; on heterogeneous rigs (different mount points
142 /// per host), the controller's value diverges (e.g. the controller
143 /// sees `/opt/flodl` while a VM worker sees `/mnt/flodl`).
144 pub path: String,
145 /// Docker compose service the controller runs the pre-flight build
146 /// inside (e.g. `cuda`, `dev`). Optional; affects build context
147 /// only.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub docker: Option<String>,
150 /// libtorch variant subpath under `<path>/libtorch/` for the
151 /// pre-flight build (e.g. `precompiled/cu128`,
152 /// `builds/sm61-sm120`). When unset, fdl-cli falls back to the
153 /// controller's project-root `.active` file.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub arch: Option<String>,
156 /// Dataset source root visible to the controller: training data,
157 /// model checkpoints and per-rank logs. Usually a filesystem
158 /// reachable at the same logical path on every node, which is the
159 /// recommended shape; a node-local directory is legal, and then
160 /// each host holds its own copy. When absent, the convention
161 /// default [`DEFAULT_DATA_PATH`] applies to the checks (`fdl probe`,
162 /// pre-flight) but is NOT shipped to a run — see
163 /// [`ClusterWorker::data_path`].
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub data_path: Option<String>,
166 /// Join-window quorum knobs (dial-in membership). fdl-cli carries
167 /// the block through the launcher envelope verbatim; flodl owns
168 /// validation and the capacity-derived defaults.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub join: Option<ClusterJoin>,
171}
172
173/// `controller.join:` sub-block — per-field overrides for the
174/// membership window. All fields optional; unset keeps flodl's fan-out
175/// defaults (quorum = early-close target = configured capacity, window
176/// 300s, hard cap 600s, pre-shared-salt admission).
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct ClusterJoin {
180 /// Quorum in ranks — the run cannot start below it.
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub min_rank_start: Option<usize>,
183 /// Join window in seconds; quorum reached early does NOT close it.
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub join_timeout: Option<u64>,
186 /// Early-close target in ranks: the window closes the moment this
187 /// many are in.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub target_ranks: Option<usize>,
190 /// Hard cap in seconds: quorum still unmet when it expires fails
191 /// the run loudly.
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub max_join_timeout: Option<u64>,
194 /// Accept joins without pre-shared-salt authentication on a
195 /// non-loopback bind (loudly warned by flodl).
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub open_admission: Option<bool>,
198 /// Roster-free formation: the join window alone defines the world,
199 /// so `workers:` may be empty (walk-ins self-register via `fdl
200 /// join`). Requires an explicit `min_rank_start` — there is no
201 /// configured capacity to derive the quorum from.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub discovery: Option<bool>,
204 /// Pre-shared session salt, hex (32 chars / 16 bytes). Injected at
205 /// fleet-create time and presented by walk-ins as their join
206 /// credential; forces authenticated admission even behind sshd.
207 /// Mutually exclusive with `open_admission: true`.
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub token: Option<String>,
210 /// Discovery-only: bind the controller loopback-only so every
211 /// walk-in must arrive through an sshd-carried forward
212 /// (reachability = authentication). Requires a CPU averaging mode.
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub tunnel_only: Option<bool>,
215 /// Who closes the window once quorum is met: `auto` (clock —
216 /// target/expiry, the default), `manual` (only the operator, via
217 /// `fdl start`; refuses `target_ranks`), or `hybrid` (clock, and
218 /// the operator may fire earlier). flodl owns the semantics; fdl
219 /// validates the value so a typo dies before any host is touched.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub start: Option<String>,
222}
223
224impl ClusterController {
225 /// Effective shared-data path: `data_path` if set, else
226 /// [`DEFAULT_DATA_PATH`].
227 pub fn effective_data_path(&self) -> &str {
228 self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
229 }
230}
231
232/// Top-level `join:` block — the worker-side counterpart of
233/// `controller.join:`. Carries defaults for `fdl join` so a golden
234/// image (or a systemd unit) can bake the whole dial-in recipe into
235/// config and run bare `fdl join`. Every field is overridable on the
236/// command line; flags win over this block.
237#[derive(Debug, Clone, Default, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct WorkerJoin {
240 /// Controller mux address, `host[:port]` (default port 1337).
241 /// Under `ssh:` this is the address as seen FROM the ssh host —
242 /// usually loopback, the guardrailed-sshd-on-the-controller-box
243 /// case.
244 #[serde(default)]
245 pub controller: Option<String>,
246 /// SSH tunnel hop: `fdl join` brings up a local `-L` forward of
247 /// the controller mux port through this host and dials loopback.
248 /// Same shape as a worker's `ssh:` sub-block (target / port /
249 /// user / identity_file / options).
250 #[serde(default)]
251 pub ssh: Option<SshConfig>,
252 /// Pre-shared session credential (the run's
253 /// `controller.join.token`), hex. Unset = open admission (the
254 /// controller hands the salt out in the accept reply).
255 #[serde(default)]
256 pub token: Option<String>,
257 /// Training binary to run in agent role, as a path on this box: use
258 /// it as given. Mutually exclusive with `source:`, and one of the
259 /// two is required — the binary IS the protocol (it dials, joins,
260 /// and spawns this host's relay + rank children itself), so `fdl
261 /// join` cannot default it.
262 ///
263 /// This is also the escape hatch for a box whose owner wants the
264 /// last word on what it compiles and runs: a declared binary wins
265 /// over any source a controller would hand it.
266 #[serde(default)]
267 pub bin: Option<String>,
268 /// Build the training binary here instead of naming one. The box
269 /// fetches the tree to local disk and compiles it against its OWN
270 /// libtorch, which is what makes the ABI match by construction
271 /// rather than by manifest discipline.
272 #[serde(default)]
273 pub source: Option<WorkerSource>,
274 /// libtorch variant to acquire before building or running:
275 /// `auto`, `cpu`, `cu126`, `cu128`, `rocm7.0`. It lands under
276 /// `~/.flodl/libtorch/` (never the project tree, which on a walk-in
277 /// is often a read-only mount) and becomes this box's active
278 /// variant. Unset leaves the box on whatever it already has.
279 ///
280 /// `auto` is the fleet value: it routes on the devices THIS box has,
281 /// so one golden image serves NVIDIA and AMD instances.
282 #[serde(default)]
283 pub libtorch: Option<String>,
284 /// Logical host name presented in the join hello (default: this
285 /// machine's hostname).
286 #[serde(default)]
287 pub host: Option<String>,
288 /// GPU device indices to offer, one rank each (default: every
289 /// device detection sees for the training build's vendor).
290 #[serde(default)]
291 pub devices: Option<Vec<u8>>,
292 /// Keep dialing across runs: when the agent exits (run finished,
293 /// controller gone, no window open yet) `fdl join` backs off and
294 /// re-dials instead of exiting. The systemd / golden-image mode.
295 #[serde(default)]
296 pub persist: bool,
297 /// Dataset source root on THIS box — a local path, the same role
298 /// `cluster.workers[].data_path` plays for a fan-out host. `fdl
299 /// join` verifies it before dialing and ships it to this host's
300 /// ranks, so the training binary needs no data flag. Unset ships
301 /// nothing: the binary keeps its own default.
302 ///
303 /// With `data_source:` set this is the MOUNTPOINT, defaulting to
304 /// [`DEFAULT_DATA_PATH`].
305 #[serde(default)]
306 pub data_path: Option<String>,
307 /// Transport that establishes `data_path:` when it is not already
308 /// there, `<scheme>://<target>`. One scheme ships today:
309 ///
310 /// ```text
311 /// sshfs://[user@]host[:port]/abs/path
312 /// sshfs://[user@]host:/abs/path # the scp spelling, same thing
313 /// ```
314 ///
315 /// A source already mounted by provisioning needs nothing here —
316 /// name its path in `data_path:` and leave this unset. The mount
317 /// goes up READ-ONLY: a rank reads the source root and never
318 /// writes it, so the kernel, not a convention, enforces that.
319 #[serde(default)]
320 pub data_source: Option<String>,
321 /// Integrated-GPU host-RAM share for THIS box: the fraction of
322 /// `MemTotal` its GPU aperture claims (the library's
323 /// `gpu_ram_share` knob; discrete GPUs ignore it). Same role as a
324 /// fan-out host's `cluster.workers[].gpu_ram_share`, riding the
325 /// same envelope road: the agent writes it into this host's block
326 /// at join time, where it overrides any cluster-scope default the
327 /// controller stamped. Non-negative fraction of `MemTotal` (above
328 /// 1.0 is legal where the platform under-states the aperture);
329 /// unset ships nothing.
330 #[serde(default)]
331 pub gpu_ram_share: Option<f64>,
332 /// Probe the training binary for its model signature before each
333 /// dial (default: on). The probe re-runs the binary with
334 /// `FLODL_INTERNAL_MODEL_SIG_PROBE` set; it builds its model on
335 /// CPU, prints the signature and exits, and admission can then
336 /// refuse a box building a different model without costing the
337 /// run. `false` skips it — the formation-time check remains — for
338 /// a binary whose startup is too heavy to run twice per dial, or
339 /// one built against a flodl that predates the probe (which would
340 /// otherwise run its whole `main` until the probe timeout kills
341 /// it).
342 #[serde(default)]
343 pub sig_probe: Option<bool>,
344 /// Arguments for the training binary (the binary's own training
345 /// flags). A `--` tail on the command line replaces this list.
346 #[serde(default)]
347 pub args: Vec<String>,
348}
349
350/// `join.source:` — build the training binary on this box.
351///
352/// A tree always lands on LOCAL disk before it is built, whatever the
353/// transport: cargo fingerprints by stat'ing every source file on every
354/// invocation, so compiling over a network mount pays that latency
355/// thousands of times, and the attribute caching that would hide the
356/// latency makes cargo serve a stale binary. The fetch preserves mtimes,
357/// which is what keeps the loop incremental rather than a cold rebuild
358/// wearing an incremental costume.
359#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct WorkerSource {
362 /// Transport plus location. Three ship:
363 ///
364 /// ```text
365 /// file:///abs/path a directory on this box (a mount, a disk)
366 /// rsync://[user@]host[:port]:/abs/path a working tree over ssh
367 /// git+https://host/owner/repo#<ref> a pinned checkout (also git+ssh://)
368 /// ```
369 ///
370 /// rsync is the one that carries UNCOMMITTED work, which is what a
371 /// training crate living in no repo at all needs. `git+` wants a ref
372 /// (`#<tag|branch|sha>`) because a default branch floats, and a
373 /// floating ref is not a pin.
374 pub from: String,
375 /// Project directory inside the fetched tree (default: its root).
376 /// Governs the build and the run both, so it answers "where is the
377 /// project in this tree" exactly once. A path dep pointing outside
378 /// this directory still resolves, which is why the fetched tree has
379 /// to be the dep root and not just the crate.
380 #[serde(default)]
381 pub cwd: Option<String>,
382 /// Build recipe, run as a shell line in `cwd`. Default: `cargo build
383 /// --release`, which is right whenever the crate carries its own
384 /// `Cargo.toml`, lockfile and `rust-toolchain.toml` — the usual case
385 /// for a crate that builds on the operator's own box.
386 ///
387 /// It can be a script committed beside the code (`./ci/node-build.sh`),
388 /// so the recipe travels with the source while its invocation stays
389 /// here. fdl exports what it resolved: `LIBTORCH_PATH`,
390 /// `FDL_GPU_FEATURE` (say `--features "$FDL_GPU_FEATURE"` rather than
391 /// naming a vendor) and `LD_LIBRARY_PATH`.
392 ///
393 /// It re-runs on every dial, so it must be cheap when nothing
394 /// changed. cargo is; a recipe that rebuilds unconditionally is not.
395 #[serde(default)]
396 pub build: Option<String>,
397 /// Built artifact, relative to `cwd` (e.g. `target/release/train`).
398 /// Not guessed: `cargo build` in a workspace member writes to the
399 /// WORKSPACE `target/`, not the member's, so any rule fdl invented
400 /// would be wrong for someone.
401 ///
402 /// Optional because a published tree carries a run manifest that
403 /// names it, and that manifest is the authority when present. Declare
404 /// it here for a box the operator drives by hand.
405 #[serde(default)]
406 pub bin: Option<String>,
407}
408
409/// Top-level `publish:` block — the controller's standing answers for
410/// `fdl publish`, so re-publishing a run is one bare command. Every
411/// field mirrors a flag 1:1 and the FLAG WINS when both are given; a
412/// `--` tail replaces `args:` outright (even an empty tail, because
413/// "explicitly none" must be sayable — the args belong to the RUN).
414///
415/// `--no-build` has no field here ON PURPOSE: a standing config that
416/// silently skips the gate would ship every future typo to the fleet.
417/// The escape hatch stays a per-invocation decision.
418#[derive(Debug, Clone, Default, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct PublishBlock {
421 /// Source to publish, same grammar as `join.source.from:`
422 /// (`file://`, `rsync://`, `git+https://…#<ref>`).
423 #[serde(default)]
424 pub source: Option<String>,
425 /// Built artifact, relative to the project directory — what workers
426 /// run (`--bin`).
427 #[serde(default)]
428 pub bin: Option<String>,
429 /// Project directory inside the tree (`--cwd`; default: its root).
430 #[serde(default)]
431 pub cwd: Option<String>,
432 /// Build recipe for the gate, a shell line (`--build`; default
433 /// `cargo build --release`).
434 #[serde(default)]
435 pub build: Option<String>,
436 /// Served directory (`--to`; default `~/.flodl/run`).
437 #[serde(default)]
438 pub to: Option<String>,
439 /// Identity file for a source pulled over ssh (`--identity`).
440 #[serde(default)]
441 pub identity: Option<String>,
442 /// The run's own arguments — what rank children re-enter the binary
443 /// with. A command-line `--` tail replaces this list.
444 #[serde(default)]
445 pub args: Vec<String>,
446}
447
448/// CUDA device indices on a host. Either explicit (a list of indices) or
449/// the `"all"` shorthand (auto-detect at startup on that host).
450///
451/// `local_devices: all` defers device-index resolution to startup on
452/// whichever host the value lives on. The host uses `gpu_device_count()`
453/// and binds devices `0..ranks.len()` (sequential from index 0). The
454/// detected count must be at least as large as `ranks.len()`, otherwise
455/// rendezvous errors loudly.
456///
457/// `local_devices: [0, 1]` is the explicit form; same as before this
458/// shorthand existed.
459///
460/// Symmetric: works for both the controller host and remote nodes. Pair
461/// with explicit `ranks: [N, N+1, ...]` to define the world topology;
462/// `local_devices` only selects which device indices on this host map to
463/// those ranks.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum LocalDevices {
466 /// Auto-detect at startup on this host. Indices `0..ranks.len()` bound.
467 All,
468 /// Explicit list of CUDA device indices, paired positionally with ranks.
469 Explicit(Vec<u8>),
470}
471
472impl LocalDevices {
473 /// True for the auto-detect form (unresolved).
474 pub fn is_all(&self) -> bool {
475 matches!(self, LocalDevices::All)
476 }
477
478 /// Explicit indices as a slice, or `None` for `All`.
479 pub fn as_explicit(&self) -> Option<&[u8]> {
480 match self {
481 LocalDevices::All => None,
482 LocalDevices::Explicit(v) => Some(v.as_slice()),
483 }
484 }
485}
486
487impl Serialize for LocalDevices {
488 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
489 match self {
490 LocalDevices::All => s.serialize_str("all"),
491 LocalDevices::Explicit(v) => v.serialize(s),
492 }
493 }
494}
495
496impl<'de> Deserialize<'de> for LocalDevices {
497 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
498 use serde::de::Error;
499 let v = Value::deserialize(d)?;
500 match v {
501 Value::String(s) if s == "all" => Ok(LocalDevices::All),
502 Value::String(s) => Err(D::Error::custom(format!(
503 "local_devices: expected \"all\" or array of device indices, got string {s:?}"
504 ))),
505 Value::Array(arr) => {
506 let mut out = Vec::with_capacity(arr.len());
507 for (i, item) in arr.iter().enumerate() {
508 let n = item.as_u64().ok_or_else(|| {
509 D::Error::custom(format!(
510 "local_devices[{i}]: expected integer device index, got {item}"
511 ))
512 })?;
513 let d = u8::try_from(n).map_err(|_| {
514 D::Error::custom(format!(
515 "local_devices[{i}]: value {n} does not fit in u8"
516 ))
517 })?;
518 out.push(d);
519 }
520 Ok(LocalDevices::Explicit(out))
521 }
522 _ => Err(D::Error::custom(
523 "local_devices: expected \"all\" or array of device indices",
524 )),
525 }
526 }
527}
528
529/// SSH endpoint configuration for a remote worker host.
530///
531/// fdl-cli parser side; mirrors `flodl::distributed::launcher::SshConfig`
532/// shape so the slim envelope round-trips cleanly into the launcher. All
533/// fields optional; absent fields fall back to system ssh defaults or
534/// `~/.ssh/config` rules.
535#[derive(Debug, Clone, Default, Deserialize, Serialize)]
536#[serde(deny_unknown_fields)]
537pub struct SshConfig {
538 /// SSH target hostname / IP / alias. Defaults to the worker's
539 /// `host` field when unset.
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub target: Option<String>,
542 /// SSH port (`ssh -p <port>`).
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub port: Option<u16>,
545 /// SSH login user (`ssh -l <user>`). Defaults to the current user
546 /// (or `FLODL_INTERNAL_HOST_USER` from the controller env).
547 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub user: Option<String>,
549 /// Identity file / private key (`ssh -i <path>`).
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub identity_file: Option<String>,
552 /// Pass-through `-o Key=Value` SSH options (e.g.
553 /// `"ProxyJump=bastion"`, `"StrictHostKeyChecking=no"`). Each entry
554 /// becomes one `-o ...` arg on the spawned `ssh` command, in the
555 /// declared order.
556 #[serde(default, skip_serializing_if = "Vec::is_empty")]
557 pub options: Vec<String>,
558}
559
560/// One worker (a physical host running one or more NCCL ranks).
561#[derive(Debug, Clone, Deserialize, Serialize)]
562#[serde(deny_unknown_fields)]
563pub struct ClusterWorker {
564 /// Hostname / identifier (was `name:` in the pre-Refactor-2 schema).
565 /// Used for /etc/hosts resolution, `--add-host` injection, and as
566 /// the default SSH target (override via `ssh:`). On the worker
567 /// itself, this is the value `hostname` returns (or the
568 /// `FLODL_HOST_NAME`-overridden value).
569 pub host: String,
570 /// Global ranks owned by this worker.
571 ///
572 /// NOT part of the user YAML schema — populated internally by
573 /// [`ClusterConfig::populate_ranks`] from probed device counts
574 /// (called by `prepare_cluster_env` before envelope emission).
575 /// Sequential assignment by worker order: worker 0 owns
576 /// `[0..counts[0])`, worker 1 owns
577 /// `[counts[0]..counts[0]+counts[1])`, etc.
578 ///
579 /// Serialized into the wire format so the rank-side library reads
580 /// the post-probe assignment via `FullCluster::from_value`, and
581 /// deserializable so the canonical JSON round-trips. A `ranks:` key
582 /// in USER yaml is rejected loudly at load
583 /// (`loading::reject_user_ranks`): a user writing it expects it to
584 /// pin ranks, and it never did (probe is authoritative).
585 #[serde(default)]
586 pub ranks: Vec<usize>,
587 /// CUDA device indices paired by position with `ranks`, or `"all"`
588 /// shorthand for auto-detect at startup. See [`LocalDevices`] for
589 /// semantics.
590 pub local_devices: LocalDevices,
591 /// Network interface NCCL binds to (e.g. `virbr0`, `enp1s0`). Becomes
592 /// `NCCL_SOCKET_IFNAME` in the spawned process environment.
593 pub nccl_socket_ifname: String,
594 /// Project checkout path on this host. `fdl-cli` cd's here before
595 /// invoking the remote command. Heterogeneous mounts are fine
596 /// (e.g. `/opt/flodl` on the controller, `/srv/flodl` in a VM); training
597 /// data is the user's responsibility to mount identically across hosts
598 /// (NAS / SMB / virtiofs / S3-FUSE).
599 pub path: String,
600 /// SSH endpoint for `fdl-cli`'s launcher. `None` means the host
601 /// runs on the same machine as the launcher (fork/exec, no ssh).
602 /// When `Some`, all fields inside are optional and fall back to
603 /// system ssh defaults (or `~/.ssh/config` rules) when unset.
604 ///
605 /// In YAML, the sub-block accepts:
606 ///
607 /// ```yaml
608 /// ssh:
609 /// target: node-b.lan # ssh hostname/IP, default: host
610 /// port: 2222 # -p <port>
611 /// user: ubuntu # -l <user>
612 /// identity_file: ~/.ssh/key # -i <path>
613 /// options: # -o <opt> (repeatable)
614 /// - ProxyJump=bastion
615 /// - StrictHostKeyChecking=no
616 /// ```
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub ssh: Option<SshConfig>,
619 /// Route this worker's training traffic through its fan-out SSH
620 /// session instead of a direct TCP connection to the controller:
621 /// the launcher adds a remote forward to the host's relay SSH
622 /// session and points the host at `127.0.0.1:<controller.port>`.
623 /// Requires a CPU ElChe mode (NCCL's peer-to-peer data plane cannot
624 /// ride a controller tunnel) and a remote host — validated loudly
625 /// at launch. When EVERY remote worker is tunneled, the controller
626 /// binds loopback only. Consumed by the library's launcher; fdl-cli
627 /// just carries it through the envelope.
628 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
629 pub tunnel: bool,
630 /// libtorch variant subpath under `<path>/libtorch/` on this host.
631 /// E.g. `precompiled/cu128` for a Blackwell host on PT 2.10 cu128,
632 /// `builds/sm61-sm120` for a Pascal host on a from-source build.
633 /// Convention: the convention path
634 /// `<host.path>/libtorch/<arch>` is what the rank exec reads at
635 /// runtime; the controller-side build mirrors it via
636 /// `<cluster.controller.path or controller-host.path>/libtorch/<arch>`.
637 /// Both paths point at the same physical libtorch via the shared
638 /// project-root mount.
639 ///
640 /// Optional; when unset, fdl-cli falls back to the host's
641 /// project-root `.active` file (single-host default behaviour
642 /// for non-cluster runs).
643 ///
644 /// `fdl probe`'s GPU compat check derives the supported sm
645 /// architectures by parsing the basename of this value (e.g.
646 /// `builds/sm61-sm120` → `6.1 12.0`) AND/OR reading the variant's
647 /// `.arch` metadata file at
648 /// `<path>/libtorch/<arch>/.arch`. The former wins when the
649 /// basename encodes archs; the latter covers `precompiled/cuXXX`
650 /// where the basename names the CUDA version, not GPU archs.
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub arch: Option<String>,
653 /// Dataset source root on this host: where its ranks READ training
654 /// data from, alongside model checkpoints and per-rank logs.
655 ///
656 /// Usually a shared filesystem (NAS / SMB / virtiofs / S3-FUSE /
657 /// SSHFS) reachable at the same logical path on every node, which
658 /// is the recommended shape and the one multi-host checkpointing
659 /// requires. A node-local directory is legal too, and then each
660 /// host holds its own copy. `fdl probe` verifies the path exists +
661 /// is readable on each host before training can fan out.
662 ///
663 /// **Declaring it changes a run.** The value travels to every rank
664 /// through the launcher envelope and supplies the training binary's
665 /// data directory (`LocalCluster::data_path` on the flodl side; an
666 /// explicit `--data-dir` still wins). When absent, nothing travels
667 /// and the binary keeps its own default: the convention default
668 /// [`DEFAULT_DATA_PATH`] governs the CHECKS only, because shipping
669 /// it would point every cluster that never mentioned data at a
670 /// directory most hosts do not have.
671 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub data_path: Option<String>,
673 /// Integrated-GPU host-RAM share for THIS host: the fraction of
674 /// `MemTotal` its GPU aperture claims (the library's
675 /// `gpu_ram_share` knob; discrete GPUs ignore it). Host-hardware
676 /// truth like `data_path`: it travels to this host's ranks through
677 /// the launcher envelope and fills the training binary's config
678 /// when that left the knob unset (an explicit `with_gpu_ram_share`
679 /// in code still wins). Overrides the cluster-scope
680 /// [`ClusterConfig::gpu_ram_share`] default. Non-negative fraction
681 /// of `MemTotal` (above 1.0 is legal where the platform
682 /// under-states the aperture).
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub gpu_ram_share: Option<f64>,
685 /// Names the docker compose service that provides this host's
686 /// runtime environment (e.g. `cuda`, `dev`). It does NOT wrap the
687 /// training exec: cluster fan-out runs each rank's binary directly
688 /// on the host over SSH (the pre-flight build is what runs in a
689 /// container, in the *controller's* [`ClusterController`] `docker`).
690 /// This field is a probe-time signal only: when set, `fdl probe`
691 /// skips host-level NCCL discovery — NCCL ships inside the image,
692 /// not on the host — and reports "provided via Docker image
693 /// `<svc>`" instead of erroring on a missing `libnccl.so`. The
694 /// host's libtorch (resolved via the `<path>/libtorch/<arch>`
695 /// convention) is still validated because it's the bind-mount
696 /// target, not container state. Per-host (not global) because mixed
697 /// deployments are common: controller in Docker, worker bare-metal
698 /// (or vice-versa). Library ignores this field; consumed only by
699 /// fdl-cli's probe / deploy paths.
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub docker: Option<String>,
702 /// Host-scoped env vars exported into every rank child spawned on
703 /// this host. Mapping `NAME: VALUE` (string→string). Useful for
704 /// host-specific tuning that doesn't belong at cluster scope
705 /// (e.g. one host needs a different `NCCL_SOCKET_IFNAME` override,
706 /// or a custom `LD_LIBRARY_PATH` due to a non-standard CUDA
707 /// install). Overrides matching keys from
708 /// [`ClusterConfig::env`].
709 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
710 pub env: std::collections::BTreeMap<String, String>,
711}
712
713/// Convention default for [`ClusterWorker::data_path`] /
714/// [`ClusterController::data_path`] when the entry does not declare
715/// one. Maps to the cross-node mount that holds training data +
716/// checkpoints + per-rank logs.
717pub const DEFAULT_DATA_PATH: &str = "/flodl/data";
718
719impl ClusterWorker {
720 /// Effective shared-data path: `data_path` if set, else
721 /// [`DEFAULT_DATA_PATH`].
722 pub fn effective_data_path(&self) -> &str {
723 self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
724 }
725}
726
727impl ClusterConfig {
728 /// Total ranks across the cluster.
729 pub fn world_size(&self) -> usize {
730 self.workers.iter().map(|w| w.ranks.len()).sum()
731 }
732
733 /// Whether the cluster spans more than one physical worker.
734 /// Single-worker clusters don't require `NCCL_SOCKET_IFNAME`.
735 pub fn spans_multiple_hosts(&self) -> bool {
736 self.workers.len() > 1
737 }
738
739 /// Pre-flight validation. Mirrors the library check so failures
740 /// surface before SSH dispatch instead of from a stack trace on a
741 /// remote host:
742 ///
743 /// - `controller.host` non-empty, `controller.path` non-empty
744 /// - `workers` non-empty
745 /// - per worker: `host` non-empty, `nccl_socket_ifname` non-empty
746 /// (when cluster spans multiple workers), `path` non-empty
747 ///
748 /// Ranks are NOT user input — they're computed by
749 /// [`Self::populate_ranks`] from probed device counts. When this
750 /// runs pre-probe (ranks empty), the rank-shape check is skipped;
751 /// when called post-probe (ranks populated), the
752 /// `0..world_size` + length-match-vs-local_devices invariant is
753 /// enforced.
754 pub fn validate(&self) -> Result<(), String> {
755 if self.controller.host.trim().is_empty() {
756 return Err("cluster.controller.host must be non-empty".into());
757 }
758 if self.controller.path.trim().is_empty() {
759 return Err("cluster.controller.path must be non-empty".into());
760 }
761 let discovery = self
762 .controller
763 .join
764 .as_ref()
765 .is_some_and(|j| j.discovery == Some(true));
766 if self.workers.is_empty() && !discovery {
767 return Err("cluster.workers must be non-empty (a roster-free \
768 window needs `controller.join.discovery: true`)"
769 .into());
770 }
771 if let Some(start) = self
772 .controller
773 .join
774 .as_ref()
775 .and_then(|j| j.start.as_deref())
776 && !matches!(start, "auto" | "manual" | "hybrid")
777 {
778 return Err(format!(
779 "cluster.controller.join.start must be one of \
780 auto | manual | hybrid, got {start:?}"
781 ));
782 }
783 // Reserved-env-key check: a user env map must not carry a key the
784 // launcher owns per-rank. The launcher applies user env after its
785 // own built-ins (shell last-wins), so an override would silently
786 // break device mapping / rank identity / the HMAC envelope. The
787 // reserved predicate lives in the library (single source of truth
788 // for the launcher-owned names).
789 for k in self.env.keys() {
790 if crate::cluster::is_reserved_cluster_env_key(k) {
791 return Err(format!(
792 "cluster.env: key {k:?} is reserved (launcher-owned) and \
793 cannot be set via env — it would override the launcher's \
794 per-rank value"
795 ));
796 }
797 }
798 for (i, w) in self.workers.iter().enumerate() {
799 for k in w.env.keys() {
800 if crate::cluster::is_reserved_cluster_env_key(k) {
801 return Err(format!(
802 "cluster.workers[{i}] ({:?}): env key {k:?} is reserved \
803 (launcher-owned) and cannot be set via env — it would \
804 override the launcher's per-rank value",
805 w.host,
806 ));
807 }
808 }
809 }
810 let multi_host = self.spans_multiple_hosts();
811 for (i, w) in self.workers.iter().enumerate() {
812 if w.host.trim().is_empty() {
813 return Err(format!("cluster.workers[{i}].host must be non-empty"));
814 }
815 if multi_host && w.nccl_socket_ifname.trim().is_empty() {
816 return Err(format!(
817 "cluster.workers[{i}] ({:?}): nccl_socket_ifname must be \
818 non-empty when the cluster spans multiple workers",
819 w.host
820 ));
821 }
822 if w.path.trim().is_empty() {
823 return Err(format!(
824 "cluster.workers[{i}] ({:?}): path (project checkout dir) \
825 must be non-empty",
826 w.host
827 ));
828 }
829 }
830 // Post-probe shape checks: only when ranks have been populated.
831 // Pre-probe (all empty) skips this branch.
832 if self.workers.iter().all(|w| !w.ranks.is_empty()) {
833 for (i, w) in self.workers.iter().enumerate() {
834 if let Some(devs) = w.local_devices.as_explicit()
835 && w.ranks.len() != devs.len()
836 {
837 return Err(format!(
838 "cluster.workers[{i}] ({:?}): ranks ({}) and local_devices ({}) length mismatch",
839 w.host,
840 w.ranks.len(),
841 devs.len()
842 ));
843 }
844 }
845 let mut all: Vec<usize> = self
846 .workers
847 .iter()
848 .flat_map(|w| w.ranks.iter().copied())
849 .collect();
850 let ws = all.len();
851 all.sort_unstable();
852 let expected: Vec<usize> = (0..ws).collect();
853 if all != expected {
854 return Err(format!(
855 "cluster: ranks across workers must be exactly 0..{ws} with no \
856 duplicates or gaps, got sorted-unique sequence {all:?}"
857 ));
858 }
859 }
860 Ok(())
861 }
862
863 /// Populate `workers[i].ranks` from probed device counts.
864 /// Sequential assignment by worker order: worker 0 owns
865 /// `[0..counts[0])`, worker 1 owns
866 /// `[counts[0]..counts[0]+counts[1])`, etc.
867 ///
868 /// Errors when `device_counts.len() != workers.len()` or any
869 /// count is 0. Workers' existing `ranks` are unconditionally
870 /// overwritten — they're not user input, the probe is
871 /// authoritative.
872 ///
873 /// Caller orchestration (see `prepare_cluster_env`):
874 /// 1. parse YAML → ClusterConfig (ranks empty by serde default)
875 /// 2. probe device counts per worker
876 /// 3. call `populate_ranks` to fill in
877 /// 4. validate (now ranks are non-empty → shape checks run)
878 /// 5. serialize and ship via FLODL_INTERNAL_FULL_CLUSTER_JSON
879 pub fn populate_ranks(&mut self, device_counts: &[usize]) -> Result<(), String> {
880 if device_counts.len() != self.workers.len() {
881 return Err(format!(
882 "populate_ranks: device_counts len {} != workers len {}",
883 device_counts.len(),
884 self.workers.len(),
885 ));
886 }
887 let mut next_rank = 0usize;
888 for (i, w) in self.workers.iter_mut().enumerate() {
889 let count = device_counts[i];
890 if count == 0 {
891 return Err(format!(
892 "populate_ranks: worker[{i}] ({:?}) reported 0 devices",
893 w.host,
894 ));
895 }
896 w.ranks = (next_rank..next_rank + count).collect();
897 next_rank += count;
898 }
899 Ok(())
900 }
901
902 /// Canonical JSON of the full topology. Used for debug-dumping the
903 /// controller-side view; per-worker envelopes go through
904 /// [`Self::local_envelope_for`] instead.
905 pub fn canonical_json(&self) -> Result<String, String> {
906 serde_json::to_string_pretty(self)
907 .map_err(|e| format!("cluster: JSON serialization failed: {e}"))
908 }
909
910 /// Build the slim per-worker envelope the library reads via
911 /// `flodl::distributed::LocalCluster::from_env`.
912 ///
913 /// The envelope strips launcher-only fields (`ssh*`), embeds derived
914 /// world metadata (`world_size`, `num_workers`), and carries only
915 /// the requested worker's slice. The launcher hex-encodes the
916 /// resulting JSON into `FLODL_INTERNAL_CLUSTER_JSON` per ssh invocation, so
917 /// each remote process sees only itself + the controller
918 /// coordinates.
919 pub fn local_envelope_for(&self, worker: &ClusterWorker) -> Value {
920 let mut worker_obj = serde_json::Map::new();
921 worker_obj.insert("host".into(), Value::String(worker.host.clone()));
922 worker_obj.insert(
923 "ranks".into(),
924 Value::Array(worker.ranks.iter().map(|r| Value::from(*r)).collect()),
925 );
926 worker_obj.insert(
927 "local_devices".into(),
928 match &worker.local_devices {
929 LocalDevices::All => Value::String("all".into()),
930 LocalDevices::Explicit(v) => {
931 Value::Array(v.iter().map(|d| Value::from(*d)).collect())
932 }
933 },
934 );
935 worker_obj.insert(
936 "nccl_socket_ifname".into(),
937 Value::String(worker.nccl_socket_ifname.clone()),
938 );
939 worker_obj.insert("path".into(), Value::String(worker.path.clone()));
940 if let Some(a) = &worker.arch {
941 worker_obj.insert("arch".into(), Value::String(a.clone()));
942 }
943 // SSH fields (port/user/identity_file/options) are launcher-only.
944 // The slim per-rank envelope read by `LocalCluster::from_env`
945 // doesn't need them — the rank-side library has nothing to do
946 // with SSH. They're already on the FULL envelope via serde on
947 // `ClusterConfig` so the launcher picks them up there.
948 //
949 // Dataset source root: DECLARED values only, never
950 // `effective_data_path()`. The convention default names a path
951 // that most hosts do not actually have, and the rank consumes
952 // this one (it reaches `--data-dir` through
953 // `LocalCluster::data_path`), so shipping the default would
954 // point every run that never mentioned data at a directory that
955 // is not there. Absent = the training binary keeps its own
956 // default. Same split `check_remote_data_path` already makes:
957 // a declared path that is missing is an error, a convention
958 // default that is missing is a warning.
959 if let Some(dp) = &worker.data_path {
960 worker_obj.insert("data_path".into(), Value::String(dp.clone()));
961 }
962
963 let mut controller_obj = serde_json::Map::new();
964 controller_obj.insert("host".into(), Value::String(self.controller.host.clone()));
965 controller_obj.insert("port".into(), Value::from(self.controller.port));
966
967 let mut envelope = serde_json::Map::new();
968 envelope.insert("controller".into(), Value::Object(controller_obj));
969 envelope.insert("world_size".into(), Value::from(self.world_size()));
970 envelope.insert("num_workers".into(), Value::from(self.workers.len()));
971 envelope.insert("worker".into(), Value::Object(worker_obj));
972 Value::Object(envelope)
973 }
974
975 /// Look up the SSH target for a worker, defaulting to `host` if
976 /// `ssh:` is not set. Used by the launcher; library callers don't
977 /// need this.
978 pub fn ssh_target<'a>(&'a self, worker: &'a ClusterWorker) -> &'a str {
979 worker
980 .ssh
981 .as_ref()
982 .and_then(|s| s.target.as_deref())
983 .unwrap_or(&worker.host)
984 }
985}