1use crate::accelerator::ExecutionBackend;
10use crate::cluster::ClusterState;
11use crate::host::{AccelerationMode, RuntimeProfile};
12use crate::system_profile::SystemProfile;
13use crate::watcher::ProfileChange;
14use std::sync::Arc;
15
16#[derive(Clone, Copy, Debug)]
18pub struct LoadBalanceConfig {
19 pub lock_timeout_us: u64,
21 pub min_load_threshold: f32,
23 pub max_layers_per_assignment: usize,
25 pub max_concurrent_rebalances: usize,
27}
28
29impl Default for LoadBalanceConfig {
30 fn default() -> Self {
31 Self {
32 lock_timeout_us: 1000, min_load_threshold: 1.25,
34 max_layers_per_assignment: 100,
35 max_concurrent_rebalances: 1,
36 }
37 }
38}
39
40impl LoadBalanceConfig {
41 pub fn autotuned(profile: &RuntimeProfile) -> Self {
43 let base_timeout = match profile.acceleration_mode {
44 AccelerationMode::Gpu => 500,
45 AccelerationMode::Avx512 => 750,
46 _ => 1000,
47 };
48 let min_load_threshold = match profile.acceleration_mode {
54 AccelerationMode::Gpu => 1.15,
55 AccelerationMode::Avx512 => 1.05,
56 _ => 1.02,
57 };
58 let max_layers_per_assignment = profile.recommended_workers.max(1) * 2;
59
60 Self {
61 lock_timeout_us: base_timeout,
62 min_load_threshold,
63 max_layers_per_assignment,
64 max_concurrent_rebalances: profile.recommended_workers.clamp(1, 8),
65 }
66 }
67}
68
69#[derive(Clone, Debug)]
71pub struct TensorSlice {
72 pub layer_range: (usize, usize), pub size_gb: f32,
76 pub num_weights: u32,
78}
79
80impl TensorSlice {
81 pub fn new(layer_range: (usize, usize), size_gb: f32) -> Self {
83 Self {
84 layer_range,
85 size_gb,
86 num_weights: 0,
87 }
88 }
89}
90
91#[derive(Clone, Debug)]
93pub struct LoadDistributionPlan {
94 pub distributions: Vec<(String, Vec<TensorSlice>)>,
96 pub total_layers: usize,
98 pub participating_nodes: Vec<String>,
100}
101
102impl LoadDistributionPlan {
103 pub fn new(distributions: Vec<(String, Vec<TensorSlice>)>, total_layers: usize) -> Self {
105 let participating_nodes = distributions
106 .iter()
107 .map(|(node_id, _)| node_id.clone())
108 .collect();
109
110 Self {
111 distributions,
112 total_layers,
113 participating_nodes,
114 }
115 }
116
117 pub fn summary(&self) -> String {
119 let mut output = String::from("Load Distribution Plan\n");
120 output.push_str("======================\n\n");
121
122 for (node_id, slices) in &self.distributions {
123 output.push_str(&format!("Node: {node_id}\n"));
124
125 let total_size_gb: f32 = slices.iter().map(|s| s.size_gb).sum();
126
127 output.push_str(&format!(" Total size: {total_size_gb:.1} GB\n"));
128 output.push_str(&format!(
129 " Layers: {}-{}\n",
130 slices.first().map(|s| s.layer_range.0).unwrap_or(0),
131 slices.last().map(|s| s.layer_range.1).unwrap_or(0)
132 ));
133 output.push('\n');
134 }
135
136 output.push_str(&format!("Total layers: {}\n", self.total_layers));
137 output.push_str(&format!("Nodes: {}\n", self.participating_nodes.join(", ")));
138
139 output
140 }
141}
142
143#[derive(Clone, Debug)]
145pub struct LoadBalancer {
146 cluster: Arc<ClusterState>,
148 config: Arc<std::sync::Mutex<LoadBalanceConfig>>,
150}
151
152impl LoadBalancer {
153 pub fn new(cluster: Arc<ClusterState>, config: LoadBalanceConfig) -> Self {
155 Self {
156 cluster,
157 config: Arc::new(std::sync::Mutex::new(config)),
158 }
159 }
160
161 pub fn with_runtime_profile(cluster: Arc<ClusterState>, profile: &RuntimeProfile) -> Self {
163 Self::new(cluster, LoadBalanceConfig::autotuned(profile))
164 }
165
166 pub fn config(&self) -> LoadBalanceConfig {
168 self.config
169 .lock()
170 .ok()
171 .as_deref()
172 .copied()
173 .unwrap_or_default()
174 }
175
176 pub fn distribute_layers(
183 &self,
184 layers: &[crate::planning::LayerSpec],
185 ) -> Result<LoadDistributionPlan, String> {
186 let nodes_snapshot = self.cluster.nodes_snapshot();
187 if nodes_snapshot.is_empty() {
188 return Err("no nodes available".into());
189 }
190
191 let mut sorted_nodes: Vec<&crate::protocol::NodeResources> =
193 nodes_snapshot.iter().collect();
194 sorted_nodes.sort_by(|a, b| {
195 b.vram_gb
196 .partial_cmp(&a.vram_gb)
197 .unwrap_or(std::cmp::Ordering::Equal)
198 });
199
200 let is_sorted = layers.windows(2).all(|w| w[0].index <= w[1].index);
203 let sorted_layers: std::borrow::Cow<'_, [crate::planning::LayerSpec]> = if is_sorted {
204 std::borrow::Cow::Borrowed(layers)
205 } else {
206 let mut all_layers = layers.to_vec();
207 all_layers.sort_by_key(|l| l.index);
208 std::borrow::Cow::Owned(all_layers)
209 };
210 let total_layer_count = sorted_layers.len();
211
212 let mut distributions = Vec::with_capacity(sorted_nodes.len());
214 let mut participating_nodes = Vec::with_capacity(sorted_nodes.len());
215 let mut current_layer_idx = 0usize;
216
217 for node in &sorted_nodes {
218 if current_layer_idx >= sorted_layers.len() {
219 break;
220 }
221
222 let mut used_vram = 0.0f32;
223 let start_idx = current_layer_idx;
224 let mut end_idx = current_layer_idx;
225
226 for layer in &sorted_layers[current_layer_idx..] {
227 if used_vram + layer.vram_gb > node.vram_gb {
228 break;
229 }
230 end_idx += 1;
231 used_vram += layer.vram_gb;
232 }
233
234 if end_idx > start_idx {
235 let start_layer = sorted_layers[start_idx].index;
236 let end_layer = sorted_layers[end_idx - 1].index + 1;
237 let slice = TensorSlice::new((start_layer, end_layer), used_vram);
239 let node_id = node.id.clone();
240 participating_nodes.push(node_id.clone());
241 distributions.push((node_id, vec![slice]));
242 current_layer_idx = end_idx;
243 }
244 }
245
246 if current_layer_idx >= sorted_layers.len() {
247 Ok(LoadDistributionPlan {
248 distributions,
249 total_layers: total_layer_count,
250 participating_nodes,
251 })
252 } else {
253 Err(format!(
254 "insufficient VRAM: {} layers remain",
255 sorted_layers.len() - current_layer_idx
256 ))
257 }
258 }
259
260 pub fn distribute_layers_with_runtime_profile(
262 &self,
263 layers: &[crate::planning::LayerSpec],
264 profile: &RuntimeProfile,
265 ) -> Result<LoadDistributionPlan, String> {
266 let cfg = self.config();
267 let plan = self.distribute_layers(layers)?;
268 let backend = ExecutionBackend::from_runtime_profile(profile);
269 let vector_bias = (backend.vector_width_bits / 128).max(1);
270 let max_layers_per_slice = cfg
271 .max_layers_per_assignment
272 .min(profile.recommended_workers.max(1).saturating_mul(2))
273 .min(backend.preferred_batch_size / vector_bias)
274 .max(1);
275 Ok(chunk_distribution_plan(plan, max_layers_per_slice))
276 }
277
278 pub fn rebalance(&self) -> bool {
280 let cfg = self.config();
281
282 let (min_available, max_available, has_nodes) = {
283 let metrics = self
284 .cluster
285 .metrics
286 .lock()
287 .unwrap_or_else(|poison| poison.into_inner());
288
289 let mut min_val = f32::MAX;
290 let mut max_val = 0.0_f32;
291 let mut count = 0usize;
292
293 for node in metrics.values() {
294 if node.status == crate::cluster::NodeStatus::Active {
295 min_val = min_val.min(node.available_vram_gb.max(0.0));
296 max_val = max_val.max(node.available_vram_gb.max(0.0));
297 count += 1;
298 }
299 }
300 (min_val, max_val, count > 0)
301 };
302
303 if !has_nodes {
304 return false;
305 }
306
307 let skew_ratio = if min_available <= f32::EPSILON {
309 if max_available > 0.0 {
310 f32::INFINITY
311 } else {
312 1.0
313 }
314 } else {
315 max_available / min_available
316 };
317
318 if skew_ratio >= cfg.min_load_threshold {
319 tracing::info!(
320 "Detected load skew ratio {:.2} (threshold {:.2})",
321 skew_ratio,
322 cfg.min_load_threshold
323 );
324 return true;
325 }
326
327 false
328 }
329
330 pub fn shed_load(&self) -> Vec<(String, String)> {
332 let cfg = self.config();
333 let mut transfers: Vec<(String, String)> = Vec::new();
334
335 let metrics = self
336 .cluster
337 .metrics
338 .lock()
339 .unwrap_or_else(|poison| poison.into_inner());
340
341 let active_nodes: Vec<(&String, f32, f32)> = metrics
343 .values()
344 .filter(|m| m.status == crate::cluster::NodeStatus::Active)
345 .map(|m| (&m.name, m.available_vram_gb, m.total_vram_gb))
346 .collect();
347
348 let overloaded_nodes: Vec<_> = active_nodes
350 .iter()
351 .copied()
352 .filter(|&(_, available, total)| available < total * 0.2)
353 .collect();
354
355 let underloaded_nodes: Vec<_> = active_nodes
357 .iter()
358 .copied()
359 .filter(|&(_, available, total)| available > total * 0.5)
360 .collect();
361
362 for &(overloaded_name, _, _) in &overloaded_nodes {
364 if transfers.len() >= cfg.max_concurrent_rebalances {
365 break;
366 }
367 if let Some(target_name) =
368 self.find_best_target_name(&underloaded_nodes, overloaded_name)
369 {
370 transfers.push((overloaded_name.clone(), target_name.clone()));
371 }
372 }
373
374 transfers
375 }
376
377 fn find_best_target_name<'a>(
379 &self,
380 targets: &[(&'a String, f32, f32)],
381 source_name: &str,
382 ) -> Option<&'a String> {
383 let mut best_target: Option<&'a String> = None;
385 let mut max_available = 0.0f32;
386
387 for &(name, available, _) in targets {
388 if name == source_name {
389 continue;
390 }
391
392 if available > max_available {
393 max_available = available;
394 best_target = Some(name);
395 }
396 }
397
398 best_target
399 }
400
401 pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
407 let rp: RuntimeProfile = profile.into();
408 if let Ok(mut guard) = self.config.lock() {
409 *guard = LoadBalanceConfig::autotuned(&rp);
410 }
411 }
412
413 pub fn subscribe_to_watcher(
418 self: &Arc<Self>,
419 watcher: &crate::watcher::SystemProfileWatcher,
420 ) -> tokio::task::JoinHandle<()> {
421 let this = self.clone();
422 let mut rx = watcher.subscribe();
423 tokio::spawn(async move {
424 while let Ok(event) = rx.recv().await {
425 if let ProfileChange::Updated(profile) = event {
426 this.reconfigure_from_system_profile(&profile);
427 tracing::info!(
428 "load balancer re-tuned from profile: {} workers, {:?} accel",
429 profile.recommended_workers,
430 profile.acceleration_mode,
431 );
432 }
433 }
434 })
435 }
436
437 pub fn distribute_with_deadlock_prevention(
439 &self,
440 layers: &[crate::planning::LayerSpec],
441 ) -> Result<LoadDistributionPlan, String> {
442 let cfg = self.config();
443 let start_time = std::time::Instant::now();
445
446 while start_time.elapsed().as_micros() < cfg.lock_timeout_us as u128 {
447 match self.distribute_layers(layers) {
448 Ok(plan) => return Ok(plan),
449 Err(_) => {
450 std::thread::sleep(std::time::Duration::from_millis(10));
452 }
453 }
454 }
455
456 Err("deadlock prevention timeout".into())
457 }
458}
459
460fn chunk_distribution_plan(
461 plan: LoadDistributionPlan,
462 max_layers_per_slice: usize,
463) -> LoadDistributionPlan {
464 let slice_limit = max_layers_per_slice.max(1);
465 let distributions = plan
466 .distributions
467 .into_iter()
468 .map(|(node_id, slices)| {
469 let estimated_chunks = slices
470 .iter()
471 .map(|slice| {
472 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
473 total_layers.div_ceil(slice_limit).max(1)
474 })
475 .sum();
476 let mut chunked = Vec::with_capacity(estimated_chunks);
477 for slice in slices {
478 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
479 if total_layers <= slice_limit {
480 chunked.push(slice);
481 continue;
482 }
483
484 let avg_size = if total_layers == 0 {
485 0.0
486 } else {
487 slice.size_gb / total_layers as f32
488 };
489 let mut start = slice.layer_range.0;
490 while start < slice.layer_range.1 {
491 let end = (start + slice_limit).min(slice.layer_range.1);
492 let layers_in_chunk = end - start;
493 let mut chunk =
494 TensorSlice::new((start, end), avg_size * layers_in_chunk as f32);
495 chunk.num_weights = slice.num_weights;
496 chunked.push(chunk);
497 start = end;
498 }
499 }
500 (node_id, chunked)
501 })
502 .collect();
503
504 LoadDistributionPlan {
506 distributions,
507 total_layers: plan.total_layers,
508 participating_nodes: plan.participating_nodes,
509 }
510}
511
512#[derive(Clone, Debug, Default)]
514pub struct LoadStats {
515 pub total_layers_distributed: usize,
517 pub total_vram_used_gb: f32,
519 pub avg_load_balance_ratio: f32,
521 pub rebalancing_count: usize,
523 balance_ratio_initialized: bool,
525}
526
527impl LoadStats {
528 pub fn new() -> Self {
530 Self::default()
531 }
532
533 pub fn record_distribution(&mut self, num_layers: usize, vram_gb: f32) {
535 self.total_layers_distributed += num_layers;
536 self.total_vram_used_gb += vram_gb;
537 }
538
539 pub fn update_balance_ratio(&mut self, ratio: f32) {
541 if !self.balance_ratio_initialized {
542 self.avg_load_balance_ratio = ratio;
544 self.balance_ratio_initialized = true;
545 } else {
546 self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
548 }
549 }
550
551 pub fn record_rebalancing(&mut self) {
553 self.rebalancing_count += 1;
554 }
555
556 pub fn report(&self) -> String {
558 format!(
559 "Load Statistics\n\
560 ==========\n\
561 Total layers distributed: {}\n\
562 Total VRAM used: {:.1} GB\n\
563 Avg load balance ratio: {:.2}\n\
564 Rebalancing operations: {}",
565 self.total_layers_distributed,
566 self.total_vram_used_gb,
567 self.avg_load_balance_ratio,
568 self.rebalancing_count
569 )
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::cluster::ClusterState;
577 use crate::host::AccelerationMode;
578 use crate::protocol::NodeResources;
579 use std::sync::Arc;
580
581 fn sample_layers(count: usize, vram_gb: f32) -> Vec<crate::planning::LayerSpec> {
582 (0..count)
583 .map(|index| crate::planning::LayerSpec {
584 index,
585 vram_gb,
586 num_weights: 0,
587 })
588 .collect()
589 }
590
591 fn sample_nodes(count: usize, base_vram: f32) -> Vec<NodeResources> {
592 (0..count)
593 .map(|i| {
594 NodeResources::new(
595 format!("node-{i}"),
596 base_vram + (i as f32 * 6.0),
597 64.0,
598 "8.9".to_string(),
599 None,
600 )
601 })
602 .collect()
603 }
604
605 #[test]
606 fn load_balancer_distributes_layers() {
607 let cluster = ClusterState::new();
608 for node in sample_nodes(2, 24.0) {
609 cluster.register(node);
610 }
611
612 let layers = sample_layers(33, 1.0);
613 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
614
615 let plan = balancer.distribute_layers(&layers).unwrap();
616 assert_eq!(plan.total_layers, 33);
617 }
618
619 #[test]
620 fn load_balancer_generates_summary() {
621 let cluster = ClusterState::new();
622 for node in sample_nodes(2, 24.0) {
623 cluster.register(node);
624 }
625
626 let layers = sample_layers(33, 1.0);
627 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
628
629 let plan = balancer.distribute_layers(&layers).unwrap();
630 let summary = plan.summary();
631
632 assert!(summary.contains("Total layers: 33"));
633 }
634
635 #[test]
636 fn load_stats_records_distribution() {
637 let mut stats = LoadStats::new();
638
639 stats.record_distribution(24, 24.0);
640 stats.record_distribution(9, 9.0);
641
642 assert_eq!(stats.total_layers_distributed, 33);
643 assert!((stats.total_vram_used_gb - 33.0).abs() < 0.01);
644 }
645
646 #[test]
647 fn load_stats_updates_balance_ratio() {
648 let mut stats = LoadStats::new();
649
650 stats.update_balance_ratio(0.95);
651 assert!((stats.avg_load_balance_ratio - 0.95).abs() < 1e-6);
652
653 stats.update_balance_ratio(0.85);
654 assert!((stats.avg_load_balance_ratio - 0.94).abs() < 1e-6);
656 }
657
658 #[test]
659 fn load_stats_first_balance_ratio_not_diluted_by_prior_distribution() {
660 let mut stats = LoadStats::new();
668
669 stats.record_distribution(24, 24.0);
670 stats.update_balance_ratio(0.95);
671
672 assert!(
673 (stats.avg_load_balance_ratio - 0.95).abs() < 1e-6,
674 "first balance ratio sample must be set directly, got {}",
675 stats.avg_load_balance_ratio
676 );
677 }
678
679 #[test]
680 fn load_stats_reports() {
681 let mut stats = LoadStats::new();
682
683 stats.record_distribution(24, 24.0);
684 stats.update_balance_ratio(0.95);
685
686 let report = stats.report();
687 assert!(report.contains("Total layers distributed: 24"));
688 }
689
690 #[test]
691 fn autotuned_config_reflects_runtime_profile() {
692 let profile = RuntimeProfile {
693 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
694 logical_cores: 16,
695 recommended_workers: 6,
696 acceleration_mode: AccelerationMode::Gpu,
697 gpu_backend: crate::host::GpuBackend::Cuda,
698 xdp_supported: true,
699 detection_source: String::from("test"),
700 probe_mode: crate::host::ProbeMode::Fast,
701 };
702
703 let config = LoadBalanceConfig::autotuned(&profile);
704 assert_eq!(config.lock_timeout_us, 500);
705 assert_eq!(config.max_layers_per_assignment, 12);
706 assert_eq!(config.max_concurrent_rebalances, 6);
707 assert!(config.min_load_threshold > 1.0);
708 }
709
710 #[test]
711 fn autotuned_cpu_threshold_stays_above_one() {
712 let profile = RuntimeProfile {
716 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
717 logical_cores: 4,
718 recommended_workers: 2,
719 acceleration_mode: AccelerationMode::Generic,
720 gpu_backend: crate::host::GpuBackend::Cpu,
721 xdp_supported: false,
722 detection_source: String::from("test"),
723 probe_mode: crate::host::ProbeMode::Fast,
724 };
725 let config = LoadBalanceConfig::autotuned(&profile);
726 assert!(
727 config.min_load_threshold > 1.0,
728 "CPU-tier threshold {} must exceed skew_ratio's mathematical floor of 1.0",
729 config.min_load_threshold
730 );
731 }
732
733 #[test]
734 fn rebalance_does_not_always_trigger_on_balanced_cpu_cluster() {
735 let cluster = Arc::new(ClusterState::new());
736 cluster.register(NodeResources::new("node-a", 0.0, 16.0, "generic", None));
737 cluster.register(NodeResources::new("node-b", 0.0, 16.0, "generic", None));
738
739 let profile = RuntimeProfile {
740 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
741 logical_cores: 4,
742 recommended_workers: 2,
743 acceleration_mode: AccelerationMode::Generic,
744 gpu_backend: crate::host::GpuBackend::Cpu,
745 xdp_supported: false,
746 detection_source: String::from("test"),
747 probe_mode: crate::host::ProbeMode::Fast,
748 };
749 let balancer = LoadBalancer::new(cluster, LoadBalanceConfig::autotuned(&profile));
750 assert!(
751 !balancer.rebalance(),
752 "a perfectly balanced (zero-VRAM) CPU cluster must not be reported as needing rebalance"
753 );
754 }
755
756 #[test]
757 fn runtime_profile_chunks_large_distribution_slices() {
758 let plan = LoadDistributionPlan::new(
759 vec![("node-a".into(), vec![TensorSlice::new((0, 10), 10.0)])],
760 10,
761 );
762
763 let chunked = chunk_distribution_plan(plan, 4);
764 assert_eq!(chunked.distributions[0].1.len(), 3);
765 assert_eq!(chunked.distributions[0].1[0].layer_range, (0, 4));
766 assert_eq!(chunked.distributions[0].1[2].layer_range, (8, 10));
767 }
768
769 #[test]
770 fn shed_load_moves_from_low_headroom_to_high_headroom_nodes() {
771 let cluster = ClusterState::new();
772 cluster.register(NodeResources::new("node-hot", 24.0, 64.0, "8.9", None));
773 cluster.register(NodeResources::new("node-cool", 24.0, 64.0, "8.9", None));
774
775 cluster.get_metrics_mut("node-hot", |m| {
777 m.available_vram_gb = 2.0;
778 m.total_vram_gb = 24.0;
779 });
780
781 cluster.get_metrics_mut("node-cool", |m| {
783 m.available_vram_gb = 20.0;
784 m.total_vram_gb = 24.0;
785 });
786
787 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
788 let transfers = balancer.shed_load();
789
790 assert_eq!(transfers.len(), 1);
791 assert_eq!(transfers[0].0, "node-hot");
792 assert_eq!(transfers[0].1, "node-cool");
793 }
794}