Cluster and Hardware Commands

fdl probe

Cluster readiness audit. Single-host (default) probes the local box; in cluster context (fdl @cluster probe or FDL_ENV=cluster fdl probe) SSHes every host in fdl.cluster.yml and aggregates the report. Use it as a CI gate before launching multi-hour training runs.

fdl probe                       # local host: GPU + libtorch + NCCL + shared-data
fdl @cluster probe               # multi-host: SSHes each worker, aggregates
fdl probe --json                # machine-readable for CI gating
fdl @cluster probe --json        # cluster JSON aggregate
fdl probe --skip-mount          # skip shared-data-mount check on single-host setups
fdl probe --data-path /flodl/data        # override the shared-data path
fdl probe --libtorch-path /opt/libtorch  # override the libtorch directory
fdl probe --docker cuda         # NCCL is provided by a Docker image (compose service)

Exit code: 0 when every checked component is green, 1 when any issue was surfaced. The green path is silent enough to use as a post-deploy smoke test.

What it checks:

Output splits results into warnings (informational; do not block) and errors (block dispatch). fdl probe is a manual readiness gate — run it yourself before a cluster run; it is not invoked implicitly by fan-out. (The automatic pre-flight step fdl @cluster <cmd> performs is the per-host binary build, skippable with --no-prebuild.)

fdl status

Live status of a running cluster: lifecycle phase (waiting / staging / forming / training / done / failed), who has joined with what hardware, the join-window countdowns while it is still open, and — on start: manual / hybrid runs — the start switch’s state (a staging roster renders “roster startable, fire with fdl start). The controller serves the state as state.json over plain HTTP on its training port, so no extra port or config is involved — and curl works where fdl isn’t installed.

fdl @cluster status              # controller from the overlay's cluster.yml
fdl status --addr host[:port]    # explicit controller (default port 1337)
fdl @cluster status --json       # raw state.json for scripts
curl http://<controller>:1337/state.json   # same truth, no fdl

Address resolution: --addr wins; otherwise the active env’s cluster.controller (with a loopback retry, so all-tunneled runs are found when running on the controller box); otherwise the convention default 127.0.0.1:1337 (single-host auto-promoted runs), noted on stderr.

Exit code: 0 when the state was fetched and printed, 1 when no endpoint answered. The endpoint lives exactly as long as the launcher process — connection-refused after a run ends is the expected “no run listening” signal, not a fault.

fdl start

Fire the operator start switch of a staging cluster run. A join window opened with controller.join.start: manual (or hybrid) holds the roster once quorum is met instead of forming on the clock — the scavenged-credit shape: launch instances until the money runs out, watch fdl status, start when the roster looks full enough.

fdl @cluster start               # controller from the overlay's cluster.yml
fdl start --addr host[:port]     # explicit controller
fdl start --token <hex>          # non-loopback fire: the run's join token

Trust mirrors join admission: fired from the controller box (or through the sshd tunnel) the loopback peer address is the credential and no token is needed; from anywhere else --token must match the run’s controller.join.token. Address resolution is the same as fdl status.

Refusals name their reason and are never queued — auto mode (no switch to fire), quorum not met (with counts), window already closed, bad token. Exit code: 0 when the start was armed (the world forms at the next poll — watch fdl status), 1 otherwise.

fdl publish

Put a training run where a fleet can pull it. The controller side of compiling on the node: it resolves a source spec into a served directory, builds it once as a gate, and writes the run manifest workers read.

# The operator's own crate, wherever it lives, with the run's arguments:
fdl publish file:///home/op/my-train --bin target/release/my-train \
            -- --model resnet --epochs 20

# A pinned checkout, fetched by the controller itself (a remote
# controller has no copy of your tree):
fdl publish git+https://github.com/me/train#v3 --bin target/release/train

# This repo's own vehicle: a workspace-excluded crate, so `--cwd`:
fdl publish file:///home/op/rdl --cwd ddp-bench \
            --build 'cargo build --release --features "$FDL_GPU_FEATURE" --bin ddp-bench' \
            --bin target/release/ddp-bench -- --model olmo-graph --epochs 1

The tree lands in <served>/tree (default ~/.flodl/run/tree), which is what a worker’s --source points at, and the manifest sits at its root so one fetch carries both.

Flags: --bin (required — the artifact relative to the project dir; a workspace member’s build lands in the WORKSPACE target/, so no rule fdl invented would be right for everyone), --cwd (project dir inside the tree), --build (a shell recipe, default cargo build --release; it receives LIBTORCH_PATH, FDL_GPU_FEATURE and LD_LIBRARY_PATH, with the system ROCm runtime — $ROCM_PATH / $HIP_PATH / $HSA_PATH, else /opt/rocm — resolved ahead of libtorch’s bundled copy), `–to

` (the served directory, default `~/.flodl/run`; the guardrail key's `rrsync -ro` scopes to exactly this, so pick it deliberately), `--identity ` (for an `rsync://` source the controller itself pulls over ssh), `--no-build` (skip the gate — recorded in the manifest, loudly), `--gate ` (extra check-builds, below) and `--json` (the report as JSON on stdout, notes on stderr — the machine twin of the human report, same data). One deliberate asymmetry with `fdl join`: publish owns the whole command so its flags are bare (`--cwd`), while join prefixes its source flags (`--source-cwd`) because join also carries data, tunnel and libtorch surfaces. **A standing `publish:` block** in fdl.yml (or the active env overlay) carries all of the above, so re-publishing a run is one bare `fdl publish`. Flags win field by field, and a `--` tail replaces the block's `args:` outright — even an empty tail, because the args belong to the run and "explicitly none" must be sayable. `--no-build` has no block field on purpose: a standing config that skips the gate would ship every future typo to the fleet. ```yaml publish: source: file:///home/op/rdl cwd: ddp-bench build: cargo build --release --features "$FDL_GPU_FEATURE" --bin ddp-bench bin: target/release/ddp-bench args: [--model, olmo-graph, --epochs, "1"] ``` **The manifest is `.fdl-run.yml`**, at the tree root: | field | meaning | |---|---| | `cwd` | project directory inside the tree (default: its root); governs the build AND the run | | `build` | build recipe, a shell line (default `cargo build --release`) | | `bin` | built artifact, relative to `cwd` — what workers run | | `args` | the binary's own arguments (everything after `--`) | | `origin` | the source spec the controller resolved, for provenance | | `rustc` | `rustc -V` on the controller — advisory; a worker reports a mismatch, never enforces it | | `published_epoch` | unix seconds at publish, so a box can say how old its run is | | `run` | this publish's identity nonce; it rides each worker's join hello, and the window refuses a cohort whose members hold different ids (two boxes that fetched across a publish boundary would train two different runs as one world) | | `built` | `false` when `--no-build` skipped the gate; workers say so out loud | Do not hand-edit it: the next publish overwrites it, and its *presence* is what tells a worker the run is ready. **Chaining runs on a standing fleet is then one command.** Publish again and every box picks the new run up on its next dial, with nothing to edit on any worker. That is the point of the manifest: a worker's own config keeps only what is stable for that box (its credentials, its libtorch policy, where to pull from), while `cwd` / `build` / `bin` / `args` belong to the run and come from the controller. `args` is the sharp case rather than a convenience: they must match the run, because rank children re-enter the binary with them, so a fleet carrying its own copy would train the next run with the previous one's hyperparameters. **The build is validation, not an artifact.** One build gates the publish; every worker still compiles its own, because a controller producing binaries for N worker variants is the build matrix this design deleted. It also needs no GPU libtorch — compiling without a GPU feature against the cheap CPU variant catches user-code errors just as well — so the cost of having it on by default is rustup plus `fdl libtorch download --cpu`. What it buys is that a tree which cannot compile never reaches the fleet, where N boxes would each discover it separately in logs nobody is watching. It proves the tree for the *controller's* variant only: a break that exists solely under `--features rocm` passes a CUDA gate and lands on a worker. `--no-build` skips it and the manifest records that nothing has compiled this tree. `--gate ` closes that per-vendor hole from the controller: each one runs the same recipe as an extra check-build against a named libtorch variant (`--gate precompiled/rocm70` on a CUDA controller, and vice versa), under its own `CARGO_TARGET_DIR` so every variant's incremental cache stays warm. No GPU is needed — linking is the proof — but a flodl-linking crate does need that vendor's *dev headers* on the controller (libtorch bundles runtime libraries, not headers); a gate on a box without them fails loudly with the exact package line to install. A failed check-build publishes nothing, exactly like the primary gate. **The manifest's presence is the commit point.** `fdl publish` removes it before it touches the tree and writes it only once the build has passed, so a box that dials mid-publish, or after a publish whose build failed, finds no manifest and waits for the next dial instead of training something unvalidated. A failed gate publishes nothing, and the fleet keeps running whatever it had. The served directory is what a source key must be scoped to: `command="rrsync -ro "`. rrsync re-roots every requested path under that directory, so a worker behind it points its `source.from` at `rsync://:/tree`, not the absolute path (which double-roots and fails). `fdl publish` prints both spellings, each labelled with the key it pairs with (see the [guardrail recipe](/guide/0.8.x/ddp/cluster-guide#dial-in-membership-the-join-window)). Exit code: **0** when the run is published, **1** otherwise. ## `fdl join` Join a cluster run's window as a **self-deployed worker**: the worker-side walk-in for discovery windows (`controller.join.discovery: true`), where the window alone defines the world and worker addresses need not exist in any roster. It dials the controller, offers the box's GPUs, and runs your training binary in agent role — the binary joins, then spawns and supervises this host's relay and rank children itself; training code downstream is byte-identical to the fan-out path. ```bash # Direct dial (trusted segment), authenticated by the run token: fdl join 10.0.0.1:1337 --token --bin target/release/train -- --model resnet # Through a guardrailed sshd on the controller box (the controller # binds loopback under `tunnel_only`; reachability = authentication): fdl join --ssh [email protected] --identity ~/.ssh/join_key \ --bin target/release/train -- --model resnet ``` - `--ssh [user@]host[:port]` brings up a local `ssh -L` forward of the controller port (fresh per attempt, `ExitOnForwardFailure`, never a password prompt) and dials through it. The positional controller address is then as seen FROM the ssh host — default `127.0.0.1:1337`. - Arguments after `--` go to the binary verbatim and must match the run: rank children re-enter the binary with them. - `--devices 0,1` scopes the GPUs offered (default: all); `--host` names the worker in the roster (default: hostname). - `--persist` re-dials with backoff (5s doubling to 60s) whenever the agent exits — no window open yet, run finished, controller rebooted — the systemd / golden-image mode. - Inside a project, the active libtorch's `lib/` rides `LD_LIBRARY_PATH` onto the binary automatically (`FDL_LIBTORCH_CASE` honored) and its variant label rides the join hello. Every flag defaults from a top-level `join:` block in `fdl.yml` (see `fdl.yml.example`), so a golden image boots into bare `fdl join`. ### Preparation, before the dial Admission starts a window deadline, so everything a box needs is acquired before it dials, and re-acquired on every attempt — which is what makes `--persist` a provisioning loop: a box picks up a changed source on its next re-dial, with no reprovisioning. - **The GPU gate.** No usable device at all, and the box does not dial. The agent already refuses an empty device list, but only *after* admission — by then this host has been counted into a quorum and takes the cohort's formation down with it. The bar is "any usable device", not "nothing to report": an unusable card beside working ones (an AMD iGPU with no ROCm runtime, say) is what `fdl probe` flags and what a perfectly trainable box looks like. Those findings become the *explanation* when there is genuinely nothing. - **The dataset source root.** `--data-path` is the local path this box's ranks read from; it is verified, then shipped to them, so the training binary needs no data flag. `--data-source` mounts it first when it is not already there: ```bash fdl join --ssh flodl-join@ctrl --bin target/release/train \ --data-source sshfs://flodl-join@ctrl:/flodl/data ``` The mount goes up **read-only**: a rank reads the source root and never writes it (anything missing is acquired into `~/.flodl/data` instead), so the kernel enforces what was otherwise a convention. An already-mounted path is left alone and reused; a mount from a *different* source is reported and still reused, since unmounting behind the operator would be worse. Credentials come from the `ssh:` block — same box, same key, and that key has to permit sftp: a join key guardrailed with `command="/usr/sbin/nologin"` refuses it, so the tunnel comes up and the mount says permission denied. Either the key carries `command="internal-sftp -R -d /flodl/data"` instead, or the root is mounted during provisioning and `--data-path` is declared bare. Both are in the [guardrail recipe](/guide/0.8.x/ddp/cluster-guide#dial-in-membership-the-join-window). - **The integrated-GPU RAM share**, when `--gpu-ram-share` (or `join.gpu_ram_share:`) declares one: shipped to this box's ranks the same way `--data-path` is, where it overrides any cluster-scope default the controller declared and fills the training binary's config when that left the knob unset. APU boxes only; discrete GPUs ignore it. - **The local directories.** `~/.flodl/data` (the across-run dataset cache) and the temp dir (the within-run disk stage) are proven writable by writing. RAM-backed (`tmpfs`) or nearly-full volumes are reported, not refused. - **libtorch**, when `--libtorch` names a variant: acquired into `~/.flodl/libtorch/` and made active. `auto` routes on the devices *this* box has, which is what lets one golden image serve NVIDIA and AMD instances. Never into the project tree, which on a walk-in is frequently a read-only mount. - **The training binary**, when `--source` names a tree instead of `--bin` naming a path. The tree is fetched to local disk and built there, so it links against the libtorch this box holds and the ABI matches by construction: ```bash fdl join --ssh flodl-join@ctrl --libtorch auto \ --source rsync://flodl@ctrl:/home/op/my-train \ --source-bin target/release/my-train ``` Building from a mount is never an option: cargo fingerprints by stat'ing every source file on every invocation, and the attribute caching that would hide that latency makes it serve a stale binary. The fetch preserves mtimes, so the build stays incremental across dials rather than being a cold rebuild in an incremental costume. `--source-cwd` is the project directory inside the tree and governs the build and the run both; `--source-build` is a shell line (default `cargo build --release`) and can be a script committed beside the code, so the recipe travels with the source while its invocation stays in the box's config. It receives `LIBTORCH_PATH`, `FDL_GPU_FEATURE` and `LD_LIBRARY_PATH`. There is deliberately no toolchain flag: the tree carries its own `rust-toolchain.toml` and lockfile when the operator pinned them, and `RUSTUP_TOOLCHAIN` set here would silently override that. All three are optional when the tree came from [`fdl publish`](#fdl-publish): it carries a run manifest naming them, and that manifest is the authority, so a worker pointed at a published tree needs nothing but the pointer: ```bash # plain source key; behind a guardrailed rrsync key the spec is # `rsync://flodl-join@ctrl:/tree` instead (rrsync re-roots the path) fdl join --ssh flodl-join@ctrl --source rsync://flodl@ctrl:/home/op/.flodl/run/tree ``` A tree with no manifest and no local artifact is a **transient** failure, not a permanent one: publishing is exactly what fixes it, and that includes the window a publish opens deliberately while its build runs. - **The model signature** (default on, `--no-sig-probe` or `join.sig_probe: false` to skip). The resolved binary is re-run once, CPU-only, to print the signature of the model it builds (parameter names, shapes and dtypes); the signature rides the join hello and admission refuses a box whose model differs from the cohort's — at the door, where the refusal costs only this box's own dial and `--persist` re-dials once it is fixed. The probe's outcome is cached across re-dials, keyed on the binary's identity and the run's arguments, so an idle `--persist` box pays it once per actual change (a rebuild or a re-publish re-probes), not once per backoff tick. Without it the mismatch is still caught, but at formation, where it takes the whole cohort's attempt down. The probe is best-effort: a probe that fails or times out joins without a signature and says so. One probe outcome deserves attention beyond its warning: a binary that exits non-zero under the probe will usually fail the same way when rank children re-enter it with the same arguments after admission. A source your provisioning already mounts needs no scheme at all: name its path in `--data-path`. Nothing is checked and nothing is shipped when neither field is set. Exit codes: | code | meaning | `--persist` | |---|---|---| | **0** | the agent's own: this host's ranks all finished cleanly | re-dials | | **1** | transient failure — the controller unreachable or the agent exiting 1, a mount or fetch attempt failed, **the source did not compile** | re-dials | | **2** | permanent failure — no GPU, a spec that does not parse, a missing binary or toolchain, unwritable stage | **stops** | A compile error being transient is deliberate rather than lenient. The source is remote, so the fix is a push away, and the systemd recipe below pairs code 2 with `poweroff`: a box that stopped permanently over a typo would take the fleet with it. One exception: a compile failure on a box whose vendor toolkit headers are demonstrably missing is permanent — re-dialing cannot install a package, and the error names the `apt` line. One-shot mode (no `--persist`) passes the **agent's own exit code** through verbatim, so a training binary that exits 2 for its own reasons is indistinguishable from fdl's permanent class. The systemd recipe below is therefore written for `--persist`, where agent exits re-dial inside fdl and only classed preparation failures ever reach systemd — pair the recipe with `persist: true` (or treat 2 as reserved in your training binary). fdl never powers a box off itself; 2 is how the thing that owns the instance hears about it: ```ini # /etc/systemd/system/flodl-join.service (fdl join --persist ...) Restart=always RestartPreventExitStatus=2 # stop hot-looping a misprovisioned box FailureAction=poweroff # ... and self-deprovision it ``` Full protocol walkthrough, trust model, and the join-sshd guardrail recipe: [DDP reference](/guide/0.8.x/ddp/cluster-guide#dial-in-membership-the-join-window). ## `fdl join-config` The once-per-farm wizard: everything the guardrail recipe asks an operator to assemble by hand, produced in one pass on the controller. ```bash fdl join-config b300 # interactive: prompts have defaults fdl join-config b300 --controller [email protected]:2222 \ --install-key --cloud-init --yes # scripted fdl @b300 join-config --regen # new farm instantiation: rotate credentials ``` A **farm is an env overlay**: the wizard scaffolds `fdl.