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
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// DDP configuration. Maps 1:1 to flodl DdpConfig / DdpRunConfig.
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct DdpConfig {
13 pub mode: Option<String>,
14 pub policy: Option<String>,
15 pub backend: Option<String>,
16 /// "auto" or integer.
17 pub anchor: Option<serde_json::Value>,
18 pub max_anchor: Option<u32>,
19 pub overhead_target: Option<f64>,
20 pub divergence_threshold: Option<f64>,
21 /// null (unlimited) or integer.
22 pub max_batch_diff: Option<serde_json::Value>,
23 pub speed_hint: Option<SpeedHint>,
24 pub partition_ratios: Option<Vec<f64>>,
25 /// "auto" or bool.
26 pub progressive: Option<serde_json::Value>,
27 pub max_grad_norm: Option<f64>,
28 pub lr_scale_ratio: Option<f64>,
29 pub snapshot_timeout: Option<u32>,
30 pub checkpoint_every: Option<u32>,
31 pub timeline: Option<bool>,
32}
33
34#[derive(Debug, Clone, Default, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct SpeedHint {
37 pub slow_rank: usize,
38 pub ratio: f64,
39}
40
41/// Training scalars.
42#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct TrainingConfig {
45 pub epochs: Option<u32>,
46 pub batch_size: Option<u32>,
47 pub batches_per_epoch: Option<u32>,
48 pub lr: Option<f64>,
49 pub seed: Option<u64>,
50}
51
52/// Output settings.
53#[derive(Debug, Clone, Default, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct OutputConfig {
56 pub dir: Option<String>,
57 pub timeline: Option<bool>,
58 pub monitor: Option<u16>,
59}
60
61// ── Cluster topology (multi-host DDP) ───────────────────────────────────
62
63/// Default port for the controller's rendezvous bind. Overridable via
64/// `cluster.controller.port:` in fdl.cluster.yml.
65pub const DEFAULT_CONTROLLER_PORT: u16 = 1337;
66
67fn default_controller_port() -> u16 {
68 DEFAULT_CONTROLLER_PORT
69}
70
71/// Cluster topology, parsed from the `cluster:` block at the project
72/// root. Two role-separated sub-blocks:
73///
74/// - `controller`: the orchestrator host fdl-cli runs on. Holds the
75/// rendezvous bind point (`host`/`port`) plus the controller-local
76/// fields needed for pre-flight build and the ClusterController TCP
77/// listener. The controller is NOT a NCCL rank.
78/// - `workers`: every rank-carrying host. Each entry binds one or more
79/// global ranks and their CUDA device indices.
80///
81/// This is the controller-side full topology; per-worker slim
82/// envelopes are derived from it and shipped to each node, where the
83/// library reads them via `flodl::distributed::LocalCluster::from_env`.
84/// Launcher-only fields (`ssh:`) are not propagated to the envelope.
85///
86/// The library re-validates after reading; this validation runs earlier
87/// so errors surface before `fdl-cli` opens any SSH connection.
88#[derive(Debug, Clone, Deserialize, Serialize)]
89#[serde(deny_unknown_fields)]
90pub struct ClusterConfig {
91 pub controller: ClusterController,
92 pub workers: Vec<ClusterWorker>,
93 /// Cluster-scope env vars exported into every rank child on every
94 /// worker. Mapping `NAME: VALUE` (string→string). Use for tuning
95 /// the launcher itself shouldn't hardcode — e.g. on the Pascal-
96 /// under-VFIO rig, set `NCCL_P2P_DISABLE: "1"` +
97 /// `NCCL_SHM_DISABLE: "1"` so NCCL falls back to socket transport
98 /// (the direct-IPC transports fail under VFIO on consumer Pascal).
99 /// Worker-scope [`ClusterWorker::env`] takes precedence for matching
100 /// keys.
101 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
102 pub env: std::collections::BTreeMap<String, String>,
103}
104
105/// Controller-side cluster config. Holds the rendezvous bind point and
106/// pre-flight build context. The controller is the orchestrator host
107/// fdl-cli runs on; it is NOT a NCCL rank itself.
108///
109/// Rejected fields (via `deny_unknown_fields`): `ranks`,
110/// `local_devices`, `ssh*` (controller is local to fdl-cli),
111/// per-controller `env` (use cluster-scope `env:` instead) — and any
112/// mistyped key.
113#[derive(Debug, Clone, Deserialize, Serialize)]
114#[serde(deny_unknown_fields)]
115pub struct ClusterController {
116 /// Bind address. Used as the rendezvous endpoint workers dial.
117 /// What was `cluster.controller.host` in the pre-Refactor-2 schema.
118 pub host: String,
119 /// Bind port. What was `cluster.controller.port` in the pre-Refactor-2
120 /// schema. Defaults to [`DEFAULT_CONTROLLER_PORT`] when omitted.
121 #[serde(default = "default_controller_port")]
122 pub port: u16,
123 /// Controller's view of the shared project root. Used by the pre-
124 /// flight build phase to drive cargo invocations from the
125 /// controller's filesystem perspective; the remote-side path (each
126 /// worker's [`ClusterWorker::path`]) is used at runtime via SSH.
127 ///
128 /// On homogeneous-mount rigs the controller's view equals every
129 /// worker's view; on heterogeneous rigs (different mount points
130 /// per host), the controller's value diverges (e.g. the controller
131 /// sees `/opt/flodl` while a VM worker sees `/mnt/flodl`).
132 pub path: String,
133 /// Docker compose service the controller runs the pre-flight build
134 /// inside (e.g. `cuda`, `dev`). Optional; affects build context
135 /// only.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub docker: Option<String>,
138 /// libtorch variant subpath under `<path>/libtorch/` for the
139 /// pre-flight build (e.g. `precompiled/cu128`,
140 /// `builds/sm61-sm120`). When unset, fdl-cli falls back to the
141 /// controller's project-root `.active` file.
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub arch: Option<String>,
144 /// Shared-storage path visible to the controller. flodl assumes a
145 /// shared filesystem reachable at the same logical path on every
146 /// node — training data, model checkpoints, and per-rank logs all
147 /// live here. When absent, the convention default
148 /// [`DEFAULT_DATA_PATH`] applies.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub data_path: Option<String>,
151 /// Join-window quorum knobs (dial-in membership). fdl-cli carries
152 /// the block through the launcher envelope verbatim; flodl owns
153 /// validation and the capacity-derived defaults.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub join: Option<ClusterJoin>,
156}
157
158/// `controller.join:` sub-block — per-field overrides for the
159/// membership window. All fields optional; unset keeps flodl's fan-out
160/// defaults (quorum = early-close target = configured capacity, window
161/// 300s, hard cap 600s, pre-shared-salt admission).
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct ClusterJoin {
165 /// Quorum in ranks — the run cannot start below it.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub min_rank_start: Option<usize>,
168 /// Join window in seconds; quorum reached early does NOT close it.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub join_timeout: Option<u64>,
171 /// Early-close target in ranks: the window closes the moment this
172 /// many are in.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub target_ranks: Option<usize>,
175 /// Hard cap in seconds: quorum still unmet when it expires fails
176 /// the run loudly.
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub max_join_timeout: Option<u64>,
179 /// Accept joins without pre-shared-salt authentication on a
180 /// non-loopback bind (loudly warned by flodl).
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub open_admission: Option<bool>,
183}
184
185impl ClusterController {
186 /// Effective shared-data path: `data_path` if set, else
187 /// [`DEFAULT_DATA_PATH`].
188 pub fn effective_data_path(&self) -> &str {
189 self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
190 }
191}
192
193/// CUDA device indices on a host. Either explicit (a list of indices) or
194/// the `"all"` shorthand (auto-detect at startup on that host).
195///
196/// `local_devices: all` defers device-index resolution to startup on
197/// whichever host the value lives on. The host uses `cuda_device_count()`
198/// and binds devices `0..ranks.len()` (sequential from index 0). The
199/// detected count must be at least as large as `ranks.len()`, otherwise
200/// rendezvous errors loudly.
201///
202/// `local_devices: [0, 1]` is the explicit form; same as before this
203/// shorthand existed.
204///
205/// Symmetric: works for both the controller host and remote nodes. Pair
206/// with explicit `ranks: [N, N+1, ...]` to define the world topology;
207/// `local_devices` only selects which device indices on this host map to
208/// those ranks.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum LocalDevices {
211 /// Auto-detect at startup on this host. Indices `0..ranks.len()` bound.
212 All,
213 /// Explicit list of CUDA device indices, paired positionally with ranks.
214 Explicit(Vec<u8>),
215}
216
217impl LocalDevices {
218 /// True for the auto-detect form (unresolved).
219 pub fn is_all(&self) -> bool {
220 matches!(self, LocalDevices::All)
221 }
222
223 /// Explicit indices as a slice, or `None` for `All`.
224 pub fn as_explicit(&self) -> Option<&[u8]> {
225 match self {
226 LocalDevices::All => None,
227 LocalDevices::Explicit(v) => Some(v.as_slice()),
228 }
229 }
230}
231
232impl Serialize for LocalDevices {
233 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
234 match self {
235 LocalDevices::All => s.serialize_str("all"),
236 LocalDevices::Explicit(v) => v.serialize(s),
237 }
238 }
239}
240
241impl<'de> Deserialize<'de> for LocalDevices {
242 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
243 use serde::de::Error;
244 let v = Value::deserialize(d)?;
245 match v {
246 Value::String(s) if s == "all" => Ok(LocalDevices::All),
247 Value::String(s) => Err(D::Error::custom(format!(
248 "local_devices: expected \"all\" or array of device indices, got string {s:?}"
249 ))),
250 Value::Array(arr) => {
251 let mut out = Vec::with_capacity(arr.len());
252 for (i, item) in arr.iter().enumerate() {
253 let n = item.as_u64().ok_or_else(|| {
254 D::Error::custom(format!(
255 "local_devices[{i}]: expected integer device index, got {item}"
256 ))
257 })?;
258 let d = u8::try_from(n).map_err(|_| {
259 D::Error::custom(format!(
260 "local_devices[{i}]: value {n} does not fit in u8"
261 ))
262 })?;
263 out.push(d);
264 }
265 Ok(LocalDevices::Explicit(out))
266 }
267 _ => Err(D::Error::custom(
268 "local_devices: expected \"all\" or array of device indices",
269 )),
270 }
271 }
272}
273
274/// SSH endpoint configuration for a remote worker host.
275///
276/// fdl-cli parser side; mirrors `flodl::distributed::launcher::SshConfig`
277/// shape so the slim envelope round-trips cleanly into the launcher. All
278/// fields optional; absent fields fall back to system ssh defaults or
279/// `~/.ssh/config` rules.
280#[derive(Debug, Clone, Default, Deserialize, Serialize)]
281#[serde(deny_unknown_fields)]
282pub struct SshConfig {
283 /// SSH target hostname / IP / alias. Defaults to the worker's
284 /// `host` field when unset.
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub target: Option<String>,
287 /// SSH port (`ssh -p <port>`).
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub port: Option<u16>,
290 /// SSH login user (`ssh -l <user>`). Defaults to the current user
291 /// (or `FLODL_INTERNAL_HOST_USER` from the controller env).
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub user: Option<String>,
294 /// Identity file / private key (`ssh -i <path>`).
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub identity_file: Option<String>,
297 /// Pass-through `-o Key=Value` SSH options (e.g.
298 /// `"ProxyJump=bastion"`, `"StrictHostKeyChecking=no"`). Each entry
299 /// becomes one `-o ...` arg on the spawned `ssh` command, in the
300 /// declared order.
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub options: Vec<String>,
303}
304
305/// One worker (a physical host running one or more NCCL ranks).
306#[derive(Debug, Clone, Deserialize, Serialize)]
307#[serde(deny_unknown_fields)]
308pub struct ClusterWorker {
309 /// Hostname / identifier (was `name:` in the pre-Refactor-2 schema).
310 /// Used for /etc/hosts resolution, `--add-host` injection, and as
311 /// the default SSH target (override via `ssh:`). On the worker
312 /// itself, this is the value `hostname` returns (or the
313 /// `FLODL_HOST_NAME`-overridden value).
314 pub host: String,
315 /// Global ranks owned by this worker.
316 ///
317 /// NOT part of the user YAML schema — populated internally by
318 /// [`ClusterConfig::populate_ranks`] from probed device counts
319 /// (called by `prepare_cluster_env` before envelope emission).
320 /// Sequential assignment by worker order: worker 0 owns
321 /// `[0..counts[0])`, worker 1 owns
322 /// `[counts[0]..counts[0]+counts[1])`, etc.
323 ///
324 /// Serialized into the wire format so the rank-side library reads
325 /// the post-probe assignment via `FullCluster::from_value`, and
326 /// deserializable so the canonical JSON round-trips. A `ranks:` key
327 /// in USER yaml is rejected loudly at load
328 /// (`loading::reject_user_ranks`): a user writing it expects it to
329 /// pin ranks, and it never did (probe is authoritative).
330 #[serde(default)]
331 pub ranks: Vec<usize>,
332 /// CUDA device indices paired by position with `ranks`, or `"all"`
333 /// shorthand for auto-detect at startup. See [`LocalDevices`] for
334 /// semantics.
335 pub local_devices: LocalDevices,
336 /// Network interface NCCL binds to (e.g. `virbr0`, `enp1s0`). Becomes
337 /// `NCCL_SOCKET_IFNAME` in the spawned process environment.
338 pub nccl_socket_ifname: String,
339 /// Project checkout path on this host. `fdl-cli` cd's here before
340 /// invoking the remote command. Heterogeneous mounts are fine
341 /// (e.g. `/opt/flodl` on the controller, `/srv/flodl` in a VM); training
342 /// data is the user's responsibility to mount identically across hosts
343 /// (NAS / SMB / virtiofs / S3-FUSE).
344 pub path: String,
345 /// SSH endpoint for `fdl-cli`'s launcher. `None` means the host
346 /// runs on the same machine as the launcher (fork/exec, no ssh).
347 /// When `Some`, all fields inside are optional and fall back to
348 /// system ssh defaults (or `~/.ssh/config` rules) when unset.
349 ///
350 /// In YAML, the sub-block accepts:
351 ///
352 /// ```yaml
353 /// ssh:
354 /// target: node-b.lan # ssh hostname/IP, default: host
355 /// port: 2222 # -p <port>
356 /// user: ubuntu # -l <user>
357 /// identity_file: ~/.ssh/key # -i <path>
358 /// options: # -o <opt> (repeatable)
359 /// - ProxyJump=bastion
360 /// - StrictHostKeyChecking=no
361 /// ```
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub ssh: Option<SshConfig>,
364 /// Route this worker's training traffic through its fan-out SSH
365 /// session instead of a direct TCP connection to the controller:
366 /// the launcher adds a remote forward to the host's relay SSH
367 /// session and points the host at `127.0.0.1:<controller.port>`.
368 /// Requires a CPU ElChe mode (NCCL's peer-to-peer data plane cannot
369 /// ride a controller tunnel) and a remote host — validated loudly
370 /// at launch. When EVERY remote worker is tunneled, the controller
371 /// binds loopback only. Consumed by the library's launcher; fdl-cli
372 /// just carries it through the envelope.
373 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
374 pub tunnel: bool,
375 /// libtorch variant subpath under `<path>/libtorch/` on this host.
376 /// E.g. `precompiled/cu128` for a Blackwell host on PT 2.10 cu128,
377 /// `builds/sm61-sm120` for a Pascal host on a from-source build.
378 /// Convention: the convention path
379 /// `<host.path>/libtorch/<arch>` is what the rank exec reads at
380 /// runtime; the controller-side build mirrors it via
381 /// `<cluster.controller.path or controller-host.path>/libtorch/<arch>`.
382 /// Both paths point at the same physical libtorch via the shared
383 /// project-root mount.
384 ///
385 /// Optional; when unset, fdl-cli falls back to the host's
386 /// project-root `.active` file (single-host default behaviour
387 /// for non-cluster runs).
388 ///
389 /// `fdl probe`'s GPU compat check derives the supported sm
390 /// architectures by parsing the basename of this value (e.g.
391 /// `builds/sm61-sm120` → `6.1 12.0`) AND/OR reading the variant's
392 /// `.arch` metadata file at
393 /// `<path>/libtorch/<arch>/.arch`. The former wins when the
394 /// basename encodes archs; the latter covers `precompiled/cuXXX`
395 /// where the basename names the CUDA version, not GPU archs.
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub arch: Option<String>,
398 /// Shared-storage path visible to this host. flodl assumes a
399 /// shared filesystem (NAS / SMB / virtiofs / S3-FUSE / SSHFS)
400 /// reachable at the same logical path on every node — training
401 /// data, model checkpoints, and per-rank logs all live here. When
402 /// absent, the convention default [`DEFAULT_DATA_PATH`] applies.
403 /// `fdl probe` verifies the path exists + is readable on each
404 /// host before training can fan out.
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub data_path: Option<String>,
407 /// Names the docker compose service that provides this host's
408 /// runtime environment (e.g. `cuda`, `dev`). It does NOT wrap the
409 /// training exec: cluster fan-out runs each rank's binary directly
410 /// on the host over SSH (the pre-flight build is what runs in a
411 /// container, in the *controller's* [`ClusterController`] `docker`).
412 /// This field is a probe-time signal only: when set, `fdl probe`
413 /// skips host-level NCCL discovery — NCCL ships inside the image,
414 /// not on the host — and reports "provided via Docker image
415 /// `<svc>`" instead of erroring on a missing `libnccl.so`. The
416 /// host's libtorch (resolved via the `<path>/libtorch/<arch>`
417 /// convention) is still validated because it's the bind-mount
418 /// target, not container state. Per-host (not global) because mixed
419 /// deployments are common: controller in Docker, worker bare-metal
420 /// (or vice-versa). Library ignores this field; consumed only by
421 /// fdl-cli's probe / deploy paths.
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub docker: Option<String>,
424 /// Host-scoped env vars exported into every rank child spawned on
425 /// this host. Mapping `NAME: VALUE` (string→string). Useful for
426 /// host-specific tuning that doesn't belong at cluster scope
427 /// (e.g. one host needs a different `NCCL_SOCKET_IFNAME` override,
428 /// or a custom `LD_LIBRARY_PATH` due to a non-standard CUDA
429 /// install). Overrides matching keys from
430 /// [`ClusterConfig::env`].
431 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
432 pub env: std::collections::BTreeMap<String, String>,
433}
434
435/// Convention default for [`ClusterWorker::data_path`] /
436/// [`ClusterController::data_path`] when the entry does not declare
437/// one. Maps to the cross-node mount that holds training data +
438/// checkpoints + per-rank logs.
439pub const DEFAULT_DATA_PATH: &str = "/flodl/data";
440
441impl ClusterWorker {
442 /// Effective shared-data path: `data_path` if set, else
443 /// [`DEFAULT_DATA_PATH`].
444 pub fn effective_data_path(&self) -> &str {
445 self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
446 }
447}
448
449impl ClusterConfig {
450 /// Total ranks across the cluster.
451 pub fn world_size(&self) -> usize {
452 self.workers.iter().map(|w| w.ranks.len()).sum()
453 }
454
455 /// Whether the cluster spans more than one physical worker.
456 /// Single-worker clusters don't require `NCCL_SOCKET_IFNAME`.
457 pub fn spans_multiple_hosts(&self) -> bool {
458 self.workers.len() > 1
459 }
460
461 /// Pre-flight validation. Mirrors the library check so failures
462 /// surface before SSH dispatch instead of from a stack trace on a
463 /// remote host:
464 ///
465 /// - `controller.host` non-empty, `controller.path` non-empty
466 /// - `workers` non-empty
467 /// - per worker: `host` non-empty, `nccl_socket_ifname` non-empty
468 /// (when cluster spans multiple workers), `path` non-empty
469 ///
470 /// Ranks are NOT user input — they're computed by
471 /// [`Self::populate_ranks`] from probed device counts. When this
472 /// runs pre-probe (ranks empty), the rank-shape check is skipped;
473 /// when called post-probe (ranks populated), the
474 /// `0..world_size` + length-match-vs-local_devices invariant is
475 /// enforced.
476 pub fn validate(&self) -> Result<(), String> {
477 if self.controller.host.trim().is_empty() {
478 return Err("cluster.controller.host must be non-empty".into());
479 }
480 if self.controller.path.trim().is_empty() {
481 return Err("cluster.controller.path must be non-empty".into());
482 }
483 if self.workers.is_empty() {
484 return Err("cluster.workers must be non-empty".into());
485 }
486 // Reserved-env-key check: a user env map must not carry a key the
487 // launcher owns per-rank. The launcher applies user env after its
488 // own built-ins (shell last-wins), so an override would silently
489 // break device mapping / rank identity / the HMAC envelope. The
490 // reserved predicate lives in the library (single source of truth
491 // for the launcher-owned names).
492 for k in self.env.keys() {
493 if crate::cluster::is_reserved_cluster_env_key(k) {
494 return Err(format!(
495 "cluster.env: key {k:?} is reserved (launcher-owned) and \
496 cannot be set via env — it would override the launcher's \
497 per-rank value"
498 ));
499 }
500 }
501 for (i, w) in self.workers.iter().enumerate() {
502 for k in w.env.keys() {
503 if crate::cluster::is_reserved_cluster_env_key(k) {
504 return Err(format!(
505 "cluster.workers[{i}] ({:?}): env key {k:?} is reserved \
506 (launcher-owned) and cannot be set via env — it would \
507 override the launcher's per-rank value",
508 w.host,
509 ));
510 }
511 }
512 }
513 let multi_host = self.spans_multiple_hosts();
514 for (i, w) in self.workers.iter().enumerate() {
515 if w.host.trim().is_empty() {
516 return Err(format!("cluster.workers[{i}].host must be non-empty"));
517 }
518 if multi_host && w.nccl_socket_ifname.trim().is_empty() {
519 return Err(format!(
520 "cluster.workers[{i}] ({:?}): nccl_socket_ifname must be \
521 non-empty when the cluster spans multiple workers",
522 w.host
523 ));
524 }
525 if w.path.trim().is_empty() {
526 return Err(format!(
527 "cluster.workers[{i}] ({:?}): path (project checkout dir) \
528 must be non-empty",
529 w.host
530 ));
531 }
532 }
533 // Post-probe shape checks: only when ranks have been populated.
534 // Pre-probe (all empty) skips this branch.
535 if self.workers.iter().all(|w| !w.ranks.is_empty()) {
536 for (i, w) in self.workers.iter().enumerate() {
537 if let Some(devs) = w.local_devices.as_explicit() {
538 if w.ranks.len() != devs.len() {
539 return Err(format!(
540 "cluster.workers[{i}] ({:?}): ranks ({}) and local_devices ({}) length mismatch",
541 w.host,
542 w.ranks.len(),
543 devs.len()
544 ));
545 }
546 }
547 }
548 let mut all: Vec<usize> = self
549 .workers
550 .iter()
551 .flat_map(|w| w.ranks.iter().copied())
552 .collect();
553 let ws = all.len();
554 all.sort_unstable();
555 let expected: Vec<usize> = (0..ws).collect();
556 if all != expected {
557 return Err(format!(
558 "cluster: ranks across workers must be exactly 0..{ws} with no \
559 duplicates or gaps, got sorted-unique sequence {all:?}"
560 ));
561 }
562 }
563 Ok(())
564 }
565
566 /// Populate `workers[i].ranks` from probed device counts.
567 /// Sequential assignment by worker order: worker 0 owns
568 /// `[0..counts[0])`, worker 1 owns
569 /// `[counts[0]..counts[0]+counts[1])`, etc.
570 ///
571 /// Errors when `device_counts.len() != workers.len()` or any
572 /// count is 0. Workers' existing `ranks` are unconditionally
573 /// overwritten — they're not user input, the probe is
574 /// authoritative.
575 ///
576 /// Caller orchestration (see `prepare_cluster_env`):
577 /// 1. parse YAML → ClusterConfig (ranks empty by serde default)
578 /// 2. probe device counts per worker
579 /// 3. call `populate_ranks` to fill in
580 /// 4. validate (now ranks are non-empty → shape checks run)
581 /// 5. serialize and ship via FLODL_INTERNAL_FULL_CLUSTER_JSON
582 pub fn populate_ranks(&mut self, device_counts: &[usize]) -> Result<(), String> {
583 if device_counts.len() != self.workers.len() {
584 return Err(format!(
585 "populate_ranks: device_counts len {} != workers len {}",
586 device_counts.len(),
587 self.workers.len(),
588 ));
589 }
590 let mut next_rank = 0usize;
591 for (i, w) in self.workers.iter_mut().enumerate() {
592 let count = device_counts[i];
593 if count == 0 {
594 return Err(format!(
595 "populate_ranks: worker[{i}] ({:?}) reported 0 devices",
596 w.host,
597 ));
598 }
599 w.ranks = (next_rank..next_rank + count).collect();
600 next_rank += count;
601 }
602 Ok(())
603 }
604
605 /// Canonical JSON of the full topology. Used for debug-dumping the
606 /// controller-side view; per-worker envelopes go through
607 /// [`Self::local_envelope_for`] instead.
608 pub fn canonical_json(&self) -> Result<String, String> {
609 serde_json::to_string_pretty(self)
610 .map_err(|e| format!("cluster: JSON serialization failed: {e}"))
611 }
612
613 /// Build the slim per-worker envelope the library reads via
614 /// `flodl::distributed::LocalCluster::from_env`.
615 ///
616 /// The envelope strips launcher-only fields (`ssh*`), embeds derived
617 /// world metadata (`world_size`, `num_workers`), and carries only
618 /// the requested worker's slice. The launcher hex-encodes the
619 /// resulting JSON into `FLODL_INTERNAL_CLUSTER_JSON` per ssh invocation, so
620 /// each remote process sees only itself + the controller
621 /// coordinates.
622 pub fn local_envelope_for(&self, worker: &ClusterWorker) -> Value {
623 let mut worker_obj = serde_json::Map::new();
624 worker_obj.insert("host".into(), Value::String(worker.host.clone()));
625 worker_obj.insert(
626 "ranks".into(),
627 Value::Array(worker.ranks.iter().map(|r| Value::from(*r)).collect()),
628 );
629 worker_obj.insert(
630 "local_devices".into(),
631 match &worker.local_devices {
632 LocalDevices::All => Value::String("all".into()),
633 LocalDevices::Explicit(v) => {
634 Value::Array(v.iter().map(|d| Value::from(*d)).collect())
635 }
636 },
637 );
638 worker_obj.insert(
639 "nccl_socket_ifname".into(),
640 Value::String(worker.nccl_socket_ifname.clone()),
641 );
642 worker_obj.insert("path".into(), Value::String(worker.path.clone()));
643 if let Some(a) = &worker.arch {
644 worker_obj.insert("arch".into(), Value::String(a.clone()));
645 }
646 // SSH fields (port/user/identity_file/options) are launcher-only.
647 // The slim per-rank envelope read by `LocalCluster::from_env`
648 // doesn't need them — the rank-side library has nothing to do
649 // with SSH. They're already on the FULL envelope via serde on
650 // `ClusterConfig` so the launcher picks them up there.
651 // Shared-data path: always serialize the effective value
652 // (declared or convention default) so the rank-side envelope
653 // surfaces a non-ambiguous path. Library validates existence
654 // via `fdl probe` before training; ship the path verbatim.
655 worker_obj.insert(
656 "data_path".into(),
657 Value::String(worker.effective_data_path().into()),
658 );
659
660 let mut controller_obj = serde_json::Map::new();
661 controller_obj.insert("host".into(), Value::String(self.controller.host.clone()));
662 controller_obj.insert("port".into(), Value::from(self.controller.port));
663
664 let mut envelope = serde_json::Map::new();
665 envelope.insert("controller".into(), Value::Object(controller_obj));
666 envelope.insert("world_size".into(), Value::from(self.world_size()));
667 envelope.insert("num_workers".into(), Value::from(self.workers.len()));
668 envelope.insert("worker".into(), Value::Object(worker_obj));
669 Value::Object(envelope)
670 }
671
672 /// Look up the SSH target for a worker, defaulting to `host` if
673 /// `ssh:` is not set. Used by the launcher; library callers don't
674 /// need this.
675 pub fn ssh_target<'a>(&'a self, worker: &'a ClusterWorker) -> &'a str {
676 worker
677 .ssh
678 .as_ref()
679 .and_then(|s| s.target.as_deref())
680 .unwrap_or(&worker.host)
681 }
682}