Skip to main content

rlx_distributed/cluster/
placement.rs

1// RLX — versatile ML compiler + runtime.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! **Hardware-aware placement** — split a layer stack into contiguous per-node
5//! stages that (a) FIT each node's RAM/VRAM budget and (b) balance the pipeline.
6//! This replaces hand-tuned `--layers 0:15` splits: give it the model's resident
7//! cost per layer and the probed [`NodeCaps`], get an assignment back.
8
9use super::caps::NodeCaps;
10use super::config::{NodeConfig, Objective, Optimize, PlacementPolicy, UnknownKv};
11use super::cost::ModelCost;
12use anyhow::{Result, bail};
13use std::ops::Range;
14
15/// One node's stage.
16#[derive(Debug, Clone)]
17pub struct Assignment {
18    pub addr: String,
19    pub ssh: Option<String>,
20    pub layers: Range<usize>,
21    pub first: bool,
22    pub last: bool,
23    /// Estimated resident bytes for this stage (weights + embed/head),
24    /// **excluding** routed experts when those page from disk.
25    pub est_bytes: u64,
26    /// The node's stage RAM budget the planner used.
27    pub budget_bytes: u64,
28    /// Routed-expert bytes this stage must keep on disk (0 for a dense model,
29    /// or when the experts fit in RAM alongside the resident weights).
30    pub est_disk_bytes: u64,
31    /// True when this stage's experts page from disk rather than sitting in RAM.
32    /// Paged stages are bounded by [`NodeCaps::io_mbps`], not by GFLOP/s.
33    pub experts_paged: bool,
34    /// Predicted seconds per token for this stage: compute time plus, when
35    /// paged, the time to stream in the experts each token touches. The planner
36    /// balances the pipeline by minimizing the largest of these.
37    pub est_stage_secs: f64,
38    /// KV-cache bytes THIS stage will hold at the objective's context — exact
39    /// for its actual layer range, not the stack average.
40    pub est_kv_bytes: u64,
41    /// Primary device label for the monitor.
42    pub device: String,
43    /// Active / standby / draining, from [`NodeConfig::role`].
44    pub role: super::config::NodeRole,
45    /// For a standby: the `addr` of the active node whose layers it mirrors.
46    pub backs: Option<String>,
47}
48
49/// Fraction of a resident GPU's reported memory usable for one stage's largest
50/// single allocation — the rest is driver reserve + arena/weight-buffer slack.
51const GPU_ALLOC_SAFETY: f64 = 0.85;
52
53/// A node's usable stage-RAM budget. `max_ram_gb`, when set, is the EXPLICIT
54/// budget (the operator's control — overrides a pessimistic `ram_avail`, e.g.
55/// macOS reporting reclaimable cache as used); otherwise the probed available
56/// RAM is used. A *resident* discrete-GPU primary further caps at its VRAM; a
57/// *paged* GPU (CUDA managed memory, or Apple/iGPU unified memory) migrates from
58/// host RAM and so stays bounded by RAM. The reserve headroom is subtracted last.
59fn budget_bytes(caps: &NodeCaps, cfg: &NodeConfig, reserve: u64) -> u64 {
60    let mut b = match cfg.max_ram_gb {
61        Some(gb) => ((gb * 1e9) as u64).min(caps.ram_total),
62        None => caps.ram_avail,
63    };
64    let dev = cfg.primary_device();
65    // CUDA runs on managed (paged) memory here — the stage migrates over PCIe from
66    // host RAM, so it is bounded by RAM, not VRAM. Other discrete GPUs are resident.
67    let paged = matches!(dev, rlx_runtime::Device::Cuda);
68    if dev != rlx_runtime::Device::Cpu && !paged {
69        let vram = caps.accel_mem();
70        if vram > 0 && vram < caps.ram_total {
71            // A single VkDeviceMemory (or discrete cudaMalloc) can't consume the
72            // *whole* reported ceiling: the driver reserves some, the mapped
73            // activation arena + weight buffer are separate allocations, and
74            // alignment adds slack. Cap the stage at 85% of the GPU's memory so
75            // its largest single allocation actually succeeds instead of OOM-ing
76            // right at the ceiling (an amdgpu APU's GTT is especially tight).
77            b = b.min((vram as f64 * GPU_ALLOC_SAFETY) as u64);
78        }
79    }
80    b.saturating_sub(reserve)
81}
82
83/// Max whole layers a node can hold given its byte budget and the per-stage
84/// overhead it must also carry (embed on the first node, head on the last).
85fn layer_cap(budget: u64, per_layer: u64, overhead: u64) -> usize {
86    if per_layer == 0 {
87        return usize::MAX;
88    }
89    (budget.saturating_sub(overhead) / per_layer) as usize
90}
91
92/// Fraction of free disk a stage may fill with expert banks. Leaves room for
93/// the OS, logs and the checkpoint itself if it is not already on that disk.
94const DISK_SAFETY: f64 = 0.90;
95
96/// Whether this node can keep a stage's experts in RAM rather than paging them.
97///
98/// This is the branch that decides whether a stage is compute-bound or
99/// IO-bound, so it is worth being explicit: experts stay resident only if the
100/// RAM budget covers the dense weights AND the full expert banks for the same
101/// layers. Otherwise they live on disk and stream in per token.
102fn experts_fit_in_ram(budget: u64, model: &ModelCost, layers: u64, overhead: u64) -> bool {
103    let need = layers * (model.per_layer_bytes + model.per_layer_expert_bytes) + overhead;
104    need <= budget
105}
106
107/// Max whole layers bounded by free disk for the routed expert banks.
108/// `usize::MAX` for a dense model (no expert bytes to store).
109fn disk_cap(caps: &NodeCaps, model: &ModelCost) -> usize {
110    if model.per_layer_expert_bytes == 0 {
111        return usize::MAX;
112    }
113    let usable = (caps.disk_free as f64 * DISK_SAFETY) as u64;
114    (usable / model.per_layer_expert_bytes) as usize
115}
116
117/// Settle on a KV profile, or explain why we cannot.
118///
119/// Order: an explicit `kv_bytes_per_layer_token` wins, then whatever the
120/// checkpoint yielded, then [`UnknownKv`].
121pub fn resolve_kv(model: &ModelCost, objective: &Objective) -> Result<super::cost::KvProfile> {
122    use super::cost::KvProfile;
123    if let Some(b) = objective.kv_bytes_per_layer_token {
124        return Ok(KvProfile::declared(b));
125    }
126    if model.kv.is_known() {
127        return Ok(model.kv.clone());
128    }
129    match objective.on_unknown_kv {
130        // A declared zero: known to be nothing, as opposed to unknown.
131        UnknownKv::Ignore => Ok(KvProfile::declared(0)),
132        UnknownKv::Assume => {
133            // MHA worst case at model width: every layer caching full keys and
134            // values, `2 × hidden × elem` per token.
135            //
136            // This needs the model's REAL width. Estimating it from byte counts
137            // — `sqrt(per_layer_bytes / 12)`, on the premise that a transformer
138            // layer is ~12·hidden² parameters at >=1 byte each — is not a
139            // conservative fallback: the premise inverts under quantization. At
140            // 2 bits per weight a layer is ~3·hidden² bytes, so the estimate
141            // returns hidden/2 and the "pessimistic" bound comes out at half the
142            // real cache. A bound that can under-reserve is worse than no bound,
143            // so without a width this fails.
144            let hidden = model.hidden_size as u64;
145            if hidden == 0 {
146                bail!(
147                    "`on_unknown_kv = \"assume\"` needs the model width to bound the \
148                     cache, and the checkpoint did not yield one (no rank-1 norm \
149                     inside a block). Estimating it from byte counts under-reserves \
150                     for quantized weights, so set \
151                     `[placement.objective] kv_bytes_per_layer_token = <bytes>` \
152                     instead."
153                );
154            }
155            let elem = super::cost::kv_elem_bytes(objective.kv_dtype.as_deref().unwrap_or("f32"));
156            Ok(KvProfile::declared(2 * hidden * elem))
157        }
158        UnknownKv::Fail => bail!(
159            "KV cache size is unknown, so the planner cannot reserve for it at \
160             context {}. A plan that ignores the cache fits on paper and dies \
161             when it fills. Fix by one of:\n  \
162             • set `[placement.objective] kv_bytes_per_layer_token = <bytes>`\n  \
163             • set `ModelCost::kv` from the model crate (it knows its head dims)\n  \
164             • `on_unknown_kv = \"assume\"` for a pessimistic MHA-width bound\n  \
165             • `on_unknown_kv = \"ignore\"` if there is genuinely no cache",
166            objective.context
167        ),
168    }
169}
170
171/// A node's layer capacity, and whether reaching it means paging experts.
172///
173/// A node has two ways to carry a layer, and the better one wins:
174///
175/// * **experts resident** — RAM holds dense weights *and* expert banks. No disk,
176///   no paging, bounded purely by RAM/VRAM.
177/// * **experts paged** — RAM holds only the dense weights; the banks live on
178///   disk. Bounded by RAM for the dense part *and* by free disk for the banks.
179///
180/// Taking the max matters in both directions: a node with 1 TB of RAM and a
181/// small disk should not be limited by the disk it will never use, and a node
182/// with modest RAM and a big NVMe should not be limited to what its RAM can
183/// hold outright. For a dense model the two coincide.
184fn node_capacity(caps: &NodeCaps, model: &ModelCost, budget: u64, overhead: u64) -> (usize, bool) {
185    let resident = layer_cap(
186        budget,
187        model.per_layer_bytes + model.per_layer_expert_bytes,
188        overhead,
189    );
190    if model.per_layer_expert_bytes == 0 {
191        return (resident, false);
192    }
193    let paged = layer_cap(budget, model.per_layer_bytes, overhead).min(disk_cap(caps, model));
194    if paged > resident {
195        (paged, true)
196    } else {
197        (resident, false)
198    }
199}
200
201/// Predicted seconds per token for `layers` on this node.
202///
203/// `gflops` is the throughput of the device the stage will actually run on —
204/// see [`NodeCaps::gflops_for`], and note that it is not always the node's
205/// fastest device. Compute time comes from that measured throughput; when the experts
206/// page, the stage additionally waits on disk for the bytes each token touches.
207/// A node with a fast NVMe therefore carries more MoE layers than one with the
208/// same RAM and a spinning disk — which is the whole point of costing IO.
209fn stage_secs(caps: &NodeCaps, gflops: f64, model: &ModelCost, layers: u64, paged: bool) -> f64 {
210    let gflops = gflops.max(1.0);
211    // Both terms are seconds: `per_layer_flops` is a real FLOP count (see
212    // `decode_flops_per_layer`) and `gflops` a measured rate, so their quotient
213    // is a time that can be added to the disk wait below.
214    let compute = layers as f64 * model.per_layer_flops / (gflops * 1e9);
215    if !paged || model.per_layer_expert_active_bytes == 0 {
216        return compute;
217    }
218    let bps = (caps.io_mbps.max(1.0)) * 1e6;
219    compute + (layers as f64 * model.per_layer_expert_active_bytes as f64) / bps
220}
221
222/// Move layers between stages until the pipeline's critical path stops
223/// improving.
224///
225/// A pipeline runs at the speed of its slowest stage, so the number that matters
226/// is `max(est_stage_secs)` — not how close each node is to its proportional
227/// share. The proportional split cannot target that directly, because a node's
228/// per-layer cost depends on how many layers it ends up with: cross the point
229/// where the expert banks no longer fit in RAM and the stage switches from
230/// resident to paged, and its per-layer time jumps by the disk term. The weights
231/// are computed before that is known, so they can be badly wrong in exactly the
232/// interesting case.
233///
234/// This is a steepest-descent pass over the real objective: repeatedly take one
235/// layer from the critical node and give it to whichever node leaves the best
236/// sorted-descending time vector. Each accepted move strictly lowers that vector
237/// lexicographically, and the state space is finite, so it terminates; the
238/// iteration cap is a belt-and-braces bound, not the usual exit.
239///
240/// Costs come from [`Costing::secs_for`], the same function that will report the
241/// plan, so what is optimised is what is shown.
242fn balance_critical_path(
243    cost: &Costing<'_>,
244    nodes: &[(NodeCaps, NodeConfig)],
245    caps_layers: &[usize],
246    counts: &mut [usize],
247    optimize: Optimize,
248) {
249    let k = counts.len();
250    if k < 2 {
251        return;
252    }
253    let secs = |i: usize, n: usize| {
254        if n == 0 {
255            return 0.0;
256        }
257        let (caps, cfg) = &nodes[i];
258        cost.secs_for(caps, cfg, n, i == 0, i == k - 1)
259    };
260    // Lexicographic on times sorted worst-first: strictly better than comparing
261    // the max alone, which stalls as soon as two nodes tie for slowest.
262    let key = |c: &[usize]| {
263        let mut v: Vec<f64> = (0..k).map(|i| secs(i, c[i])).collect();
264        v.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
265        v
266    };
267    let better = |a: &[f64], b: &[f64]| -> bool {
268        // `a` improves on `b` by more than rounding noise.
269        for (x, y) in a.iter().zip(b) {
270            if (x - y).abs() > 1e-9 * y.abs().max(1e-6) {
271                return x < y;
272            }
273        }
274        false
275    };
276
277    let mut cur = key(counts);
278    for _ in 0..(counts.iter().sum::<usize>() + 1) * k {
279        // Donor: the stage on the critical path. Anyone else is not the problem.
280        let Some(from) = (0..k).filter(|&i| counts[i] > 0).max_by(|&a, &b| {
281            secs(a, counts[a])
282                .partial_cmp(&secs(b, counts[b]))
283                .unwrap_or(std::cmp::Ordering::Equal)
284        }) else {
285            return;
286        };
287        let mut best: Option<(usize, Vec<f64>)> = None;
288        for to in 0..k {
289            if to == from || counts[to] >= caps_layers[to] {
290                continue;
291            }
292            // Optimising for latency means the fewest hops; waking an unused
293            // node to shave a stage would trade the thing being optimised for.
294            if optimize == Optimize::Latency && counts[to] == 0 {
295                continue;
296            }
297            counts[from] -= 1;
298            counts[to] += 1;
299            let cand = key(counts);
300            counts[from] += 1;
301            counts[to] -= 1;
302            if better(&cand, &cur) && best.as_ref().is_none_or(|(_, b)| better(&cand, b)) {
303                best = Some((to, cand));
304            }
305        }
306        match best {
307            Some((to, k2)) => {
308                counts[from] -= 1;
309                counts[to] += 1;
310                cur = k2;
311            }
312            None => return,
313        }
314    }
315}
316
317/// Plan contiguous stages. `nodes` is in pipeline order (first node runs the
318/// embedding, last runs the head). Returns one [`Assignment`] per node.
319///
320/// Equivalent to [`plan_placement_with`] under the default [`Objective`].
321pub fn plan_placement(
322    model: &ModelCost,
323    nodes: &[(NodeCaps, NodeConfig)],
324    policy: PlacementPolicy,
325    reserve_bytes: u64,
326) -> Result<Vec<Assignment>> {
327    plan_placement_with(model, nodes, policy, reserve_bytes, &Objective::default())
328}
329
330/// Plan contiguous stages under an explicit [`Objective`].
331///
332/// The objective shapes two things the policy alone cannot express:
333///
334/// * **KV is reserved before weights.** `objective.context` decides how much of
335///   each node's RAM the cache will eventually want, and that comes off the
336///   budget first. Fitting the weights and discovering the cache does not fit is
337///   a failure mode worth designing out.
338/// * **[`Optimize::Latency`] uses as few nodes as it can.** Each stage boundary
339///   is a network hop on the critical path, so latency packs nodes full and
340///   leaves the rest idle, while [`Optimize::Throughput`] spreads to even out
341///   stage times and [`Optimize::Memory`] spreads to maximise free RAM.
342pub fn plan_placement_with(
343    model: &ModelCost,
344    nodes: &[(NodeCaps, NodeConfig)],
345    policy: PlacementPolicy,
346    reserve_bytes: u64,
347    objective: &Objective,
348) -> Result<Vec<Assignment>> {
349    let k = nodes.len();
350    if k == 0 {
351        bail!("no nodes");
352    }
353    // Manual: honour each node's explicit range.
354    if policy == PlacementPolicy::Manual {
355        let cost = Costing {
356            model,
357            kv: &resolve_kv(model, objective)?,
358            context: objective.context,
359            reserve: reserve_bytes,
360        };
361        let mut out = Vec::new();
362        for (i, (caps, cfg)) in nodes.iter().enumerate() {
363            let r = cfg.manual_range().ok_or_else(|| {
364                anyhow::anyhow!("node {} has no `layers` for manual policy", cfg.addr)
365            })?;
366            out.push(cost.assign(caps, cfg, r, i == 0, i == k - 1));
367        }
368        return Ok(out);
369    }
370
371    // KV first: reserve what the cache will grow into at the target context, so
372    // the weight budget is what is genuinely left over. Charged per node at its
373    // fair share of layers, since the true split is not known yet.
374    let kv = resolve_kv(model, objective)?;
375    // Budget for the WORST contiguous window of a node's likely size, not the
376    // average one. On a hybrid stack the layers that cache are not evenly
377    // spread, so the average fits on paper and OOMs on whichever node draws the
378    // attention-heavy slice.
379    let fair = (model.n_layers).div_ceil(k).max(1);
380    let kv_reserve = kv.max_window_bytes(fair, objective.context);
381    let budgets: Vec<u64> = nodes
382        .iter()
383        .map(|(c, cfg)| budget_bytes(c, cfg, reserve_bytes).saturating_sub(kv_reserve))
384        .collect();
385    // Per-node capacity in layers (first pays embed, last pays head).
386    let modes: Vec<(usize, bool)> = (0..k)
387        .map(|i| {
388            let overhead = if i == 0 { model.embed_bytes } else { 0 }
389                + if i == k - 1 { model.head_bytes } else { 0 };
390            node_capacity(&nodes[i].0, model, budgets[i], overhead)
391        })
392        .collect();
393    let caps_layers: Vec<usize> = modes.iter().map(|&(c, _)| c).collect();
394    let total_cap: usize = caps_layers
395        .iter()
396        .copied()
397        .map(|c| c.min(model.n_layers))
398        .sum();
399    if total_cap < model.n_layers {
400        // A node in paged mode has capacity == min(ram_for_dense, disk_cap), so
401        // "disk was the binding constraint" is `disk_cap == capacity`, not
402        // `disk_cap < capacity` — the latter can never fire.
403        let disk_bound = model.per_layer_expert_bytes > 0
404            && (0..k).any(|i| modes[i].1 && disk_cap(&nodes[i].0, model) <= caps_layers[i]);
405        bail!(
406            "model ({} layers, {:.1} GB total / {:.1} GB resident) does not fit the \
407             cluster (capacity {} layers).{} Lower precision, add a node, raise \
408             max_ram, or reduce reserve.",
409            model.n_layers,
410            model.total_bytes() as f64 / 1e9,
411            model.total_resident_bytes() as f64 / 1e9,
412            total_cap,
413            if disk_bound {
414                " At least one node is bounded by FREE DISK for the routed expert \
415                 banks, not by RAM — free space or move the checkpoint."
416            } else {
417                ""
418            }
419        );
420    }
421
422    // Weight each node by budget (ram_balanced), raw throughput (throughput), or
423    // predicted per-layer time (auto).
424    let weight: Vec<f64> = match policy {
425        PlacementPolicy::Throughput => nodes
426            .iter()
427            .map(|(c, cfg)| {
428                c.gflops_for(cfg.primary_device()).max(1.0) * model.per_layer_flops.max(0.001)
429            })
430            .collect(),
431        // Auto: share ∝ 1 / (time for one layer here), so the node that
432        // processes a layer fastest gets the most layers — and "fastest"
433        // accounts for expert paging, so a fast-NVMe node outranks an equally
434        // fast one on a slow disk when the experts stream.
435        PlacementPolicy::Auto => (0..k)
436            .map(|i| {
437                let (caps, cfg) = &nodes[i];
438                let t = stage_secs(
439                    caps,
440                    caps.gflops_for(cfg.primary_device()),
441                    model,
442                    1,
443                    modes[i].1,
444                );
445                if t > 0.0 { 1.0 / t } else { 1.0 }
446            })
447            .collect(),
448        _ => budgets.iter().map(|&b| b as f64).collect(),
449    };
450    // Latency: use the fewest nodes that hold the model. Walk the nodes in
451    // pipeline order, filling each to capacity, and drop the rest from the
452    // weighting entirely — an unused stage is a hop not taken.
453    let weight: Vec<f64> = if objective.optimize == Optimize::Latency {
454        let mut left = model.n_layers;
455        (0..k)
456            .map(|i| {
457                if left == 0 {
458                    return 0.0;
459                }
460                let take = caps_layers[i].min(left);
461                left -= take;
462                take as f64
463            })
464            .collect()
465    } else {
466        weight
467    };
468    let wsum: f64 = weight.iter().sum::<f64>().max(f64::MIN_POSITIVE);
469
470    let cost = Costing {
471        model,
472        kv: &kv,
473        context: objective.context,
474        reserve: reserve_bytes,
475    };
476
477    // Proportional target, clamped to per-node layer cap, remainder water-filled.
478    let mut counts: Vec<usize> = (0..k)
479        .map(|i| ((model.n_layers as f64 * weight[i] / wsum).round() as usize).min(caps_layers[i]))
480        .collect();
481    let mut assigned: usize = counts.iter().sum();
482    // Add under-target: give leftover layers to nodes with remaining capacity,
483    // most spare first. Remove over-target similarly.
484    while assigned < model.n_layers {
485        let i = (0..k)
486            .filter(|&i| counts[i] < caps_layers[i])
487            .max_by_key(|&i| caps_layers[i] - counts[i]);
488        match i {
489            Some(i) => {
490                counts[i] += 1;
491                assigned += 1;
492            }
493            None => bail!("placement: could not distribute remaining layers"),
494        }
495    }
496    while assigned > model.n_layers {
497        let i = (0..k).filter(|&i| counts[i] > 0).max_by_key(|&i| counts[i]);
498        match i {
499            Some(i) => {
500                counts[i] -= 1;
501                assigned -= 1;
502            }
503            None => break,
504        }
505    }
506
507    // The proportional split is only as good as the weights, and the weights are
508    // per-node guesses made before the split exists: `weight[i]` costs node i as
509    // if it ran in its capacity-maximising mode, but the split it produces may
510    // put few enough layers there for the experts to sit in RAM instead. That
511    // node is then costed as slow, given few layers, and runs fast — while the
512    // pipeline waits on whoever got the rest. Finish on the real objective.
513    //
514    // Only under `Auto`. The other policies are literal on purpose: `Throughput`
515    // ranks by raw GFLOP/s, `RamBalanced` by budget, and a user who picks one is
516    // asking for that rule rather than for the planner's judgement.
517    if policy == PlacementPolicy::Auto {
518        balance_critical_path(&cost, nodes, &caps_layers, &mut counts, objective.optimize);
519    }
520
521    let mut out = Vec::with_capacity(k);
522    let mut start = 0usize;
523    for (i, ((caps, cfg), &cnt)) in nodes.iter().zip(&counts).enumerate() {
524        let r = start..start + cnt;
525        start += cnt;
526        out.push(cost.assign(caps, cfg, r, i == 0, i == k - 1));
527    }
528    Ok(out)
529}
530
531/// Everything a stage is costed against: the model, the resolved cache, and the
532/// two cluster-wide knobs.
533///
534/// These four always travel together and none of them varies per node, so they
535/// belong to the planner rather than to each call. Threading them individually
536/// had grown the stage-costing function to nine parameters behind an
537/// `#[allow(too_many_arguments)]` — the lint pointing at a missing struct.
538struct Costing<'a> {
539    model: &'a ModelCost,
540    kv: &'a super::cost::KvProfile,
541    context: usize,
542    reserve: u64,
543}
544
545impl Costing<'_> {
546    /// Whether `n` layers here have to page their expert banks off disk.
547    ///
548    /// Single source of truth for the residency mode: the planner asks it while
549    /// choosing a split and [`Self::assign`] asks it when reporting one, so the
550    /// two cannot disagree about whether a stage is paged.
551    fn paged_for(
552        &self,
553        caps: &NodeCaps,
554        cfg: &NodeConfig,
555        n: u64,
556        first: bool,
557        last: bool,
558    ) -> bool {
559        let overhead = if first { self.model.embed_bytes } else { 0 }
560            + if last { self.model.head_bytes } else { 0 };
561        let budget = budget_bytes(caps, cfg, self.reserve);
562        self.model.per_layer_expert_bytes > 0
563            && !experts_fit_in_ram(budget, self.model, n, overhead)
564    }
565
566    /// Predicted seconds per token for `n` layers here, in the mode that count
567    /// actually implies.
568    fn secs_for(
569        &self,
570        caps: &NodeCaps,
571        cfg: &NodeConfig,
572        n: usize,
573        first: bool,
574        last: bool,
575    ) -> f64 {
576        let paged = self.paged_for(caps, cfg, n as u64, first, last);
577        stage_secs(
578            caps,
579            caps.gflops_for(cfg.primary_device()),
580            self.model,
581            n as u64,
582            paged,
583        )
584    }
585
586    /// Cost one node's stage.
587    fn assign(
588        &self,
589        caps: &NodeCaps,
590        cfg: &NodeConfig,
591        layers: Range<usize>,
592        first: bool,
593        last: bool,
594    ) -> Assignment {
595        let (model, reserve) = (self.model, self.reserve);
596
597        let layers_for_kv = layers.clone();
598        let n = (layers.end - layers.start) as u64;
599        let overhead =
600            if first { model.embed_bytes } else { 0 } + if last { model.head_bytes } else { 0 };
601        let budget = budget_bytes(caps, cfg, reserve);
602        let paged = self.paged_for(caps, cfg, n, first, last);
603        let resident_experts = !paged && model.per_layer_expert_bytes > 0;
604        let est = n * model.per_layer_bytes
605            + overhead
606            + if resident_experts {
607                n * model.per_layer_expert_bytes
608            } else {
609                0
610            };
611        Assignment {
612            addr: cfg.addr.clone(),
613            ssh: cfg.ssh.clone(),
614            layers,
615            first,
616            last,
617            est_bytes: est,
618            budget_bytes: budget,
619            est_disk_bytes: if paged {
620                n * model.per_layer_expert_bytes
621            } else {
622                0
623            },
624            experts_paged: paged,
625            est_stage_secs: stage_secs(
626                caps,
627                caps.gflops_for(cfg.primary_device()),
628                model,
629                n,
630                paged,
631            ),
632            est_kv_bytes: self.kv.bytes_for(layers_for_kv, self.context),
633            device: rlx_runtime::device_label(cfg.primary_device()).to_string(),
634            role: cfg.role,
635            backs: cfg.standby_for.clone(),
636        }
637    }
638}
639
640/// Mirror every standby onto the active stage it backs.
641///
642/// A standby is planned with the **same layer range** as its principal, so it
643/// holds the same weights and promoting it is a pointer swap rather than a cold
644/// load — for a 90 GB stage, seconds instead of minutes.
645///
646/// `standby_for` names the principal explicitly. Left unset, a standby backs the
647/// largest active stage that is not already covered, then the next largest: with
648/// one spare you protect the most expensive stage to rebuild, and with several
649/// you spread rather than piling them all on one node.
650pub fn assign_standbys(
651    model: &ModelCost,
652    nodes: &[(NodeCaps, NodeConfig)],
653    active: &[Assignment],
654    reserve_bytes: u64,
655    kv: &super::cost::KvProfile,
656    context: usize,
657) -> Vec<Assignment> {
658    use super::config::NodeRole;
659    let cost = Costing {
660        model,
661        kv,
662        context,
663        reserve: reserve_bytes,
664    };
665    let mut covered: Vec<String> = Vec::new();
666    let mut out = Vec::new();
667    for (caps, cfg) in nodes {
668        if cfg.role != NodeRole::Standby {
669            continue;
670        }
671        let principal = match &cfg.standby_for {
672            Some(addr) => active.iter().find(|a| &a.addr == addr),
673            None => active
674                .iter()
675                .filter(|a| !covered.contains(&a.addr))
676                .max_by_key(|a| a.layers.len())
677                .or_else(|| active.iter().max_by_key(|a| a.layers.len())),
678        };
679        let Some(p) = principal else { continue };
680        covered.push(p.addr.clone());
681        let mut a = cost.assign(caps, cfg, p.layers.clone(), p.first, p.last);
682        a.backs = Some(p.addr.clone());
683        out.push(a);
684    }
685    out
686}
687
688#[cfg(test)]
689mod tests {
690    use super::super::cost::KvProfile;
691    use super::*;
692
693    fn caps(addr: &str, ram_gb: f64, gflops: f64) -> NodeCaps {
694        caps_full(addr, ram_gb, gflops, 500.0, 0.0)
695    }
696    fn caps_full(addr: &str, ram_gb: f64, gflops: f64, disk_gb: f64, io_mbps: f64) -> NodeCaps {
697        NodeCaps {
698            addr: addr.into(),
699            os: "linux".into(),
700            cores: 8,
701            ram_total: (ram_gb * 1e9) as u64,
702            ram_avail: (ram_gb * 1e9) as u64,
703            disk_free: (disk_gb * 1e9) as u64,
704            devices: vec![],
705            gflops,
706            io_mbps,
707        }
708    }
709    /// An MoE whose expert banks dwarf the dense weights — the case `Auto` is for.
710    fn moe_model(n_layers: usize) -> ModelCost {
711        ModelCost {
712            n_layers,
713            per_layer_bytes: 200_000_000,
714            per_layer_expert_bytes: 2_000_000_000,
715            per_layer_expert_active_bytes: 56_000_000,
716            embed_bytes: 0,
717            head_bytes: 0,
718            per_layer_flops: 1.0,
719            kv: KvProfile::declared(0),
720            params: 0,
721            hidden_size: 0,
722        }
723    }
724    fn node(addr: &str) -> NodeConfig {
725        NodeConfig {
726            addr: addr.into(),
727            ssh: None,
728            ckpt_dir: "/x".into(),
729            device: "cpu".parse().unwrap(),
730            precision: "bf16".parse().unwrap(),
731            kv_cache: Default::default(),
732            rng_seed: None,
733            max_ram_gb: None,
734            layers: None,
735            role: Default::default(),
736            standby_for: None,
737        }
738    }
739
740    #[test]
741    fn ram_balanced_fits_and_covers_all_layers() {
742        // 43 layers @ ~3GB each ≈ 129GB across 3 uneven nodes.
743        let model = ModelCost {
744            n_layers: 43,
745            per_layer_bytes: 3_000_000_000,
746            per_layer_expert_bytes: 0,
747            per_layer_expert_active_bytes: 0,
748            embed_bytes: 200_000_000,
749            head_bytes: 200_000_000,
750            per_layer_flops: 1.0,
751            kv: KvProfile::declared(0),
752            params: 0,
753            hidden_size: 0,
754        };
755        let nodes = vec![
756            (caps("a", 60.0, 100.0), node("a")),
757            (caps("b", 55.0, 90.0), node("b")),
758            (caps("c", 44.0, 60.0), node("c")),
759        ];
760        let plan =
761            plan_placement(&model, &nodes, PlacementPolicy::RamBalanced, 5_000_000_000).unwrap();
762        // Contiguous + complete cover of 0..43.
763        assert_eq!(plan[0].layers.start, 0);
764        assert_eq!(plan.last().unwrap().layers.end, 43);
765        for w in plan.windows(2) {
766            assert_eq!(w[0].layers.end, w[1].layers.start);
767        }
768        // Every stage within budget.
769        for a in &plan {
770            assert!(
771                a.est_bytes <= a.budget_bytes,
772                "{} over budget: {} > {}",
773                a.addr,
774                a.est_bytes,
775                a.budget_bytes
776            );
777        }
778    }
779
780    /// An MoE node's capacity comes from disk, not RAM: 20 layers × 2 GB of
781    /// banks needs 40 GB of disk but only 4 GB of resident weights. Costing the
782    /// banks as resident would say this cluster cannot run the model.
783    #[test]
784    fn experts_are_budgeted_to_disk_not_ram() {
785        let model = moe_model(20);
786        // 16 GB RAM: nowhere near 20×2.2 GB resident, but plenty for 20×0.2 GB.
787        let nodes = vec![(caps_full("a", 16.0, 100.0, 500.0, 2000.0), node("a"))];
788        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
789        assert_eq!(plan[0].layers, 0..20);
790        assert!(plan[0].experts_paged, "experts should page from disk");
791        assert_eq!(plan[0].est_disk_bytes, 20 * 2_000_000_000);
792        // Resident estimate excludes the paged banks.
793        assert_eq!(plan[0].est_bytes, 20 * 200_000_000);
794    }
795
796    /// With RAM to spare the same model keeps its experts resident — no disk, and
797    /// the stage is compute-bound rather than IO-bound.
798    #[test]
799    fn experts_stay_resident_when_ram_allows() {
800        let model = moe_model(4);
801        let nodes = vec![(caps_full("a", 64.0, 100.0, 500.0, 2000.0), node("a"))];
802        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
803        assert!(!plan[0].experts_paged);
804        assert_eq!(plan[0].est_disk_bytes, 0);
805        assert_eq!(plan[0].est_bytes, 4 * 2_200_000_000);
806    }
807
808    /// The point of costing IO: two nodes identical but for disk speed should
809    /// NOT get an equal share when the experts stream.
810    #[test]
811    fn auto_gives_more_layers_to_the_faster_disk() {
812        let model = moe_model(24);
813        let nodes = vec![
814            (caps_full("nvme", 16.0, 100.0, 500.0, 6000.0), node("nvme")),
815            (caps_full("sata", 16.0, 100.0, 500.0, 500.0), node("sata")),
816        ];
817        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
818        let (fast, slow) = (plan[0].layers.len(), plan[1].layers.len());
819        assert!(
820            fast > slow,
821            "fast-NVMe node got {fast} layers, slow-disk node {slow}"
822        );
823        assert_eq!(fast + slow, 24);
824        // RamBalanced cannot see the difference — it splits them evenly.
825        let rb =
826            plan_placement(&model, &nodes, PlacementPolicy::RamBalanced, 2_000_000_000).unwrap();
827        assert_eq!(rb[0].layers.len(), rb[1].layers.len());
828    }
829
830    /// `Auto` must return a plan no single-layer move can improve.
831    ///
832    /// The proportional split cannot target the critical path directly: a node's
833    /// per-layer cost depends on how many layers it gets, because crossing the
834    /// point where the expert banks stop fitting in RAM flips the WHOLE stage
835    /// from resident to paged and adds the disk term to every layer at once. The
836    /// weights are computed before the split exists, so they are guesses; this
837    /// pins that the planner finishes on the real objective.
838    ///
839    /// Note what this does NOT claim: that stage times come out even. A node on
840    /// a 3x slower disk genuinely earns a small resident stage and nothing more,
841    /// and an "even" split there would be worse for everyone.
842    #[test]
843    fn auto_returns_a_plan_no_single_move_improves() {
844        let model = moe_model(46);
845        for nodes in [
846            vec![
847                (caps_full("a", 34.0, 400.0, 500.0, 700.0), node("a")),
848                (caps_full("b", 34.0, 400.0, 500.0, 2200.0), node("b")),
849            ],
850            vec![
851                (caps_full("a", 24.0, 400.0, 500.0, 1500.0), node("a")),
852                (caps_full("b", 48.0, 200.0, 500.0, 1500.0), node("b")),
853                (caps_full("c", 34.0, 900.0, 500.0, 900.0), node("c")),
854            ],
855        ] {
856            let plan =
857                plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
858            let counts: Vec<usize> = plan.iter().map(|a| a.layers.len()).collect();
859            assert_eq!(counts.iter().sum::<usize>(), 46, "every layer placed");
860
861            let k = nodes.len();
862            let secs = |c: &[usize]| -> Vec<f64> {
863                let mut v: Vec<f64> = (0..k)
864                    .map(|i| {
865                        let n = c[i];
866                        if n == 0 {
867                            return 0.0;
868                        }
869                        // Same rule `assign` uses: mode follows the count.
870                        let budget = budget_bytes(&nodes[i].0, &nodes[i].1, 2_000_000_000);
871                        let paged = !experts_fit_in_ram(budget, &model, n as u64, 0);
872                        let g = nodes[i].0.gflops_for(nodes[i].1.primary_device());
873                        stage_secs(&nodes[i].0, g, &model, n as u64, paged)
874                    })
875                    .collect();
876                v.sort_by(|a, b| b.partial_cmp(a).unwrap());
877                v
878            };
879            let base = secs(&counts);
880            for from in 0..k {
881                if counts[from] == 0 {
882                    continue;
883                }
884                for to in 0..k {
885                    if to == from {
886                        continue;
887                    }
888                    let mut c = counts.clone();
889                    c[from] -= 1;
890                    c[to] += 1;
891                    let cand = secs(&c);
892                    assert!(
893                        cand >= base,
894                        "moving a layer {} -> {} improves the critical path \
895                         {base:?} -> {cand:?}; the planner left it on the table \
896                         (counts {counts:?})",
897                        nodes[from].1.addr,
898                        nodes[to].1.addr,
899                    );
900                }
901            }
902        }
903    }
904
905    fn benched(label: &str, gflops: f64) -> super::super::caps::DeviceInfo {
906        super::super::caps::DeviceInfo {
907            device: label.into(),
908            name: label.into(),
909            mem_bytes: 0,
910            unified: true,
911            gflops,
912            available: true,
913        }
914    }
915
916    /// A stage must be costed by the device it RUNS on, not by the node's
917    /// fastest one. On an M4 Pro the CPU's AMX benches ~1100 GFLOP/s against
918    /// Metal's ~540; a node pinned to Metal and priced on the headline figure
919    /// is credited with twice the throughput it will deliver, and the planner
920    /// hands it twice the layers it can carry.
921    #[test]
922    fn a_node_is_costed_by_the_device_its_stage_uses() {
923        let model = moe_model(20);
924        let mut fast_cpu_slow_gpu = caps_full("a", 64.0, 1100.0, 500.0, 1500.0);
925        fast_cpu_slow_gpu.devices = vec![benched("cpu", 1100.0), benched("metal", 540.0)];
926        let plain = caps_full("b", 64.0, 1100.0, 500.0, 1500.0);
927
928        let mut on_metal = node("a");
929        on_metal.device = "metal".parse().unwrap();
930        let nodes = vec![(fast_cpu_slow_gpu, on_metal), (plain, node("b"))];
931        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
932        assert!(
933            plan[0].layers.len() < plan[1].layers.len(),
934            "the Metal node runs at half the CPU node's rate but got {} layers \
935             against {} — it was priced on its fastest device, not its stage's",
936            plan[0].layers.len(),
937            plan[1].layers.len(),
938        );
939    }
940
941    /// Identical machines get identical stages — no drift from the rebalance.
942    #[test]
943    fn auto_splits_identical_nodes_evenly() {
944        let model = moe_model(46);
945        let nodes = vec![
946            (caps_full("a", 34.0, 400.0, 500.0, 1500.0), node("a")),
947            (caps_full("b", 34.0, 400.0, 500.0, 1500.0), node("b")),
948        ];
949        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
950        assert_eq!(plan[0].layers.len(), 23);
951        assert_eq!(plan[1].layers.len(), 23);
952    }
953
954    /// The mode a plan is COSTED in has to be the mode it reports. These used to
955    /// be computed by two different rules, so a stage could be sized as paged
956    /// and then displayed as resident.
957    #[test]
958    fn reported_mode_matches_the_costed_mode() {
959        let model = moe_model(46);
960        let nodes = vec![
961            (caps_full("a", 34.0, 400.0, 500.0, 700.0), node("a")),
962            (caps_full("b", 34.0, 400.0, 500.0, 2200.0), node("b")),
963        ];
964        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
965        for a in &plan {
966            // A paged stage pays disk for its experts; a resident one holds them.
967            if a.experts_paged {
968                assert!(a.est_disk_bytes > 0, "paged stage with no disk cost: {a:?}");
969            } else {
970                assert_eq!(a.est_disk_bytes, 0, "resident stage charged disk: {a:?}");
971                assert!(
972                    a.est_bytes >= a.layers.len() as u64 * model.per_layer_expert_bytes,
973                    "resident stage does not account for its experts: {a:?}"
974                );
975            }
976        }
977    }
978
979    /// A node with fast disk but not enough of it is capped by free space, and
980    /// the error says so rather than blaming RAM.
981    #[test]
982    fn disk_bound_cluster_reports_disk() {
983        let model = moe_model(40);
984        // RAM (16 GB) covers the dense weights for all 40 layers but nowhere near
985        // the banks, so the node pages — and 20 GB of free disk holds only 9
986        // layers' worth. Disk, not RAM, is what stops this cluster.
987        let nodes = vec![(caps_full("a", 16.0, 100.0, 20.0, 6000.0), node("a"))];
988        let err = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000)
989            .unwrap_err()
990            .to_string();
991        assert!(err.contains("FREE DISK"), "{err}");
992    }
993
994    /// A dense model must behave exactly as before — no disk, no paging.
995    #[test]
996    fn dense_models_are_unaffected_by_the_expert_path() {
997        let model = ModelCost {
998            n_layers: 8,
999            per_layer_bytes: 1_000_000_000,
1000            per_layer_expert_bytes: 0,
1001            per_layer_expert_active_bytes: 0,
1002            embed_bytes: 0,
1003            head_bytes: 0,
1004            per_layer_flops: 1.0,
1005            kv: KvProfile::declared(0),
1006            params: 0,
1007            hidden_size: 0,
1008        };
1009        let nodes = vec![(caps("a", 32.0, 100.0), node("a"))];
1010        let plan = plan_placement(&model, &nodes, PlacementPolicy::Auto, 2_000_000_000).unwrap();
1011        assert_eq!(plan[0].layers, 0..8);
1012        assert!(!plan[0].experts_paged);
1013        assert_eq!(plan[0].est_disk_bytes, 0);
1014    }
1015
1016    fn obj(o: Optimize) -> Objective {
1017        Objective {
1018            optimize: o,
1019            ..Default::default()
1020        }
1021    }
1022
1023    /// Latency pays a network hop per stage boundary, so it should use the
1024    /// FEWEST nodes that hold the model — not spread for its own sake.
1025    #[test]
1026    fn latency_uses_as_few_nodes_as_will_fit() {
1027        let model = ModelCost {
1028            n_layers: 12,
1029            per_layer_bytes: 1_000_000_000,
1030            ..moe_model(12)
1031        };
1032        let model = ModelCost {
1033            per_layer_expert_bytes: 0,
1034            per_layer_expert_active_bytes: 0,
1035            ..model
1036        };
1037        // Each node holds all 12 layers on its own.
1038        let nodes = vec![
1039            (caps("a", 32.0, 100.0), node("a")),
1040            (caps("b", 32.0, 100.0), node("b")),
1041            (caps("c", 32.0, 100.0), node("c")),
1042        ];
1043        let lat = plan_placement_with(
1044            &model,
1045            &nodes,
1046            PlacementPolicy::Auto,
1047            2_000_000_000,
1048            &obj(Optimize::Latency),
1049        )
1050        .unwrap();
1051        assert_eq!(lat[0].layers.len(), 12, "first node should take everything");
1052        assert!(
1053            lat[1..].iter().all(|a| a.layers.is_empty()),
1054            "latency should leave the other nodes idle: {:?}",
1055            lat.iter().map(|a| a.layers.len()).collect::<Vec<_>>()
1056        );
1057
1058        // Throughput spreads instead, so no single stage is the bottleneck.
1059        let tp = plan_placement_with(
1060            &model,
1061            &nodes,
1062            PlacementPolicy::Auto,
1063            2_000_000_000,
1064            &obj(Optimize::Throughput),
1065        )
1066        .unwrap();
1067        assert!(tp.iter().all(|a| !a.layers.is_empty()));
1068    }
1069
1070    /// KV must be reserved BEFORE weights: the same cluster that fits at a short
1071    /// context has to refuse a long one rather than OOM at run time.
1072    #[test]
1073    fn kv_cache_is_reserved_before_weights() {
1074        let model = ModelCost {
1075            n_layers: 8,
1076            per_layer_bytes: 1_000_000_000,
1077            per_layer_expert_bytes: 0,
1078            per_layer_expert_active_bytes: 0,
1079            embed_bytes: 0,
1080            head_bytes: 0,
1081            per_layer_flops: 1.0,
1082            // 1 MB per layer per token: 8 layers × 4 k = 32 GB at 4 k context.
1083            kv: KvProfile::declared(1_000_000),
1084            params: 0,
1085            hidden_size: 0,
1086        };
1087        let nodes = vec![(caps("a", 16.0, 100.0), node("a"))];
1088        let short = Objective {
1089            context: 512,
1090            ..Default::default()
1091        };
1092        let long = Objective {
1093            context: 32_768,
1094            ..Default::default()
1095        };
1096        assert!(
1097            plan_placement_with(&model, &nodes, PlacementPolicy::Auto, 1_000_000_000, &short)
1098                .is_ok(),
1099            "8 GB weights + 4 GB KV fits 16 GB"
1100        );
1101        assert!(
1102            plan_placement_with(&model, &nodes, PlacementPolicy::Auto, 1_000_000_000, &long)
1103                .is_err(),
1104            "at 32k the KV cache alone exceeds the node"
1105        );
1106    }
1107
1108    /// A standby mirrors its principal's layer range exactly — that is what makes
1109    /// promotion a pointer swap rather than a reload.
1110    #[test]
1111    fn standby_mirrors_the_stage_it_backs() {
1112        let model = ModelCost {
1113            n_layers: 8,
1114            per_layer_bytes: 1_000_000_000,
1115            per_layer_expert_bytes: 0,
1116            per_layer_expert_active_bytes: 0,
1117            embed_bytes: 0,
1118            head_bytes: 0,
1119            per_layer_flops: 1.0,
1120            kv: KvProfile::declared(0),
1121            params: 0,
1122            hidden_size: 0,
1123        };
1124        let mut spare = node("spare");
1125        spare.role = super::super::config::NodeRole::Standby;
1126        spare.standby_for = Some("b".into());
1127        let active = vec![
1128            (caps("a", 32.0, 100.0), node("a")),
1129            (caps("b", 32.0, 100.0), node("b")),
1130        ];
1131        let plan = plan_placement(&model, &active, PlacementPolicy::Auto, 1_000_000_000).unwrap();
1132        let mut all = active.clone();
1133        all.push((caps("spare", 32.0, 100.0), spare));
1134        let sb = assign_standbys(
1135            &model,
1136            &all,
1137            &plan,
1138            1_000_000_000,
1139            &KvProfile::declared(0),
1140            4096,
1141        );
1142        assert_eq!(sb.len(), 1);
1143        assert_eq!(sb[0].backs.as_deref(), Some("b"));
1144        let b = plan.iter().find(|a| a.addr == "b").unwrap();
1145        assert_eq!(sb[0].layers, b.layers, "standby must hold the same layers");
1146        assert_eq!(sb[0].est_bytes, b.est_bytes);
1147    }
1148
1149    /// With no explicit principal, a spare covers the largest stage — the one
1150    /// that would cost the most to rebuild.
1151    #[test]
1152    fn unassigned_standby_backs_the_largest_stage() {
1153        let model = ModelCost {
1154            n_layers: 12,
1155            per_layer_bytes: 1_000_000_000,
1156            per_layer_expert_bytes: 0,
1157            per_layer_expert_active_bytes: 0,
1158            embed_bytes: 0,
1159            head_bytes: 0,
1160            per_layer_flops: 1.0,
1161            kv: KvProfile::declared(0),
1162            params: 0,
1163            hidden_size: 0,
1164        };
1165        let active = vec![
1166            (caps("big", 64.0, 100.0), node("big")),
1167            (caps("small", 16.0, 100.0), node("small")),
1168        ];
1169        let plan = plan_placement(&model, &active, PlacementPolicy::Auto, 1_000_000_000).unwrap();
1170        let biggest = plan
1171            .iter()
1172            .max_by_key(|a| a.layers.len())
1173            .unwrap()
1174            .addr
1175            .clone();
1176        let mut spare = node("spare");
1177        spare.role = super::super::config::NodeRole::Standby;
1178        let mut all = active.clone();
1179        all.push((caps("spare", 64.0, 100.0), spare));
1180        let sb = assign_standbys(
1181            &model,
1182            &all,
1183            &plan,
1184            1_000_000_000,
1185            &KvProfile::declared(0),
1186            4096,
1187        );
1188        assert_eq!(sb[0].backs.as_deref(), Some(biggest.as_str()));
1189    }
1190
1191    fn unknown_kv_model() -> ModelCost {
1192        ModelCost {
1193            n_layers: 8,
1194            per_layer_bytes: 1_000_000_000,
1195            per_layer_expert_bytes: 0,
1196            per_layer_expert_active_bytes: 0,
1197            embed_bytes: 0,
1198            head_bytes: 0,
1199            per_layer_flops: 1.0,
1200            kv: KvProfile::default(), // Unknown
1201            params: 0,
1202            hidden_size: 0,
1203        }
1204    }
1205
1206    /// Unknown cache size must stop the plan, not quietly reserve nothing — and
1207    /// the message has to say how to move forward.
1208    #[test]
1209    fn unknown_kv_fails_with_the_ways_out() {
1210        let nodes = vec![(caps("a", 32.0, 100.0), node("a"))];
1211        let err = plan_placement_with(
1212            &unknown_kv_model(),
1213            &nodes,
1214            PlacementPolicy::Auto,
1215            1_000_000_000,
1216            &Objective::default(),
1217        )
1218        .unwrap_err()
1219        .to_string();
1220        assert!(err.contains("KV cache size is unknown"), "{err}");
1221        assert!(err.contains("kv_bytes_per_layer_token"), "{err}");
1222        assert!(err.contains("assume"), "{err}");
1223        assert!(err.contains("ignore"), "{err}");
1224    }
1225
1226    /// The bound must be built on the model's real width, at the configured
1227    /// cache dtype — not on a byte-count estimate.
1228    #[test]
1229    fn assume_uses_the_real_width_and_dtype() {
1230        let m = ModelCost {
1231            hidden_size: 4096,
1232            ..unknown_kv_model()
1233        };
1234        let f32_obj = Objective {
1235            on_unknown_kv: UnknownKv::Assume,
1236            ..Default::default()
1237        };
1238        let kv = resolve_kv(&m, &f32_obj).unwrap();
1239        assert_eq!(kv.bytes_per_layer_token(), 2 * 4096 * 4);
1240
1241        let f16_obj = Objective {
1242            kv_dtype: Some("f16".into()),
1243            ..f32_obj.clone()
1244        };
1245        assert_eq!(
1246            resolve_kv(&m, &f16_obj).unwrap().bytes_per_layer_token(),
1247            2 * 4096 * 2
1248        );
1249    }
1250
1251    /// Without a width there is nothing to be conservative about. The old
1252    /// byte-count fallback returned `hidden/2` for a 2-bit checkpoint — HALF the
1253    /// real cache — so a bound that can under-reserve is refused outright.
1254    #[test]
1255    fn assume_refuses_rather_than_inventing_a_width() {
1256        // A 2-bit-per-weight layer: ~3·hidden² bytes, where the old estimate
1257        // `sqrt(bytes/12)` yields hidden/2.
1258        let hidden = 4096u64;
1259        let m = ModelCost {
1260            hidden_size: 0,
1261            per_layer_bytes: 3 * hidden * hidden,
1262            ..unknown_kv_model()
1263        };
1264        let old_estimate = ((m.per_layer_bytes as f64 / 12.0).sqrt()) as u64;
1265        assert_eq!(
1266            old_estimate,
1267            hidden / 2,
1268            "the old fallback halved the width"
1269        );
1270
1271        let err = resolve_kv(
1272            &m,
1273            &Objective {
1274                on_unknown_kv: UnknownKv::Assume,
1275                ..Default::default()
1276            },
1277        )
1278        .unwrap_err()
1279        .to_string();
1280        assert!(err.contains("needs the model width"), "{err}");
1281        assert!(err.contains("kv_bytes_per_layer_token"), "{err}");
1282    }
1283
1284    /// `assume` reserves a pessimistic bound, so it must consume budget — a plan
1285    /// that fits under it fits in practice.
1286    #[test]
1287    fn assume_reserves_more_than_ignore() {
1288        let nodes = vec![(caps("a", 32.0, 100.0), node("a"))];
1289        let mk = |u| Objective {
1290            on_unknown_kv: u,
1291            context: 8192,
1292            ..Default::default()
1293        };
1294        let ignored = plan_placement_with(
1295            &ModelCost {
1296                hidden_size: 4096,
1297                ..unknown_kv_model()
1298            },
1299            &nodes,
1300            PlacementPolicy::Auto,
1301            1_000_000_000,
1302            &mk(UnknownKv::Ignore),
1303        )
1304        .unwrap();
1305        assert_eq!(ignored[0].layers, 0..8);
1306        // The same cluster under the pessimistic bound cannot take all 8.
1307        let assumed = plan_placement_with(
1308            &ModelCost {
1309                hidden_size: 4096,
1310                ..unknown_kv_model()
1311            },
1312            &nodes,
1313            PlacementPolicy::Auto,
1314            1_000_000_000,
1315            &mk(UnknownKv::Assume),
1316        );
1317        match assumed {
1318            Ok(p) => assert!(
1319                p[0].layers.len() <= 8,
1320                "assume must not reserve less than ignore"
1321            ),
1322            Err(e) => assert!(e.to_string().contains("does not fit"), "{e}"),
1323        }
1324    }
1325
1326    /// An explicit figure overrides both inference and the unknown policy.
1327    #[test]
1328    fn declared_kv_overrides_unknown() {
1329        let nodes = vec![(caps("a", 32.0, 100.0), node("a"))];
1330        let obj = Objective {
1331            kv_bytes_per_layer_token: Some(1024),
1332            context: 1024,
1333            ..Default::default()
1334        };
1335        let p = plan_placement_with(
1336            &unknown_kv_model(),
1337            &nodes,
1338            PlacementPolicy::Auto,
1339            1_000_000_000,
1340            &obj,
1341        )
1342        .unwrap();
1343        assert_eq!(p[0].layers, 0..8);
1344    }
1345
1346    #[test]
1347    fn rejects_when_too_big() {
1348        let model = ModelCost {
1349            n_layers: 100,
1350            per_layer_bytes: 5_000_000_000,
1351            per_layer_expert_bytes: 0,
1352            per_layer_expert_active_bytes: 0,
1353            embed_bytes: 0,
1354            head_bytes: 0,
1355            per_layer_flops: 1.0,
1356            kv: KvProfile::declared(0),
1357            params: 0,
1358            hidden_size: 0,
1359        };
1360        let nodes = vec![(caps("a", 40.0, 100.0), node("a"))];
1361        assert!(
1362            plan_placement(&model, &nodes, PlacementPolicy::RamBalanced, 5_000_000_000).is_err()
1363        );
1364    }
1365}