use crate::tensor::{Result, TensorError};
use super::launcher::{FullCluster, FullWorker};
pub struct ClusterBuilder {
controller: super::launcher::FullController,
workers: Vec<FullWorker>,
env: std::collections::BTreeMap<String, String>,
deferred_errors: Vec<String>,
}
impl Default for ClusterBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClusterBuilder {
pub fn new() -> Self {
Self {
controller: super::launcher::FullController {
host: String::new(),
port: 1337,
path: String::new(),
docker: None,
arch: None,
data_path: None,
join: None,
},
workers: Vec::new(),
env: std::collections::BTreeMap::new(),
deferred_errors: Vec::new(),
}
}
pub fn controller(self, host: impl Into<String>) -> ControllerBuilder {
ControllerBuilder::new(self, host.into())
}
pub fn host(self, name: impl Into<String>) -> HostBuilder {
HostBuilder::new(self, name.into())
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn build(self) -> Result<FullCluster> {
if !self.deferred_errors.is_empty() {
return Err(TensorError::new(&format!(
"ClusterBuilder: incomplete host definitions — {}",
self.deferred_errors.join("; "),
)));
}
if self.controller.host.trim().is_empty() {
return Err(TensorError::new(
"ClusterBuilder: controller(...).done() must be called \
with a non-empty host before build()",
));
}
if self.controller.path.trim().is_empty() {
return Err(TensorError::new(
"ClusterBuilder: controller.path must be non-empty",
));
}
if self.workers.is_empty() {
return Err(TensorError::new(
"ClusterBuilder: at least one worker required",
));
}
for w in &self.workers {
if let Some(devs) = w.local_devices.as_deref() {
if devs.len() != w.ranks.len() {
return Err(TensorError::new(&format!(
"ClusterBuilder: host {:?}: devices ({}) and ranks ({}) \
length mismatch — supply exactly one device index per \
rank, or use all_devices()",
w.host,
devs.len(),
w.ranks.len(),
)));
}
}
}
for k in self.env.keys() {
if crate::distributed::is_reserved_cluster_env_key(k) {
return Err(TensorError::new(&format!(
"ClusterBuilder: cluster-scope env key {k:?} is reserved \
(launcher-owned) and cannot be set via env — it would \
override the launcher's per-rank value"
)));
}
}
for w in &self.workers {
for k in w.env.keys() {
if crate::distributed::is_reserved_cluster_env_key(k) {
return Err(TensorError::new(&format!(
"ClusterBuilder: host {:?}: env key {k:?} is reserved \
(launcher-owned) and cannot be set via env — it would \
override the launcher's per-rank value",
w.host,
)));
}
}
}
let mut all: Vec<usize> = self
.workers
.iter()
.flat_map(|w| w.ranks.iter().copied())
.collect();
let ws = all.len();
all.sort_unstable();
let expected: Vec<usize> = (0..ws).collect();
if all != expected {
return Err(TensorError::new(&format!(
"ClusterBuilder: ranks across workers must form 0..{ws} with no \
duplicates or gaps, got sorted-unique sequence {all:?}"
)));
}
Ok(FullCluster {
controller: self.controller,
workers: self.workers,
salt: [0u8; crate::distributed::wire::SESSION_SALT_BYTES],
env: self.env,
})
}
pub fn all_local_gpus() -> Result<FullCluster> {
let gpus = crate::sys::detect_gpus();
if gpus.is_empty() {
return Err(TensorError::new(
"ClusterBuilder::all_local_gpus: no CUDA GPUs visible \
(nvidia-smi reported none, or CUDA_VISIBLE_DEVICES \
narrowed to empty). Use the single-device path instead.",
));
}
let hostname = crate::distributed::cluster::resolve_hostname()?;
let n = gpus.len();
let ranks: Vec<usize> = (0..n).collect();
let local_devices: Vec<u8> = gpus.iter().map(|g| g.index).collect();
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_default();
Ok(FullCluster {
controller: super::launcher::FullController {
host: "127.0.0.1".to_string(),
port: 1337,
path: cwd.clone(),
docker: None,
arch: None,
data_path: None,
join: None,
},
workers: vec![FullWorker {
host: hostname,
ranks,
local_devices: Some(local_devices),
nccl_socket_ifname: "lo".to_string(),
path: cwd,
arch: None,
ssh: None,
tunnel: false,
env: std::collections::BTreeMap::new(),
}],
salt: [0u8; crate::distributed::wire::SESSION_SALT_BYTES],
env: std::collections::BTreeMap::new(),
})
}
}
pub struct ControllerBuilder {
parent: ClusterBuilder,
host: String,
port: u16,
path: Option<String>,
docker: Option<String>,
arch: Option<String>,
data_path: Option<String>,
join: super::launcher::JoinKnobs,
}
impl ControllerBuilder {
fn new(parent: ClusterBuilder, host: String) -> Self {
Self {
parent,
host,
port: 1337,
path: None,
docker: None,
arch: None,
data_path: None,
join: super::launcher::JoinKnobs::default(),
}
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn path(mut self, p: impl Into<String>) -> Self {
self.path = Some(p.into());
self
}
pub fn docker(mut self, d: impl Into<String>) -> Self {
self.docker = Some(d.into());
self
}
pub fn arch(mut self, a: impl Into<String>) -> Self {
self.arch = Some(a.into());
self
}
pub fn data_path(mut self, p: impl Into<String>) -> Self {
self.data_path = Some(p.into());
self
}
pub fn min_rank_start(mut self, ranks: usize) -> Self {
self.join.min_rank_start = Some(ranks);
self
}
pub fn join_timeout_secs(mut self, secs: u64) -> Self {
self.join.join_timeout_secs = Some(secs);
self
}
pub fn target_ranks(mut self, ranks: usize) -> Self {
self.join.target_ranks = Some(ranks);
self
}
pub fn max_join_timeout_secs(mut self, secs: u64) -> Self {
self.join.max_join_timeout_secs = Some(secs);
self
}
pub fn open_admission(mut self, open: bool) -> Self {
self.join.open_admission = Some(open);
self
}
pub fn done(self) -> ClusterBuilder {
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_default();
let mut parent = self.parent;
let join = if self.join == super::launcher::JoinKnobs::default() {
None
} else {
Some(self.join)
};
parent.controller = super::launcher::FullController {
host: self.host,
port: self.port,
path: self.path.unwrap_or(cwd),
docker: self.docker,
arch: self.arch,
data_path: self.data_path,
join,
};
parent
}
}
pub struct HostBuilder {
parent: ClusterBuilder,
name: String,
ranks: Option<Vec<usize>>,
local_devices: Option<Option<Vec<u8>>>, nccl_socket_ifname: Option<String>,
path: Option<String>,
arch: Option<String>,
ssh: Option<crate::distributed::launcher::SshConfig>,
tunnel: bool,
env: std::collections::BTreeMap<String, String>,
}
impl HostBuilder {
fn new(parent: ClusterBuilder, name: String) -> Self {
Self {
parent,
name,
ranks: None,
local_devices: None,
nccl_socket_ifname: None,
path: None,
arch: None,
ssh: None,
tunnel: false,
env: std::collections::BTreeMap::new(),
}
}
fn ssh_mut(&mut self) -> &mut crate::distributed::launcher::SshConfig {
self.ssh
.get_or_insert_with(crate::distributed::launcher::SshConfig::default)
}
pub fn ranks<I: IntoIterator<Item = usize>>(mut self, ranks: I) -> Self {
self.ranks = Some(ranks.into_iter().collect());
self
}
pub fn devices<I: IntoIterator<Item = u8>>(mut self, devices: I) -> Self {
self.local_devices = Some(Some(devices.into_iter().collect()));
self
}
pub fn all_devices(mut self) -> Self {
self.local_devices = Some(None);
self
}
pub fn nccl_socket_ifname(mut self, name: impl Into<String>) -> Self {
self.nccl_socket_ifname = Some(name.into());
self
}
pub fn path(mut self, p: impl Into<String>) -> Self {
self.path = Some(p.into());
self
}
pub fn arch(mut self, p: impl Into<String>) -> Self {
self.arch = Some(p.into());
self
}
pub fn ssh(mut self, target: impl Into<String>) -> Self {
self.ssh_mut().target = Some(target.into());
self
}
pub fn ssh_port(mut self, port: u16) -> Self {
self.ssh_mut().port = Some(port);
self
}
pub fn ssh_user(mut self, user: impl Into<String>) -> Self {
self.ssh_mut().user = Some(user.into());
self
}
pub fn ssh_identity_file(mut self, path: impl Into<String>) -> Self {
self.ssh_mut().identity_file = Some(path.into());
self
}
pub fn ssh_option(mut self, opt: impl Into<String>) -> Self {
self.ssh_mut().options.push(opt.into());
self
}
pub fn tunnel(mut self, tunnel: bool) -> Self {
self.tunnel = tunnel;
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn done(self) -> ClusterBuilder {
let mut parent = self.parent;
let mut missing: Vec<&str> = Vec::new();
if self.ranks.is_none() {
missing.push("ranks(...)");
}
if self.local_devices.is_none() {
missing.push("devices(...) or all_devices()");
}
if self.nccl_socket_ifname.is_none() {
missing.push("nccl_socket_ifname(...)");
}
if self.path.is_none() {
missing.push("path(...)");
}
if !missing.is_empty() {
parent.deferred_errors.push(format!(
"host '{}': missing {}",
self.name,
missing.join(", "),
));
return parent;
}
let host = FullWorker {
host: self.name,
ranks: self.ranks.expect("checked above"),
local_devices: self.local_devices.expect("checked above"),
nccl_socket_ifname: self.nccl_socket_ifname.expect("checked above"),
path: self.path.expect("checked above"),
arch: self.arch,
ssh: self.ssh,
tunnel: self.tunnel,
env: self.env,
};
parent.workers.push(host);
parent
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_two_host_cluster() {
let cluster = ClusterBuilder::new()
.controller("192.168.122.1")
.port(29500)
.done()
.host("exa")
.ranks([0])
.devices([0])
.nccl_socket_ifname("virbr0")
.path("/opt/flodl")
.done()
.host("flodl-pascal")
.ranks([1, 2])
.all_devices()
.nccl_socket_ifname("enp1s0")
.path("/mnt/rdl")
.ssh_port(2222)
.ssh_identity_file("/keys/cluster")
.ssh_option("StrictHostKeyChecking=no")
.done()
.build()
.expect("build succeeds");
assert_eq!(cluster.controller.host, "192.168.122.1");
assert_eq!(cluster.controller.port, 29500);
assert_eq!(cluster.workers.len(), 2);
assert_eq!(cluster.world_size(), 3);
let exa = &cluster.workers[0];
assert_eq!(exa.host, "exa");
assert_eq!(exa.local_devices.as_deref(), Some(&[0u8][..]));
let pascal = &cluster.workers[1];
assert!(pascal.local_devices.is_none(), "all_devices() → None");
let ssh = pascal.ssh.as_ref().expect("ssh fields set the sub-block");
assert_eq!(ssh.port, Some(2222));
assert_eq!(ssh.identity_file.as_deref(), Some("/keys/cluster"));
assert_eq!(ssh.options, vec!["StrictHostKeyChecking=no".to_string()]);
}
#[test]
fn build_rejects_rank_gap() {
let err = ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0, 2]) .devices([0, 1])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect_err("gap must error");
assert!(err.to_string().contains("gaps"), "err: {err}");
}
#[test]
fn build_rejects_missing_controller() {
let err = ClusterBuilder::new()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect_err("missing controller(...) call must error");
assert!(err.to_string().contains("controller"), "err: {err}");
}
#[test]
fn build_rejects_empty_controller_host() {
let err = ClusterBuilder::new()
.controller("").done()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect_err("empty controller host must error");
assert!(err.to_string().contains("controller"), "err: {err}");
}
#[test]
fn build_rejects_no_workers() {
let err = ClusterBuilder::new()
.controller("localhost").done()
.build()
.expect_err("no workers must error");
assert!(err.to_string().contains("worker"), "err: {err}");
}
#[test]
fn controller_path_defaults_to_cwd_when_unset() {
let cluster = ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect("build succeeds");
assert!(
!cluster.controller.path.is_empty(),
"controller.path falls back to cwd when path() is not called"
);
}
#[test]
fn controller_path_override() {
let cluster = ClusterBuilder::new()
.controller("localhost")
.port(2222)
.path("/opt/flodl")
.docker("cuda")
.arch("precompiled/cu128")
.done()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect("build succeeds");
assert_eq!(cluster.controller.port, 2222);
assert_eq!(cluster.controller.path, "/opt/flodl");
assert_eq!(cluster.controller.docker.as_deref(), Some("cuda"));
assert_eq!(cluster.controller.arch.as_deref(), Some("precompiled/cu128"));
}
#[test]
fn cluster_scope_env_flows_into_full_cluster() {
let cluster = ClusterBuilder::new()
.controller("localhost").done()
.env("NCCL_P2P_DISABLE", "1")
.env("NCCL_SHM_DISABLE", "1")
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect("build succeeds");
assert_eq!(cluster.env.get("NCCL_P2P_DISABLE").map(String::as_str), Some("1"));
assert_eq!(cluster.env.get("NCCL_SHM_DISABLE").map(String::as_str), Some("1"));
}
#[test]
fn host_scope_env_flows_into_worker() {
let cluster = ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.env("LD_LIBRARY_PATH", "/opt/custom/lib")
.done()
.build()
.expect("build succeeds");
assert_eq!(
cluster.workers[0].env.get("LD_LIBRARY_PATH").map(String::as_str),
Some("/opt/custom/lib"),
);
}
#[test]
fn repeated_env_key_last_wins() {
let cluster = ClusterBuilder::new()
.controller("localhost").done()
.env("K", "first")
.env("K", "second")
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect("build succeeds");
assert_eq!(cluster.env.get("K").map(String::as_str), Some("second"));
}
#[test]
fn build_rejects_reserved_cluster_env_key() {
let err = ClusterBuilder::new()
.controller("localhost").done()
.env("CUDA_VISIBLE_DEVICES", "3") .host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect_err("reserved cluster-scope env key must error");
assert!(err.to_string().contains("reserved"), "err: {err}");
}
#[test]
fn build_rejects_reserved_host_env_key() {
let err = ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.env("FLODL_INTERNAL_LOCAL_RANK", "0") .done()
.build()
.expect_err("reserved host-scope env key must error");
assert!(err.to_string().contains("reserved"), "err: {err}");
}
#[test]
fn build_allows_non_reserved_env_keys() {
ClusterBuilder::new()
.controller("localhost").done()
.env("NCCL_P2P_DISABLE", "1")
.env("FLODL_DASHBOARD_BIND", "0.0.0.0")
.host("h0")
.ranks([0])
.devices([0])
.nccl_socket_ifname("lo")
.path("/tmp")
.env("LD_LIBRARY_PATH", "/opt/custom/lib")
.done()
.build()
.expect("non-reserved env keys must pass");
}
#[test]
fn build_rejects_devices_ranks_length_mismatch() {
let err = ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0, 1]) .devices([0]) .nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect_err("devices/ranks length mismatch must error");
assert!(err.to_string().contains("length mismatch"), "err: {err}");
}
#[test]
fn build_allows_all_devices_without_length_check() {
ClusterBuilder::new()
.controller("localhost").done()
.host("h0")
.ranks([0, 1])
.all_devices()
.nccl_socket_ifname("lo")
.path("/tmp")
.done()
.build()
.expect("all_devices() bypasses the explicit-length check");
}
}