1pub mod caps;
34pub mod config;
35pub mod cost;
36pub mod discover;
37pub mod placement;
38pub mod stats;
39
40pub use caps::{DeviceInfo, NodeCaps, probe_local, probe_remote};
41pub use config::{
42 ClusterConfig, DeviceList, KvPolicy, NodeConfig, NodeRole, Objective, Optimize,
43 PlacementPolicy, PlacementSection, Precision, UnknownKv,
44};
45pub use cost::{
46 BlockKv, CostOptions, KvDetail, KvMarkers, KvProfile, KvSource, Markers, ModelCost, TensorEntry,
47};
48pub use discover::{DiscoverSection, Discovered, discover_ssh, discover_subnet};
49pub use placement::{Assignment, assign_standbys, plan_placement, plan_placement_with, resolve_kv};
50pub use rlx_driver::{ProcessGroup, ReduceKind};
51pub use stats::{ClusterRun, NodeReport, StageTiming};
52
53use crate::graph::transport::run_pipeline_tcp_timed;
54use crate::graph::{NamedTensor, Stage};
55use anyhow::{Context, Result};
56use std::path::Path;
57use std::process::Child;
58
59pub fn all_reduce(pg: &ProcessGroup, buf: &mut [f32], kind: ReduceKind) -> Result<()> {
65 pg.all_reduce(buf, kind)
66 .map_err(|e| anyhow::anyhow!("all_reduce: {e:?}"))
67}
68
69pub struct Cluster {
71 pub cfg: ClusterConfig,
72 pub caps: Vec<NodeCaps>,
74 pub plan: Vec<Assignment>,
77 pub chosen_precision: Option<String>,
79 last_kv: Option<KvProfile>,
81}
82
83impl Cluster {
84 pub fn from_config(cfg: ClusterConfig) -> Self {
85 Self {
86 cfg,
87 caps: Vec::new(),
88 plan: Vec::new(),
89 chosen_precision: None,
90 last_kv: None,
91 }
92 }
93 pub fn from_path(p: impl AsRef<Path>) -> Result<Self> {
94 Ok(Self::from_config(ClusterConfig::from_path(p)?))
95 }
96
97 pub fn probe(&mut self, remote_bin: &str) -> Result<&[NodeCaps]> {
101 self.caps.clear();
102 for n in &self.cfg.nodes {
103 let caps = match &n.ssh {
104 Some(host) => probe_remote(host, remote_bin, &n.addr, &n.ckpt_dir)
105 .with_context(|| format!("probe {host}"))?,
106 None => probe_local(&n.addr, &n.ckpt_dir, true),
107 };
108 self.caps.push(caps);
109 }
110 Ok(&self.caps)
111 }
112
113 pub fn discover(&mut self, remote_bin: &str) -> Result<(usize, Vec<(String, String)>)> {
120 let d = self.cfg.discover.clone();
121 if d.is_empty() {
122 return Ok((0, Vec::new()));
123 }
124 let mut added = 0;
125 let mut failed = Vec::new();
126 if !d.ssh_hosts.is_empty() {
127 let (found, f) = discover::discover_ssh(&d, remote_bin)?;
128 added += discover::merge_nodes(&mut self.cfg.nodes, found);
129 failed.extend(f);
130 }
131 if d.subnet.is_some() {
132 let found = discover::discover_subnet(&d)?;
133 added += discover::merge_nodes(&mut self.cfg.nodes, found);
134 }
135 if self.cfg.nodes.is_empty() {
136 anyhow::bail!(
137 "discovery found no usable nodes{}",
138 if failed.is_empty() {
139 String::new()
140 } else {
141 format!(
142 " ({} host(s) failed to probe: {})",
143 failed.len(),
144 failed
145 .iter()
146 .map(|(h, e)| format!("{h}: {e}"))
147 .collect::<Vec<_>>()
148 .join("; ")
149 )
150 }
151 );
152 }
153 Ok((added, failed))
154 }
155
156 pub fn autoplan(
167 &mut self,
168 index: &[TensorEntry],
169 opts: &CostOptions,
170 remote_bin: &str,
171 ) -> Result<&[Assignment]> {
172 if !self.cfg.discover.is_empty() {
173 let (added, failed) = self.discover(remote_bin)?;
174 for (h, e) in &failed {
175 eprintln!("discover: skipping {h}: {e}");
176 }
177 if added > 0 {
178 eprintln!("discover: added {added} node(s)");
179 }
180 }
181 self.probe(remote_bin)?;
182 let cost = ModelCost::from_tensor_index(index, opts)?;
183 eprintln!("model: {}", cost.summary());
184 self.plan(cost)
185 }
186
187 pub fn retire(&mut self, addr: &str, model: ModelCost) -> Result<&[Assignment]> {
198 use config::NodeRole;
199 let i = self
200 .cfg
201 .nodes
202 .iter()
203 .position(|n| n.addr == addr)
204 .with_context(|| format!("no node {addr} in this cluster"))?;
205 anyhow::ensure!(
206 self.cfg.nodes[i].role != NodeRole::Draining,
207 "node {addr} is already draining"
208 );
209 self.cfg.nodes[i].role = NodeRole::Draining;
210
211 let promoted = self
213 .cfg
214 .nodes
215 .iter_mut()
216 .find(|n| n.role == NodeRole::Standby && n.standby_for.as_deref() == Some(addr))
217 .map(|n| {
218 n.role = NodeRole::Active;
219 n.standby_for = None;
220 n.addr.clone()
221 });
222 if let Some(p) = &promoted {
223 eprintln!("retire {addr}: promoted standby {p} (weights already loaded)");
224 } else {
225 eprintln!("retire {addr}: no standby — re-planning across the remaining nodes");
226 }
227 self.plan(model)
228 }
229
230 pub fn active_nodes(&self) -> Vec<&NodeConfig> {
232 self.cfg
233 .nodes
234 .iter()
235 .filter(|n| n.role == config::NodeRole::Active)
236 .collect()
237 }
238
239 pub fn kv_profile(&self) -> KvProfile {
242 self.last_kv.clone().unwrap_or_default()
243 }
244
245 pub fn plan_table(&self) -> String {
251 let mut out =
252 String::from("node device layers resident disk stage\n");
253 for a in &self.plan {
254 out.push_str(&format!(
255 "{:<20} {:<9} {:>3}..{:<3} {:>7.1}/{:<5.1}G {:>7.1}G {:>7.2}G {:>6.3}s{}\n",
256 a.addr,
257 a.device,
258 a.layers.start,
259 a.layers.end,
260 a.est_bytes as f64 / 1e9,
261 a.budget_bytes as f64 / 1e9,
262 a.est_disk_bytes as f64 / 1e9,
263 a.est_kv_bytes as f64 / 1e9,
264 a.est_stage_secs,
265 match (a.role, a.backs.as_deref()) {
266 (config::NodeRole::Standby, Some(b)) => format!(" standby<-{b}"),
267 (config::NodeRole::Standby, None) => " standby".into(),
268 (config::NodeRole::Draining, _) => " draining".into(),
269 _ if a.experts_paged => " paged".into(),
270 _ => String::new(),
271 },
272 ));
273 }
274 let active_layers: u64 = self
275 .plan
276 .iter()
277 .filter(|a| a.role == config::NodeRole::Active)
278 .map(|a| a.layers.len() as u64)
279 .sum();
280 if active_layers > 0 {
281 out.push_str(&format!(
282 "{}\n",
283 self.kv_profile()
284 .summary(active_layers, self.cfg.placement.objective.context)
285 ));
286 }
287 if let Some(slowest) = self
288 .plan
289 .iter()
290 .filter(|a| a.role == config::NodeRole::Active)
291 .max_by(|x, y| x.est_stage_secs.total_cmp(&y.est_stage_secs))
292 {
293 out.push_str(&format!(
294 "critical path: {} at {:.3}s/token\n",
295 slowest.addr, slowest.est_stage_secs
296 ));
297 }
298 out
299 }
300
301 pub fn plan(&mut self, model: ModelCost) -> Result<&[Assignment]> {
303 anyhow::ensure!(!self.caps.is_empty(), "probe() before plan()");
304 anyhow::ensure!(
305 self.caps.len() == self.cfg.nodes.len(),
306 "probed {} node(s) but the config has {} — re-probe after discovery",
307 self.caps.len(),
308 self.cfg.nodes.len()
309 );
310 let all: Vec<(NodeCaps, NodeConfig)> = self
311 .caps
312 .iter()
313 .cloned()
314 .zip(self.cfg.nodes.iter().cloned())
315 .collect();
316 let active: Vec<(NodeCaps, NodeConfig)> = all
319 .iter()
320 .filter(|(_, c)| c.role == config::NodeRole::Active)
321 .cloned()
322 .collect();
323 anyhow::ensure!(
324 !active.is_empty(),
325 "no active nodes: {} standby, {} draining",
326 all.iter()
327 .filter(|(_, c)| c.role == config::NodeRole::Standby)
328 .count(),
329 all.iter()
330 .filter(|(_, c)| c.role == config::NodeRole::Draining)
331 .count()
332 );
333 let reserve = (self.cfg.reserve_ram_gb * 1e9) as u64;
334 let obj = self.cfg.placement.objective.clone();
335 let policy = self.cfg.placement.policy;
336
337 let mut attempts: Vec<(Option<String>, ModelCost)> = vec![(None, model.clone())];
340 for name in &obj.precision_ladder {
341 match name.bits().and_then(|b| model.at_bits(b)) {
342 Some(m) => attempts.push((Some(name.to_string()), m)),
343 None => eprintln!("plan: ignoring precision `{name}` (no fixed width)"),
344 }
345 }
346 let mut last_err = None;
347 for (name, m) in attempts {
348 match placement::plan_placement_with(&m, &active, policy, reserve, &obj) {
349 Ok(mut p) => {
350 if let Some(n) = &name {
351 eprintln!(
352 "plan: does not fit as stored; using precision `{n}` \
353 ({:.1} GB)",
354 m.total_bytes() as f64 / 1e9
355 );
356 self.chosen_precision = Some(n.clone());
357 }
358 let kv = placement::resolve_kv(&m, &obj)?;
359 p.extend(placement::assign_standbys(
360 &m,
361 &all,
362 &p,
363 reserve,
364 &kv,
365 obj.context,
366 ));
367 self.last_kv = Some(kv);
368 self.plan = p;
369 return Ok(&self.plan);
370 }
371 Err(e) => last_err = Some(e),
372 }
373 }
374 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("placement failed")))
375 }
376
377 pub fn worker_argv(&self, i: usize) -> Vec<String> {
380 let a = &self.plan[i];
381 let n = &self.cfg.nodes[i];
382 let mut v = vec![
383 "--role".into(),
384 "worker".into(),
385 "--index".into(),
386 i.to_string(),
387 "--layers".into(),
388 format!("{}:{}", a.layers.start, a.layers.end),
389 "--ckpt".into(),
390 n.ckpt_dir.clone(),
391 "--addr".into(),
392 n.addr.clone(),
393 "--seq".into(),
394 self.cfg.seq.to_string(),
395 "--device".into(),
396 n.device.to_string(),
397 "--precision".into(),
398 n.precision.to_string(),
399 "--kv".into(),
400 format!("{:?}", n.kv_cache).to_lowercase(),
401 "--rng".into(),
402 self.cfg.seed_for(i).to_string(),
403 ];
404 if a.first {
405 v.push("--first".into());
406 }
407 if a.last {
408 v.push("--last".into());
409 }
410 v
411 }
412
413 pub fn launch(&self, remote_bin: &str) -> Result<Vec<Child>> {
416 anyhow::ensure!(!self.plan.is_empty(), "plan() before launch()");
417 use std::process::Stdio;
418 let mut kids = Vec::new();
419 for (i, n) in self.cfg.nodes.iter().enumerate() {
420 let argv = self.worker_argv(i);
421 let child = match &n.ssh {
424 Some(host) => std::process::Command::new("ssh")
425 .arg(host)
426 .arg(format!("{remote_bin} {}", argv.join(" ")))
427 .stdout(Stdio::piped())
428 .spawn()?,
429 None => {
433 let mut c = std::process::Command::new(std::env::current_exe()?);
434 c.args(&argv).stdout(Stdio::piped());
435 if let Some(dir) = rlx_ir::env::var("RLX_WORKER_ERR_DIR")
436 && let Ok(f) = std::fs::File::create(format!("{dir}/local_worker_{i}.err"))
437 {
438 c.stderr(Stdio::from(f));
439 }
440 c.spawn()?
441 }
442 };
443 kids.push(child);
444 }
445 Ok(kids)
446 }
447
448 pub fn drive(&self, inputs: Vec<NamedTensor>) -> Result<ClusterRun> {
452 let stages: Vec<Stage> = (0..self.plan.len()).map(|i| self.meta_stage(i)).collect();
453 let addrs: Vec<String> = self.cfg.nodes.iter().map(|n| n.addr.clone()).collect();
454 let (out, per_stage_ms) = run_pipeline_tcp_timed(&stages, &addrs, inputs)?;
455 let timings = self
456 .plan
457 .iter()
458 .zip(per_stage_ms)
459 .map(|(a, ms)| StageTiming {
460 addr: a.addr.clone(),
461 layers: a.layers.clone(),
462 device: a.device.clone(),
463 build_ms: 0,
464 forward_ms: ms,
465 resident_bytes: a.est_bytes,
466 })
467 .collect::<Vec<_>>();
468 let total: u64 = timings.iter().map(|t| t.forward_ms).sum();
469 Ok(ClusterRun {
470 timings,
471 total_forward_ms: total,
472 output: out.into_iter().next().map(|t| t.data).unwrap_or_default(),
473 })
474 }
475
476 fn meta_stage(&self, i: usize) -> Stage {
480 let (first, last) = (self.plan[i].first, self.plan[i].last);
481 Stage {
482 index: i,
483 graph: rlx_ir::Graph::new("meta"),
484 inputs: vec![if first { "input_ids" } else { "hidden_in" }.into()],
485 outputs: vec![if last { "logits" } else { "hidden_in" }.into()],
486 output_shapes: vec![],
487 params: vec![],
488 }
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use config::NodeRole;
496
497 fn caps_for(addr: &str, ram_gb: f64) -> NodeCaps {
498 NodeCaps {
499 addr: addr.into(),
500 os: "linux".into(),
501 cores: 8,
502 ram_total: (ram_gb * 1e9) as u64,
503 ram_avail: (ram_gb * 1e9) as u64,
504 disk_free: 1_000_000_000_000,
505 devices: vec![],
506 gflops: 100.0,
507 io_mbps: 2000.0,
508 }
509 }
510 fn node_for(addr: &str, role: NodeRole, backs: Option<&str>) -> NodeConfig {
511 NodeConfig {
512 addr: addr.into(),
513 ssh: None,
514 ckpt_dir: "/m".into(),
515 device: "cpu".parse().unwrap(),
516 precision: "bf16".parse().unwrap(),
517 kv_cache: Default::default(),
518 rng_seed: None,
519 max_ram_gb: None,
520 layers: None,
521 role,
522 standby_for: backs.map(String::from),
523 }
524 }
525 fn dense(n_layers: usize, per_layer: u64) -> ModelCost {
526 ModelCost {
527 n_layers,
528 per_layer_bytes: per_layer,
529 per_layer_expert_bytes: 0,
530 per_layer_expert_active_bytes: 0,
531 embed_bytes: 0,
532 head_bytes: 0,
533 per_layer_flops: 1.0,
534 kv: cost::KvProfile::declared(0),
535 params: 0,
536 hidden_size: 0,
537 }
538 }
539 fn cluster(nodes: Vec<NodeConfig>) -> Cluster {
540 let caps = nodes.iter().map(|n| caps_for(&n.addr, 32.0)).collect();
541 let cfg = ClusterConfig {
542 model: "m".into(),
543 seq: 8,
544 rng_seed: None,
545 reserve_ram_gb: 1.0,
546 placement: Default::default(),
547 discover: Default::default(),
548 nodes,
549 };
550 let mut c = Cluster::from_config(cfg);
551 c.caps = caps;
552 c
553 }
554
555 #[test]
558 fn plan_covers_actives_and_mirrors_standbys() {
559 let mut cx = cluster(vec![
560 node_for("a", NodeRole::Active, None),
561 node_for("b", NodeRole::Active, None),
562 node_for("spare", NodeRole::Standby, Some("b")),
563 ]);
564 let plan = cx.plan(dense(8, 1_000_000_000)).unwrap().to_vec();
565 let active: Vec<_> = plan.iter().filter(|a| a.role == NodeRole::Active).collect();
566 let standby: Vec<_> = plan
567 .iter()
568 .filter(|a| a.role == NodeRole::Standby)
569 .collect();
570 assert_eq!(active.len(), 2);
571 assert_eq!(standby.len(), 1);
572 assert_eq!(active[0].layers.start, 0);
574 assert_eq!(active.last().unwrap().layers.end, 8);
575 let b = active.iter().find(|a| a.addr == "b").unwrap();
576 assert_eq!(standby[0].layers, b.layers);
577 }
578
579 #[test]
582 fn retire_promotes_the_standby_without_moving_layers() {
583 let mut cx = cluster(vec![
584 node_for("a", NodeRole::Active, None),
585 node_for("b", NodeRole::Active, None),
586 node_for("spare", NodeRole::Standby, Some("b")),
587 ]);
588 let before = cx.plan(dense(8, 1_000_000_000)).unwrap().to_vec();
589 let b_layers = before
590 .iter()
591 .find(|a| a.addr == "b")
592 .unwrap()
593 .layers
594 .clone();
595
596 let after = cx.retire("b", dense(8, 1_000_000_000)).unwrap().to_vec();
597 assert!(
599 !after
600 .iter()
601 .any(|a| a.addr == "b" && a.role == NodeRole::Active)
602 );
603 let promoted = after.iter().find(|a| a.addr == "spare").unwrap();
604 assert_eq!(promoted.role, NodeRole::Active);
605 assert_eq!(promoted.layers, b_layers, "promotion must not move layers");
606 let mut act: Vec<_> = after
608 .iter()
609 .filter(|a| a.role == NodeRole::Active)
610 .collect();
611 act.sort_by_key(|a| a.layers.start);
612 assert_eq!(act[0].layers.start, 0);
613 assert_eq!(act.last().unwrap().layers.end, 8);
614 }
615
616 #[test]
619 fn retire_without_a_standby_replans_the_rest() {
620 let mut cx = cluster(vec![
621 node_for("a", NodeRole::Active, None),
622 node_for("b", NodeRole::Active, None),
623 ]);
624 cx.plan(dense(8, 1_000_000_000)).unwrap();
625 let after = cx.retire("a", dense(8, 1_000_000_000)).unwrap().to_vec();
626 assert_eq!(after.len(), 1);
627 assert_eq!(after[0].addr, "b");
628 assert_eq!(after[0].layers, 0..8);
629 }
630
631 #[test]
632 fn retiring_the_last_active_node_is_refused() {
633 let mut cx = cluster(vec![node_for("a", NodeRole::Active, None)]);
634 cx.plan(dense(4, 1_000_000_000)).unwrap();
635 let err = cx
636 .retire("a", dense(4, 1_000_000_000))
637 .unwrap_err()
638 .to_string();
639 assert!(err.contains("no active nodes"), "{err}");
640 }
641
642 #[test]
643 fn retiring_an_unknown_node_is_an_error() {
644 let mut cx = cluster(vec![node_for("a", NodeRole::Active, None)]);
645 cx.caps = vec![caps_for("a", 32.0)];
646 let err = cx.retire("nope", dense(4, 1)).unwrap_err().to_string();
647 assert!(err.contains("no node nope"), "{err}");
648 }
649
650 #[test]
652 fn precision_ladder_steps_down_until_it_fits() {
653 let mut cx = cluster(vec![node_for("a", NodeRole::Active, None)]);
654 cx.cfg.placement.objective.precision_ladder = vec!["mxfp4".parse().unwrap()];
655 let mut model = dense(8, 4_000_000_000);
657 model.params = model.total_bytes() / 2; let plan = cx.plan(model).unwrap().to_vec();
659 assert_eq!(plan[0].layers, 0..8);
660 assert_eq!(cx.chosen_precision.as_deref(), Some("mxfp4"));
661 }
662}