1use 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#[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 pub est_bytes: u64,
26 pub budget_bytes: u64,
28 pub est_disk_bytes: u64,
31 pub experts_paged: bool,
34 pub est_stage_secs: f64,
38 pub est_kv_bytes: u64,
41 pub device: String,
43 pub role: super::config::NodeRole,
45 pub backs: Option<String>,
47}
48
49const GPU_ALLOC_SAFETY: f64 = 0.85;
52
53fn 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 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 b = b.min((vram as f64 * GPU_ALLOC_SAFETY) as u64);
78 }
79 }
80 b.saturating_sub(reserve)
81}
82
83fn 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
92const DISK_SAFETY: f64 = 0.90;
95
96fn 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
107fn 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
117pub 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 UnknownKv::Ignore => Ok(KvProfile::declared(0)),
132 UnknownKv::Assume => {
133 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
171fn 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
201fn stage_secs(caps: &NodeCaps, gflops: f64, model: &ModelCost, layers: u64, paged: bool) -> f64 {
210 let gflops = gflops.max(1.0);
211 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
222fn 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 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 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 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 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
317pub 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
330pub 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 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 let kv = resolve_kv(model, objective)?;
375 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 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 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 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 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 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 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 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 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
531struct Costing<'a> {
539 model: &'a ModelCost,
540 kv: &'a super::cost::KvProfile,
541 context: usize,
542 reserve: u64,
543}
544
545impl Costing<'_> {
546 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 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 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
640pub 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 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 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 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 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 #[test]
784 fn experts_are_budgeted_to_disk_not_ram() {
785 let model = moe_model(20);
786 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 assert_eq!(plan[0].est_bytes, 20 * 200_000_000);
794 }
795
796 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[test]
982 fn disk_bound_cluster_reports_disk() {
983 let model = moe_model(40);
984 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 #[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 #[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 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 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 #[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 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 #[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 #[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(), params: 0,
1202 hidden_size: 0,
1203 }
1204 }
1205
1206 #[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 #[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 #[test]
1255 fn assume_refuses_rather_than_inventing_a_width() {
1256 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 #[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 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 #[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}