use super::caps::NodeCaps;
use super::config::{NodeConfig, PlacementPolicy};
use anyhow::{Result, bail};
use std::ops::Range;
#[derive(Debug, Clone)]
pub struct ModelCost {
pub n_layers: usize,
pub per_layer_bytes: u64,
pub embed_bytes: u64,
pub head_bytes: u64,
pub per_layer_flops: f64,
}
impl ModelCost {
pub fn total_bytes(&self) -> u64 {
self.embed_bytes + self.head_bytes + self.per_layer_bytes * self.n_layers as u64
}
}
#[derive(Debug, Clone)]
pub struct Assignment {
pub addr: String,
pub ssh: Option<String>,
pub layers: Range<usize>,
pub first: bool,
pub last: bool,
pub est_bytes: u64,
pub budget_bytes: u64,
pub device: String,
}
const GPU_ALLOC_SAFETY: f64 = 0.85;
fn budget_bytes(caps: &NodeCaps, cfg: &NodeConfig, reserve: u64) -> u64 {
let mut b = match cfg.max_ram_gb {
Some(gb) => ((gb * 1e9) as u64).min(caps.ram_total),
None => caps.ram_avail,
};
let dev = cfg.primary_device();
let paged = matches!(dev, rlx_runtime::Device::Cuda);
if dev != rlx_runtime::Device::Cpu && !paged {
let vram = caps.accel_mem();
if vram > 0 && vram < caps.ram_total {
b = b.min((vram as f64 * GPU_ALLOC_SAFETY) as u64);
}
}
b.saturating_sub(reserve)
}
fn layer_cap(budget: u64, per_layer: u64, overhead: u64) -> usize {
if per_layer == 0 {
return usize::MAX;
}
(budget.saturating_sub(overhead) / per_layer) as usize
}
pub fn plan_placement(
model: &ModelCost,
nodes: &[(NodeCaps, NodeConfig)],
policy: PlacementPolicy,
reserve_bytes: u64,
) -> Result<Vec<Assignment>> {
let k = nodes.len();
if k == 0 {
bail!("no nodes");
}
if policy == PlacementPolicy::Manual {
let mut out = Vec::new();
for (i, (caps, cfg)) in nodes.iter().enumerate() {
let r = cfg.manual_range().ok_or_else(|| {
anyhow::anyhow!("node {} has no `layers` for manual policy", cfg.addr)
})?;
out.push(mk_assignment(
model,
caps,
cfg,
r,
i == 0,
i == k - 1,
reserve_bytes,
));
}
return Ok(out);
}
let budgets: Vec<u64> = nodes
.iter()
.map(|(c, cfg)| budget_bytes(c, cfg, reserve_bytes))
.collect();
let caps_layers: Vec<usize> = (0..k)
.map(|i| {
let overhead = if i == 0 { model.embed_bytes } else { 0 }
+ if i == k - 1 { model.head_bytes } else { 0 };
layer_cap(budgets[i], model.per_layer_bytes, overhead)
})
.collect();
let total_cap: usize = caps_layers
.iter()
.copied()
.map(|c| c.min(model.n_layers))
.sum();
if total_cap < model.n_layers {
bail!(
"model ({} layers, {:.1} GB) does not fit the cluster (capacity {} layers). \
Lower precision, add a node, raise max_ram, or reduce reserve.",
model.n_layers,
model.total_bytes() as f64 / 1e9,
total_cap
);
}
let weight: Vec<f64> = match policy {
PlacementPolicy::Throughput => nodes
.iter()
.map(|(c, _)| c.gflops.max(1.0) * model.per_layer_flops.max(0.001))
.collect(),
_ => budgets.iter().map(|&b| b as f64).collect(),
};
let wsum: f64 = weight.iter().sum();
let mut counts: Vec<usize> = (0..k)
.map(|i| ((model.n_layers as f64 * weight[i] / wsum).round() as usize).min(caps_layers[i]))
.collect();
let mut assigned: usize = counts.iter().sum();
while assigned < model.n_layers {
let i = (0..k)
.filter(|&i| counts[i] < caps_layers[i])
.max_by_key(|&i| caps_layers[i] - counts[i]);
match i {
Some(i) => {
counts[i] += 1;
assigned += 1;
}
None => bail!("placement: could not distribute remaining layers"),
}
}
while assigned > model.n_layers {
let i = (0..k).filter(|&i| counts[i] > 0).max_by_key(|&i| counts[i]);
match i {
Some(i) => {
counts[i] -= 1;
assigned -= 1;
}
None => break,
}
}
let mut out = Vec::with_capacity(k);
let mut start = 0usize;
for (i, ((caps, cfg), &cnt)) in nodes.iter().zip(&counts).enumerate() {
let r = start..start + cnt;
start += cnt;
out.push(mk_assignment(
model,
caps,
cfg,
r,
i == 0,
i == k - 1,
reserve_bytes,
));
}
Ok(out)
}
fn mk_assignment(
model: &ModelCost,
caps: &NodeCaps,
cfg: &NodeConfig,
layers: Range<usize>,
first: bool,
last: bool,
reserve: u64,
) -> Assignment {
let n = (layers.end - layers.start) as u64;
let est = n * model.per_layer_bytes
+ if first { model.embed_bytes } else { 0 }
+ if last { model.head_bytes } else { 0 };
Assignment {
addr: cfg.addr.clone(),
ssh: cfg.ssh.clone(),
layers,
first,
last,
est_bytes: est,
budget_bytes: budget_bytes(caps, cfg, reserve),
device: rlx_runtime::device_label(cfg.primary_device()).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(addr: &str, ram_gb: f64, gflops: f64) -> NodeCaps {
NodeCaps {
addr: addr.into(),
os: "linux".into(),
cores: 8,
ram_total: (ram_gb * 1e9) as u64,
ram_avail: (ram_gb * 1e9) as u64,
disk_free: 500_000_000_000,
devices: vec![],
gflops,
io_mbps: 0.0,
}
}
fn node(addr: &str) -> NodeConfig {
NodeConfig {
addr: addr.into(),
ssh: None,
ckpt_dir: "/x".into(),
device: "cpu".into(),
precision: "bf16".into(),
kv_cache: Default::default(),
rng_seed: None,
max_ram_gb: None,
layers: None,
}
}
#[test]
fn ram_balanced_fits_and_covers_all_layers() {
let model = ModelCost {
n_layers: 43,
per_layer_bytes: 3_000_000_000,
embed_bytes: 200_000_000,
head_bytes: 200_000_000,
per_layer_flops: 1.0,
};
let nodes = vec![
(caps("a", 60.0, 100.0), node("a")),
(caps("b", 55.0, 90.0), node("b")),
(caps("c", 44.0, 60.0), node("c")),
];
let plan =
plan_placement(&model, &nodes, PlacementPolicy::RamBalanced, 5_000_000_000).unwrap();
assert_eq!(plan[0].layers.start, 0);
assert_eq!(plan.last().unwrap().layers.end, 43);
for w in plan.windows(2) {
assert_eq!(w[0].layers.end, w[1].layers.start);
}
for a in &plan {
assert!(
a.est_bytes <= a.budget_bytes,
"{} over budget: {} > {}",
a.addr,
a.est_bytes,
a.budget_bytes
);
}
}
#[test]
fn rejects_when_too_big() {
let model = ModelCost {
n_layers: 100,
per_layer_bytes: 5_000_000_000,
embed_bytes: 0,
head_bytes: 0,
per_layer_flops: 1.0,
};
let nodes = vec![(caps("a", 40.0, 100.0), node("a"))];
assert!(
plan_placement(&model, &nodes, PlacementPolicy::RamBalanced, 5_000_000_000).is_err()
);
}
}