use anyhow::{Context, Result};
use rlx_runtime::{Device, parse_device_list};
use serde::{Deserialize, Serialize};
use std::ops::Range;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum KvPolicy {
#[default]
None,
Host,
Device,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PlacementPolicy {
#[default]
RamBalanced,
Throughput,
Auto,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NodeRole {
#[default]
Active,
Standby,
Draining,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
pub addr: String,
#[serde(default)]
pub ssh: Option<String>,
pub ckpt_dir: String,
#[serde(default)]
pub device: DeviceList,
#[serde(default)]
pub precision: Precision,
#[serde(default)]
pub kv_cache: KvPolicy,
#[serde(default)]
pub rng_seed: Option<u64>,
#[serde(default)]
pub max_ram_gb: Option<f64>,
#[serde(default)]
pub layers: Option<String>,
#[serde(default)]
pub role: NodeRole,
#[serde(default)]
pub standby_for: Option<String>,
}
impl NodeConfig {
pub fn devices(&self) -> &[Device] {
self.device.as_slice()
}
pub fn primary_device(&self) -> Device {
self.device.primary()
}
pub fn cpu_offload(&self) -> bool {
self.device.has_cpu_spill()
}
pub fn manual_range(&self) -> Option<Range<usize>> {
let s = self.layers.as_ref()?;
let (a, b) = s.split_once(':')?;
Some(a.trim().parse().ok()?..b.trim().parse().ok()?)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterConfig {
pub model: String,
#[serde(default = "default_seq")]
pub seq: usize,
#[serde(default)]
pub rng_seed: Option<u64>,
#[serde(default = "default_reserve")]
pub reserve_ram_gb: f64,
#[serde(default)]
pub placement: PlacementSection,
#[serde(default)]
pub discover: super::discover::DiscoverSection,
#[serde(rename = "node", default)]
pub nodes: Vec<NodeConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlacementSection {
#[serde(default)]
pub policy: PlacementPolicy,
#[serde(default)]
pub objective: Objective,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Optimize {
Latency,
#[default]
Throughput,
Memory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Objective {
#[serde(default)]
pub optimize: Optimize,
#[serde(default = "default_context")]
pub context: usize,
#[serde(default = "default_hop_secs")]
pub hop_secs: f64,
#[serde(default)]
pub kv_bytes_per_layer_token: Option<u64>,
#[serde(default)]
pub on_unknown_kv: UnknownKv,
#[serde(default)]
pub kv_dtype: Option<String>,
#[serde(default)]
pub precision_ladder: Vec<Precision>,
}
impl Default for Objective {
fn default() -> Self {
Self {
optimize: Optimize::default(),
context: default_context(),
hop_secs: default_hop_secs(),
kv_bytes_per_layer_token: None,
on_unknown_kv: UnknownKv::default(),
kv_dtype: None,
precision_ladder: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UnknownKv {
#[default]
Fail,
Assume,
Ignore,
}
fn default_context() -> usize {
4096
}
fn default_hop_secs() -> f64 {
0.002
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Precision(String);
impl Precision {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn bits(&self) -> Option<f64> {
Some(match self.0.as_str() {
"f32" => 32.0,
"f16" | "bf16" | "mixed" => 16.0,
"fp8" | "fp8e5m2" | "mxfp8" => 8.0,
"int8" | "q8_0" => 8.5,
"nvfp4" | "mxfp4" => 4.25,
other => return minifloat_bits(other),
})
}
}
fn minifloat_bits(name: &str) -> Option<f64> {
let rest = name.strip_prefix('f')?;
let width: String = rest.chars().take_while(char::is_ascii_digit).collect();
if width.is_empty() {
return None;
}
let bits: u32 = width.parse().ok()?;
let tail = &rest[width.len()..];
if tail.is_empty() {
return matches!(bits, 8 | 16 | 32 | 64).then_some(bits as f64);
}
let em = tail.strip_prefix('e')?;
let (e, m) = em.split_once('m')?;
if e.is_empty() || m.is_empty() || !e.chars().all(|c| c.is_ascii_digit()) {
return None;
}
m.chars().all(|c| c.is_ascii_digit()).then_some(bits as f64)
}
impl std::str::FromStr for Precision {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let p = Self(s.trim().to_string());
if p.bits().is_none() {
anyhow::bail!(
"unknown precision `{s}`; expected f32 | f16 | bf16 | mixed | fp8 | \
mxfp8 | mxfp4 | nvfp4 | int8, or an fNeXmY minifloat such as f8e4m3"
);
}
Ok(p)
}
}
impl TryFrom<String> for Precision {
type Error = anyhow::Error;
fn try_from(s: String) -> Result<Self> {
s.parse()
}
}
impl From<Precision> for String {
fn from(p: Precision) -> String {
p.0
}
}
impl std::fmt::Display for Precision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl Default for Precision {
fn default() -> Self {
Self("bf16".into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct DeviceList {
raw: String,
devices: Vec<Device>,
}
impl DeviceList {
pub fn primary(&self) -> Device {
self.devices.first().copied().unwrap_or(Device::Cpu)
}
pub fn as_slice(&self) -> &[Device] {
&self.devices
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn has_cpu_spill(&self) -> bool {
self.devices.len() > 1 && self.devices.contains(&Device::Cpu)
}
}
impl std::str::FromStr for DeviceList {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let raw = s.trim().to_string();
let devices = parse_device_list(&raw.replace('+', ","))
.map_err(|e| anyhow::anyhow!("bad device `{raw}`: {e:?}"))?;
if devices.is_empty() {
anyhow::bail!("device list `{raw}` is empty");
}
Ok(Self { raw, devices })
}
}
impl TryFrom<String> for DeviceList {
type Error = anyhow::Error;
fn try_from(s: String) -> Result<Self> {
s.parse()
}
}
impl From<DeviceList> for String {
fn from(d: DeviceList) -> String {
d.raw
}
}
impl std::fmt::Display for DeviceList {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
impl Default for DeviceList {
fn default() -> Self {
Self {
raw: "cpu".into(),
devices: vec![Device::Cpu],
}
}
}
fn default_seq() -> usize {
8
}
fn default_reserve() -> f64 {
6.0
}
impl ClusterConfig {
pub fn from_toml_str(s: &str) -> Result<Self> {
toml::from_str(s).map_err(|e| anyhow::anyhow!("parse cluster TOML: {e}"))
}
pub fn from_path(p: impl AsRef<Path>) -> Result<Self> {
let s = std::fs::read_to_string(p.as_ref())
.with_context(|| format!("read {}", p.as_ref().display()))?;
Self::from_toml_str(&s)
}
pub fn seed_for(&self, i: usize) -> u64 {
self.nodes
.get(i)
.and_then(|n| n.rng_seed)
.or(self.rng_seed)
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_and_hand_listed_nodes_coexist() {
let toml = r#"
model = "some/model"
[placement]
policy = "auto"
[discover]
ssh_hosts = ["node-b", "node-c"]
ckpt_dir = "/data/model"
port = 9100
[[node]]
addr = "10.0.0.5:9100"
ckpt_dir = "/pinned/model"
device = "cuda"
max_ram_gb = 40.0
"#;
let cfg = ClusterConfig::from_toml_str(toml).unwrap();
assert_eq!(cfg.placement.policy, PlacementPolicy::Auto);
assert_eq!(cfg.nodes.len(), 1);
assert_eq!(cfg.nodes[0].addr, "10.0.0.5:9100");
assert_eq!(cfg.nodes[0].max_ram_gb, Some(40.0));
assert!(!cfg.discover.is_empty());
assert_eq!(cfg.discover.ssh_hosts, ["node-b", "node-c"]);
assert_eq!(cfg.discover.ckpt_dir.as_deref(), Some("/data/model"));
assert_eq!(cfg.discover.port, 9100);
}
#[test]
fn discover_only_config_needs_no_node_blocks() {
let cfg = ClusterConfig::from_toml_str(
r#"
model = "m"
[discover]
ssh_hosts = ["a"]
ckpt_dir = "/m"
"#,
)
.unwrap();
assert!(cfg.nodes.is_empty());
assert!(!cfg.discover.is_empty());
}
#[test]
fn hand_listed_only_config_is_unchanged() {
let cfg = ClusterConfig::from_toml_str(
r#"
model = "m"
[[node]]
addr = "a:1"
ckpt_dir = "/m"
"#,
)
.unwrap();
assert_eq!(cfg.nodes.len(), 1);
assert!(cfg.discover.is_empty());
assert_eq!(cfg.placement.policy, PlacementPolicy::RamBalanced);
}
#[test]
fn device_and_precision_still_serialize_as_plain_strings() {
let cfg = ClusterConfig::from_toml_str(
r#"
model = "m"
[[node]]
addr = "a:1"
ckpt_dir = "/m"
device = "cuda+cpu"
precision = "f8e4m3"
"#,
)
.unwrap();
assert_eq!(cfg.nodes[0].device.as_str(), "cuda+cpu");
assert_eq!(cfg.nodes[0].precision.as_str(), "f8e4m3");
let back = toml::to_string(&cfg).unwrap();
assert!(back.contains(r#"device = "cuda+cpu""#), "{back}");
assert!(back.contains(r#"precision = "f8e4m3""#), "{back}");
}
#[test]
fn bad_device_and_precision_fail_at_load() {
let bad_dev = ClusterConfig::from_toml_str(
r#"model="m"
[[node]]
addr="a:1"
ckpt_dir="/m"
device="quantum"
"#,
)
.unwrap_err()
.to_string();
assert!(bad_dev.contains("quantum"), "{bad_dev}");
let bad_prec = ClusterConfig::from_toml_str(
r#"model="m"
[[node]]
addr="a:1"
ckpt_dir="/m"
precision="f7ish"
"#,
)
.unwrap_err()
.to_string();
assert!(bad_prec.contains("unknown precision"), "{bad_prec}");
}
#[test]
fn device_list_exposes_primary_and_spill() {
let d: DeviceList = "cuda+cpu".parse().unwrap();
assert_eq!(d.primary(), Device::Cuda);
assert!(d.has_cpu_spill());
let solo: DeviceList = "metal".parse().unwrap();
assert_eq!(solo.primary(), Device::Metal);
assert!(!solo.has_cpu_spill());
assert_eq!(DeviceList::default().primary(), Device::Cpu);
}
#[test]
fn minifloat_widths_are_parsed_not_looked_up() {
for (name, bits) in [
("f8e4m3", 8.0),
("f8e5m2", 8.0),
("f6e3m2", 6.0),
("f4e2m1", 4.0),
("f4e3m0", 4.0),
] {
let p: Precision = name.parse().unwrap();
assert_eq!(p.bits(), Some(bits), "{name}");
}
assert_eq!(Precision::default().bits(), Some(16.0));
assert_eq!("f32".parse::<Precision>().unwrap().bits(), Some(32.0));
assert_eq!("mxfp4".parse::<Precision>().unwrap().bits(), Some(4.25));
assert_eq!("int8".parse::<Precision>().unwrap().bits(), Some(8.5));
}
#[test]
fn malformed_minifloats_are_rejected() {
for bad in ["f8e4", "f8m3", "fe4m3", "f8e4mx", "f9", "bf", "f8e4m"] {
assert!(
bad.parse::<Precision>().is_err(),
"`{bad}` should not parse"
);
}
}
#[test]
fn policy_names_round_trip() {
for (name, want) in [
("auto", PlacementPolicy::Auto),
("ram_balanced", PlacementPolicy::RamBalanced),
("throughput", PlacementPolicy::Throughput),
("manual", PlacementPolicy::Manual),
] {
let cfg = ClusterConfig::from_toml_str(&format!(
"model = \"m\"\n[placement]\npolicy = \"{name}\"\n[[node]]\naddr=\"a:1\"\nckpt_dir=\"/m\"\n"
))
.unwrap();
assert_eq!(cfg.placement.policy, want, "{name}");
}
}
}