Skip to main content

rlx_distributed/cluster/
mod.rs

1// RLX — versatile ML compiler + runtime.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! **Cluster orchestration** — the high-level DX over the pipeline primitives.
5//! Turn a declarative [`ClusterConfig`] into a probed, HW-planned, monitored run:
6//!
7//! ```no_run
8//! # use rlx_distributed::cluster::*;
9//! # fn tensor_index() -> Vec<TensorEntry> { unimplemented!() }
10//! let bin = "/home/user/rlx-models/target/release/examples/dsv4_cluster";
11//! let mut cx = Cluster::from_path("cluster.toml")?;
12//!
13//! // Discover machines, probe them, derive the cost from the checkpoint's
14//! // tensor index, and plan — one call.
15//! cx.autoplan(&tensor_index(), &CostOptions::default().with_experts(8, 288), bin)?;
16//! print!("{}", cx.plan_table());
17//!
18//! // The model crate builds each stage; the coordinator drives + monitors:
19//! let children = cx.launch(bin)?;                    // spawn workers
20//! let run = cx.drive(vec![/* input NamedTensor */])?; // relay + time
21//! println!("{}", run.table());
22//! # Ok::<(), anyhow::Error>(())
23//! ```
24//!
25//! The three steps are still available separately ([`Cluster::discover`],
26//! [`Cluster::probe`], [`Cluster::plan`]) when you want to inspect or override
27//! something in between.
28//!
29//! The model-specific bits (build a `Stage` for a layer range, load its weights)
30//! stay in the model crate behind [`crate::Stage`] / [`crate::ParamSource`]; this
31//! module only orchestrates.
32
33pub 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
59/// Cross-node **all-reduce** over the cluster's [`ProcessGroup`] — the collective
60/// complementary to the pipeline's sequential hidden-state relay. Model crates use
61/// it for TENSOR parallelism (shard one layer across nodes, then `Sum` the partial
62/// matmul outputs) or to aggregate per-node values (`Mean`/`Max`/`Min`). The
63/// ProcessGroup rides the same rlx-driver transports (TCP / Thunderbolt / MLX).
64pub 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
69/// A configured, probed, planned cluster.
70pub struct Cluster {
71    pub cfg: ClusterConfig,
72    /// Probed hardware, one per node (same order as `cfg.nodes`). Empty until [`Cluster::probe`].
73    pub caps: Vec<NodeCaps>,
74    /// Layer→node plan. Empty until [`Cluster::plan`]. Standby stages follow the
75    /// active ones and mirror their layer ranges.
76    pub plan: Vec<Assignment>,
77    /// Set when the precision ladder had to step down from the stored width.
78    pub chosen_precision: Option<String>,
79    /// KV profile the last plan resolved to.
80    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    /// Probe every node's hardware. `remote_bin` is the worker binary path on the
98    /// remote hosts (its `--probe` mode self-reports); nodes without `ssh` are
99    /// probed locally.
100    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    /// Find machines from the `[discover]` section and append them to
114    /// `cfg.nodes`. Returns `(added, failures)`; a host that will not probe is
115    /// reported rather than fatal, so one dead machine does not stop the rest.
116    ///
117    /// Idempotent: nodes already present (by `addr`) are left alone, so
118    /// hand-pinned entries survive and re-running adds nothing.
119    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    /// **Discover → probe → cost → plan, in one call.**
157    ///
158    /// The DX this module exists for: point it at a checkpoint's tensor index
159    /// and get an assignment back, without hand-writing node blocks or working
160    /// out per-layer bytes. `remote_bin` is the worker binary on the remote
161    /// hosts (its `--probe` mode self-reports).
162    ///
163    /// Discovery is skipped when `[discover]` is absent, so an all-manual config
164    /// still works and this is just `probe` + `plan` with the cost derived for
165    /// you.
166    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    /// **Retire a node**, moving its work off without a restart.
188    ///
189    /// Marks it `Draining` and, if a standby is mirroring it, promotes that
190    /// standby in place — it already holds the same layers, so this is a
191    /// pointer swap. With no standby the remaining active nodes are re-planned,
192    /// which works but reloads weights, so a cluster you intend to drain should
193    /// carry spares.
194    ///
195    /// Re-plans from the caps already probed; call [`Cluster::probe`] first if
196    /// the hardware may have changed.
197    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        // Promote a standby that was already mirroring it, if there is one.
212        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    /// Active nodes remaining after any draining/standby ones are excluded.
231    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    /// The KV profile the last plan used, after the objective's override and
240    /// unknown-handling are applied.
241    pub fn kv_profile(&self) -> KvProfile {
242        self.last_kv.clone().unwrap_or_default()
243    }
244
245    /// The plan as a table — what each machine ends up holding.
246    ///
247    /// Shows the disk column and the predicted per-stage time, because for an
248    /// MoE those are what the assignment actually turned on; a plan that looks
249    /// RAM-balanced can still be badly skewed by one node's slow disk.
250    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    /// Plan placement from the model cost model + probed caps (probe first).
302    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        // Only Active nodes carry a stage; standbys mirror one afterwards and
317        // draining nodes are on their way out.
318        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        // Walk the precision ladder: try the model as stored, then each step
338        // down, and report which one fit rather than just "too big".
339        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    /// The worker command-line for node `i` from its plan + config — so the model
378    /// crate's worker binary honours device / precision / rng / kv / layers.
379    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    /// Spawn a worker process per node (ssh for remote, local otherwise). The
414    /// caller drives them via [`Cluster::drive`], then reaps the children.
415    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            // Pipe stdout so the coordinator can await "serving on" + collect the
422            // node's build report; leave stderr inherited for live error output.
423            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                // Local node: this very binary, not the remote path. Its stderr is
430                // otherwise a black hole (the coordinator's own stderr races it);
431                // capture to a file when RLX_WORKER_ERR_DIR is set, for diagnosis.
432                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    /// Drive one forward through the planned stages, timing each, and collect the
449    /// output. `inputs` seeds the first stage (e.g. `input_ids`). Boundary tensor
450    /// name is `hidden`/`hidden_in` per the model's stage I/O convention.
451    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    /// Coordinator-side boundary metadata for stage `i` (no weights, no graph):
477    /// first stage takes `input_ids`, others take `hidden_in`; last emits
478    /// `logits`, others `hidden_in`.
479    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    /// Standbys are planned but do not carry traffic: they mirror an active
556    /// stage and are excluded from the critical path.
557    #[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        // Active stages tile 0..8 exactly; the standby duplicates one of them.
573        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    /// Retiring a node with a standby promotes it in place — the layer range
580    /// does not move, so no weights are reloaded.
581    #[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        // `b` is gone from the active set, `spare` is active and holds its range.
598        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        // Still a complete cover.
607        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    /// Without a standby, retiring still works — it just re-plans, which is the
617    /// slow path (weights reload) and is why spares exist.
618    #[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    /// The ladder steps down until the model fits, and records what it chose.
651    #[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        // 8 layers × 4 GB = 32 GB at bf16; the node has 32 GB with 1 GB reserved.
656        let mut model = dense(8, 4_000_000_000);
657        model.params = model.total_bytes() / 2; // 16 bits per weight
658        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}