use std::cell::RefCell;
use std::env;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use std::process::Command;
use serde_json::Value;
use crate::log;
use crate::tensor::Device;
use crate::{Result, TensorError};
use super::wire::SessionSalt;
use super::NcclUniqueId;
use super::rendezvous::TcpRendezvous;
pub const ENV_CLUSTER_JSON: &str = "FLODL_INTERNAL_CLUSTER_JSON";
pub const ENV_HOST_OVERRIDE: &str = "FLODL_HOST_NAME";
pub const ENV_LOCAL_RANK: &str = "FLODL_INTERNAL_LOCAL_RANK";
pub fn is_reserved_cluster_env_key(key: &str) -> bool {
key.starts_with("FLODL_INTERNAL_")
|| key == "CUDA_VISIBLE_DEVICES"
|| key == "CUDA_DEVICE_ORDER"
|| key == ENV_HOST_OVERRIDE
|| key == crate::distributed::launcher::ENV_FDL_ENV
}
thread_local! {
static THREAD_HOSTNAME_OVERRIDE: RefCell<Option<String>> = const { RefCell::new(None) };
static THREAD_LOCAL_RANK_OVERRIDE: RefCell<Option<usize>> = const { RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_thread_hostname_override(name: Option<&str>) {
THREAD_HOSTNAME_OVERRIDE.with(|cell| {
*cell.borrow_mut() = name.map(String::from);
});
}
#[cfg(test)]
pub(crate) fn set_thread_local_rank_override(idx: Option<usize>) {
THREAD_LOCAL_RANK_OVERRIDE.with(|cell| {
*cell.borrow_mut() = idx;
});
}
#[derive(Debug, Clone)]
pub struct LocalCluster {
pub controller: ControllerBlock,
pub world_size: usize,
pub num_workers: usize,
pub worker: WorkerBlock,
pub salt: SessionSalt,
pub rank_resources: bool,
}
#[derive(Debug, Clone)]
pub struct ControllerBlock {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone)]
pub struct WorkerBlock {
pub host: String,
pub ranks: Vec<usize>,
pub local_devices: Vec<u8>,
pub nccl_socket_ifname: String,
pub path: String,
pub arch: Option<String>,
}
impl LocalCluster {
pub fn from_env() -> Result<Option<Self>> {
let raw = match env::var(ENV_CLUSTER_JSON) {
Ok(s) => s,
Err(env::VarError::NotPresent) => return Ok(None),
Err(e) => {
return Err(TensorError::new(&format!(
"cluster: reading {ENV_CLUSTER_JSON} failed: {e}"
)));
}
};
let bytes = hex_decode(raw.trim()).map_err(|e| {
TensorError::new(&format!(
"cluster: {ENV_CLUSTER_JSON} hex-decode failed: {e}"
))
})?;
let val: Value = serde_json::from_slice(&bytes).map_err(|e| {
TensorError::new(&format!(
"cluster: {ENV_CLUSTER_JSON} JSON parse failed: {e}"
))
})?;
Self::from_value(&val).map(Some)
}
pub fn from_json(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let file = File::open(path).map_err(|e| {
TensorError::new(&format!(
"cluster: failed to open {}: {}",
path.display(),
e
))
})?;
let val: Value = serde_json::from_reader(BufReader::new(file)).map_err(|e| {
TensorError::new(&format!(
"cluster: failed to parse {} as JSON: {}",
path.display(),
e
))
})?;
Self::from_value(&val)
}
pub fn from_value(val: &Value) -> Result<Self> {
let obj = val
.as_object()
.ok_or_else(|| TensorError::new("cluster: top-level JSON must be an object"))?;
let controller_val = obj
.get("controller")
.and_then(Value::as_object)
.ok_or_else(|| TensorError::new("cluster: controller (object) required"))?;
let controller_host = controller_val
.get("host")
.and_then(Value::as_str)
.ok_or_else(|| TensorError::new("cluster: controller.host (string) required"))?
.to_string();
if controller_host.trim().is_empty() {
return Err(TensorError::new("cluster: controller.host must be non-empty"));
}
let controller_port_u64 = controller_val
.get("port")
.and_then(Value::as_u64)
.ok_or_else(|| TensorError::new("cluster: controller.port (u16) required"))?;
let controller_port = u16::try_from(controller_port_u64).map_err(|_| {
TensorError::new(&format!(
"cluster: controller.port must fit in u16 (got {controller_port_u64})"
))
})?;
let world_size = obj
.get("world_size")
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| TensorError::new("cluster: world_size (usize) required"))?;
if world_size == 0 {
return Err(TensorError::new("cluster: world_size must be > 0"));
}
let num_workers = obj
.get("num_workers")
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| TensorError::new("cluster: num_workers (usize) required"))?;
if num_workers == 0 {
return Err(TensorError::new("cluster: num_workers must be > 0"));
}
if num_workers > world_size {
return Err(TensorError::new(&format!(
"cluster: num_workers ({num_workers}) cannot exceed world_size ({world_size})"
)));
}
let worker_val = obj
.get("worker")
.ok_or_else(|| TensorError::new("cluster: worker (object) required"))?;
let worker = parse_worker(worker_val)?;
for &r in &worker.ranks {
if r >= world_size {
return Err(TensorError::new(&format!(
"cluster.worker ({:?}): rank {r} out of bounds for world_size {world_size}",
worker.host
)));
}
}
let salt = match obj.get("salt").and_then(Value::as_str) {
Some(s) => super::wire::salt_from_hex(s).map_err(|e| {
TensorError::new(&format!("cluster: salt: {e}"))
})?,
None => [0u8; super::wire::SESSION_SALT_BYTES],
};
let rank_resources = obj
.get("rank_resources")
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(LocalCluster {
controller: ControllerBlock {
host: controller_host,
port: controller_port,
},
world_size,
num_workers,
worker,
salt,
rank_resources,
})
}
pub fn world_size(&self) -> usize {
self.world_size
}
pub fn this_worker(&self) -> Result<&WorkerBlock> {
let name = resolve_hostname()?;
if name != self.worker.host {
return Err(TensorError::new(&format!(
"cluster: resolved hostname {name:?} does not match envelope's \
worker.host {:?} -- the launcher shipped this envelope to the \
wrong host (set {ENV_HOST_OVERRIDE} to override for test rigs)",
self.worker.host
)));
}
log::set_node_label(&self.worker.host);
Ok(&self.worker)
}
pub fn my_rank(&self) -> Result<(usize, Device)> {
let worker = self.this_worker()?;
let idx = local_rank_index_from_env(worker.ranks.len(), &worker.host)?;
if let Ok(visible) = std::env::var("CUDA_VISIBLE_DEVICES") {
if !visible.is_empty() && !visible.contains(',') {
return Ok((worker.ranks[idx], Device::CUDA(0)));
}
}
Ok((worker.ranks[idx], Device::CUDA(worker.local_devices[idx])))
}
pub fn spans_multiple_workers(&self) -> bool {
self.num_workers > 1
}
pub fn rendezvous(&self, dataset_signature: [u8; 32]) -> Result<TcpRendezvous> {
TcpRendezvous::establish(self, dataset_signature, NcclUniqueId::new)
}
}
fn parse_worker(v: &Value) -> Result<WorkerBlock> {
let obj = v
.as_object()
.ok_or_else(|| TensorError::new("cluster.worker: must be an object"))?;
let name = obj
.get("host")
.and_then(Value::as_str)
.ok_or_else(|| TensorError::new("cluster.worker.host (string) required"))?
.to_string();
let ranks = parse_usize_array(obj.get("ranks"), "cluster.worker.ranks")?;
if ranks.is_empty() {
return Err(TensorError::new(&format!(
"cluster.worker ({name:?}): ranks must be non-empty"
)));
}
let local_devices = parse_local_devices(
obj.get("local_devices"),
&name,
ranks.len(),
)?;
let nccl_socket_ifname = obj
.get("nccl_socket_ifname")
.and_then(Value::as_str)
.ok_or_else(|| {
TensorError::new(&format!(
"cluster.worker ({name:?}): nccl_socket_ifname (string) required"
))
})?
.to_string();
let path = obj
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| {
TensorError::new(&format!(
"cluster.worker ({name:?}): path (string) required"
))
})?
.to_string();
let arch = obj
.get("arch")
.and_then(Value::as_str)
.map(String::from);
Ok(WorkerBlock {
host: name,
ranks,
local_devices,
nccl_socket_ifname,
path,
arch,
})
}
fn parse_local_devices(v: Option<&Value>, host_name: &str, ranks_len: usize) -> Result<Vec<u8>> {
let v = v.ok_or_else(|| {
TensorError::new("cluster.worker.local_devices: required ([..] or \"all\")")
})?;
if let Some(s) = v.as_str() {
if s != "all" {
return Err(TensorError::new(&format!(
"cluster.worker.local_devices: expected \"all\" or array, got string {s:?}"
)));
}
let available = crate::tensor::cuda_device_count();
if available < 0 {
return Err(TensorError::new(
"cluster.worker.local_devices: \"all\" requires CUDA support; \
cuda_device_count() returned a negative value",
));
}
let available = available as usize;
if available < ranks_len {
return Err(TensorError::new(&format!(
"cluster.worker ({host_name:?}): local_devices: \"all\" \
resolved to {available} visible CUDA device(s), but \
ranks.len() = {ranks_len} requires at least that many. \
Check CUDA_VISIBLE_DEVICES and host GPU inventory."
)));
}
return Ok((0..ranks_len as u8).collect());
}
let devs_u64 = parse_u64_array(Some(v), "cluster.worker.local_devices")?;
let local_devices: Vec<u8> = devs_u64
.into_iter()
.map(|d| {
u8::try_from(d).map_err(|_| {
TensorError::new(&format!(
"cluster.worker.local_devices: value {d} does not fit in u8"
))
})
})
.collect::<Result<_>>()?;
if ranks_len != local_devices.len() {
return Err(TensorError::new(&format!(
"cluster.worker ({host_name:?}): ranks ({}) and local_devices ({}) length mismatch",
ranks_len,
local_devices.len()
)));
}
Ok(local_devices)
}
fn parse_usize_array(v: Option<&Value>, label: &str) -> Result<Vec<usize>> {
let arr = v
.and_then(Value::as_array)
.ok_or_else(|| TensorError::new(&format!("{label} (array) required")))?;
arr.iter()
.map(|e| {
let n = e
.as_u64()
.ok_or_else(|| TensorError::new(&format!("{label}: non-integer entry")))?;
usize::try_from(n).map_err(|_| {
TensorError::new(&format!("{label}: value {n} does not fit in usize"))
})
})
.collect()
}
fn parse_u64_array(v: Option<&Value>, label: &str) -> Result<Vec<u64>> {
let arr = v
.and_then(Value::as_array)
.ok_or_else(|| TensorError::new(&format!("{label} (array) required")))?;
arr.iter()
.map(|e| {
e.as_u64()
.ok_or_else(|| TensorError::new(&format!("{label}: non-integer entry")))
})
.collect()
}
fn local_rank_index_from_env(local_count: usize, host_name: &str) -> Result<usize> {
let idx = if let Some(i) = THREAD_LOCAL_RANK_OVERRIDE.with(|c| *c.borrow()) {
i
} else {
let raw = env::var(ENV_LOCAL_RANK).map_err(|_| {
TensorError::new(&format!(
"cluster: {ENV_LOCAL_RANK} not set; in cluster mode each process \
must own exactly one local rank. The fdl-cli launcher injects \
this env var per spawned child -- if you are running cluster \
code without the launcher, set it manually."
))
})?;
let trimmed = raw.trim();
trimmed.parse::<usize>().map_err(|e| {
TensorError::new(&format!(
"cluster: {ENV_LOCAL_RANK}={trimmed:?} is not a valid usize: {e}"
))
})?
};
if idx >= local_count {
return Err(TensorError::new(&format!(
"cluster: {ENV_LOCAL_RANK}={idx} out of bounds for host {host_name:?} \
(host owns {local_count} local rank(s); valid indexes are \
0..{local_count})"
)));
}
Ok(idx)
}
pub(crate) fn resolve_hostname() -> Result<String> {
if let Some(s) = THREAD_HOSTNAME_OVERRIDE.with(|c| c.borrow().clone()) {
return Ok(s);
}
if let Ok(s) = env::var(ENV_HOST_OVERRIDE) {
let s = s.trim();
if !s.is_empty() {
return Ok(s.to_string());
}
}
let out = Command::new("hostname").output().map_err(|e| {
TensorError::new(&format!(
"cluster: `hostname` command failed: {e} \
(set {ENV_HOST_OVERRIDE} to override)"
))
})?;
if !out.status.success() {
return Err(TensorError::new(&format!(
"cluster: `hostname` command exited non-zero \
(set {ENV_HOST_OVERRIDE} to override)"
)));
}
let s = String::from_utf8(out.stdout).map_err(|e| {
TensorError::new(&format!(
"cluster: hostname output not UTF-8: {e} \
(set {ENV_HOST_OVERRIDE} to override)"
))
})?;
Ok(s.trim().to_string())
}
pub(crate) fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
if s.len() % 2 != 0 {
return Err(format!("odd-length hex string ({} chars)", s.len()));
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
for i in (0..bytes.len()).step_by(2) {
let hi = hex_nibble(bytes[i])?;
let lo = hex_nibble(bytes[i + 1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_nibble(b: u8) -> std::result::Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(10 + b - b'a'),
b'A'..=b'F' => Ok(10 + b - b'A'),
_ => Err(format!("invalid hex character {:?}", b as char)),
}
}
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(TABLE[(b >> 4) as usize] as char);
s.push(TABLE[(b & 0x0F) as usize] as char);
}
s
}
#[cfg(test)]
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
#[path = "cluster_tests.rs"]
mod tests;