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 self.distribute_layers_internal(layers, None)
187 }
188
189 fn distribute_layers_internal(
190 &self,
191 layers: &[crate::planning::LayerSpec],
192 max_layers_per_slice: Option<usize>,
193 ) -> Result<LoadDistributionPlan, String> {
194 let nodes_snapshot = self.cluster.nodes_snapshot();
195 if nodes_snapshot.is_empty() {
196 return Err("no nodes available".into());
197 }
198
199 let mut sorted_nodes: Vec<&crate::protocol::NodeResources> =
203 nodes_snapshot.iter().collect();
204 sorted_nodes.sort_unstable_by(|a, b| {
205 b.vram_gb
206 .partial_cmp(&a.vram_gb)
207 .unwrap_or(std::cmp::Ordering::Equal)
208 });
209
210 let is_sorted = layers.windows(2).all(|w| w[0].index <= w[1].index);
213 let sorted_layers: std::borrow::Cow<'_, [crate::planning::LayerSpec]> = if is_sorted {
214 std::borrow::Cow::Borrowed(layers)
215 } else {
216 let mut all_layers = layers.to_vec();
217 all_layers.sort_unstable_by_key(|l| l.index);
219 std::borrow::Cow::Owned(all_layers)
220 };
221 let total_layer_count = sorted_layers.len();
222
223 let mut distributions = Vec::with_capacity(sorted_nodes.len());
225 let mut participating_nodes = Vec::with_capacity(sorted_nodes.len());
226 let mut current_layer_idx = 0usize;
227 let slice_limit = max_layers_per_slice.unwrap_or(usize::MAX).max(1);
228
229 for node in &sorted_nodes {
230 if current_layer_idx >= sorted_layers.len() {
231 break;
232 }
233
234 let mut used_vram = 0.0f32;
235 let start_idx = current_layer_idx;
236 let mut end_idx = current_layer_idx;
237
238 for layer in &sorted_layers[current_layer_idx..] {
239 if used_vram + layer.vram_gb > node.vram_gb {
240 break;
241 }
242 end_idx += 1;
243 used_vram += layer.vram_gb;
244 }
245
246 if end_idx > start_idx {
247 let total_node_layers = end_idx - start_idx;
248 let node_id = node.id.clone();
249 participating_nodes.push(node_id.clone());
250
251 if total_node_layers <= slice_limit {
252 let start_layer = sorted_layers[start_idx].index;
253 let end_layer = sorted_layers[end_idx - 1].index + 1;
254 let slice = TensorSlice::new((start_layer, end_layer), used_vram);
255 distributions.push((node_id, vec![slice]));
256 } else {
257 let chunks_count = total_node_layers.div_ceil(slice_limit);
258 let mut slices = Vec::with_capacity(chunks_count);
259 let avg_size = used_vram / total_node_layers as f32;
260
261 let mut chunk_start_idx = start_idx;
262 while chunk_start_idx < end_idx {
263 let chunk_end_idx = (chunk_start_idx + slice_limit).min(end_idx);
264 let chunk_layers = chunk_end_idx - chunk_start_idx;
265 let start_layer = sorted_layers[chunk_start_idx].index;
266 let end_layer = sorted_layers[chunk_end_idx - 1].index + 1;
267 slices.push(TensorSlice::new(
268 (start_layer, end_layer),
269 avg_size * chunk_layers as f32,
270 ));
271 chunk_start_idx = chunk_end_idx;
272 }
273 distributions.push((node_id, slices));
274 }
275
276 current_layer_idx = end_idx;
277 }
278 }
279
280 if current_layer_idx >= sorted_layers.len() {
281 Ok(LoadDistributionPlan {
282 distributions,
283 total_layers: total_layer_count,
284 participating_nodes,
285 })
286 } else {
287 Err(format!(
288 "insufficient VRAM: {} layers remain",
289 sorted_layers.len() - current_layer_idx
290 ))
291 }
292 }
293
294 pub fn distribute_layers_with_runtime_profile(
301 &self,
302 layers: &[crate::planning::LayerSpec],
303 profile: &RuntimeProfile,
304 ) -> Result<LoadDistributionPlan, String> {
305 let cfg = self.config();
306 let backend = ExecutionBackend::from_runtime_profile(profile);
307 let vector_bias = (backend.vector_width_bits / 128).max(1);
308 let max_layers_per_slice = cfg
309 .max_layers_per_assignment
310 .min(profile.recommended_workers.max(1).saturating_mul(2))
311 .min(backend.preferred_batch_size / vector_bias)
312 .max(1);
313
314 self.distribute_layers_internal(layers, Some(max_layers_per_slice))
315 }
316
317 pub fn rebalance(&self) -> bool {
319 let cfg = self.config();
320
321 let (min_available, max_available, has_nodes) = {
322 let metrics = self
323 .cluster
324 .metrics
325 .lock()
326 .unwrap_or_else(|poison| poison.into_inner());
327
328 let mut min_val = f32::MAX;
329 let mut max_val = 0.0_f32;
330 let mut count = 0usize;
331
332 for node in metrics.values() {
333 if node.status == crate::cluster::NodeStatus::Active {
334 min_val = min_val.min(node.available_vram_gb.max(0.0));
335 max_val = max_val.max(node.available_vram_gb.max(0.0));
336 count += 1;
337 }
338 }
339 (min_val, max_val, count > 0)
340 };
341
342 if !has_nodes {
343 return false;
344 }
345
346 let skew_ratio = if min_available <= f32::EPSILON {
348 if max_available > 0.0 {
349 f32::INFINITY
350 } else {
351 1.0
352 }
353 } else {
354 max_available / min_available
355 };
356
357 if skew_ratio >= cfg.min_load_threshold {
358 tracing::info!(
359 "Detected load skew ratio {:.2} (threshold {:.2})",
360 skew_ratio,
361 cfg.min_load_threshold
362 );
363 return true;
364 }
365
366 false
367 }
368
369 pub fn shed_load(&self) -> Vec<(String, String)> {
375 let cfg = self.config();
376 let mut transfers: Vec<(String, String)> = Vec::new();
377
378 let metrics = self
379 .cluster
380 .metrics
381 .lock()
382 .unwrap_or_else(|poison| poison.into_inner());
383
384 let mut overloaded_nodes: Vec<&String> = Vec::new();
385 let mut underloaded_nodes: Vec<(&String, f32)> = Vec::new();
386
387 for m in metrics.values() {
388 if m.status == crate::cluster::NodeStatus::Active {
389 if m.available_vram_gb < m.total_vram_gb * 0.2 {
390 overloaded_nodes.push(&m.name);
391 } else if m.available_vram_gb > m.total_vram_gb * 0.5 {
392 underloaded_nodes.push((&m.name, m.available_vram_gb));
393 }
394 }
395 }
396
397 for &overloaded_name in &overloaded_nodes {
398 if transfers.len() >= cfg.max_concurrent_rebalances {
399 break;
400 }
401
402 let mut best_target: Option<&String> = None;
403 let mut max_available = 0.0f32;
404
405 for &(name, available) in &underloaded_nodes {
406 if name != overloaded_name && available > max_available {
407 max_available = available;
408 best_target = Some(name);
409 }
410 }
411
412 if let Some(target_name) = best_target {
413 transfers.push((overloaded_name.clone(), target_name.clone()));
414 }
415 }
416
417 transfers
418 }
419
420 pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
426 let rp: RuntimeProfile = profile.into();
427 if let Ok(mut guard) = self.config.lock() {
428 *guard = LoadBalanceConfig::autotuned(&rp);
429 }
430 }
431
432 pub fn subscribe_to_watcher(
437 self: &Arc<Self>,
438 watcher: &crate::watcher::SystemProfileWatcher,
439 ) -> tokio::task::JoinHandle<()> {
440 let this = self.clone();
441 let mut rx = watcher.subscribe();
442 tokio::spawn(async move {
443 while let Ok(event) = rx.recv().await {
444 if let ProfileChange::Updated(profile) = event {
445 this.reconfigure_from_system_profile(&profile);
446 tracing::info!(
447 "load balancer re-tuned from profile: {} workers, {:?} accel",
448 profile.recommended_workers,
449 profile.acceleration_mode,
450 );
451 }
452 }
453 })
454 }
455
456 pub fn distribute_with_deadlock_prevention(
458 &self,
459 layers: &[crate::planning::LayerSpec],
460 ) -> Result<LoadDistributionPlan, String> {
461 let cfg = self.config();
462 let start_time = std::time::Instant::now();
464
465 while start_time.elapsed().as_micros() < cfg.lock_timeout_us as u128 {
466 match self.distribute_layers(layers) {
467 Ok(plan) => return Ok(plan),
468 Err(_) => {
469 std::thread::sleep(std::time::Duration::from_millis(10));
471 }
472 }
473 }
474
475 Err("deadlock prevention timeout".into())
476 }
477}
478
479pub fn chunk_distribution_plan(
481 plan: LoadDistributionPlan,
482 max_layers_per_slice: usize,
483) -> LoadDistributionPlan {
484 let slice_limit = max_layers_per_slice.max(1);
485
486 let needs_chunking = plan.distributions.iter().any(|(_, slices)| {
488 slices
489 .iter()
490 .any(|s| s.layer_range.1.saturating_sub(s.layer_range.0) > slice_limit)
491 });
492 if !needs_chunking {
493 return plan;
494 }
495
496 let distributions = plan
497 .distributions
498 .into_iter()
499 .map(|(node_id, slices)| {
500 let estimated_chunks = slices
501 .iter()
502 .map(|slice| {
503 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
504 total_layers.div_ceil(slice_limit).max(1)
505 })
506 .sum();
507 let mut chunked = Vec::with_capacity(estimated_chunks);
508 for slice in slices {
509 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
510 if total_layers <= slice_limit {
511 chunked.push(slice);
512 continue;
513 }
514
515 let avg_size = if total_layers == 0 {
516 0.0
517 } else {
518 slice.size_gb / total_layers as f32
519 };
520 let mut start = slice.layer_range.0;
521 while start < slice.layer_range.1 {
522 let end = (start + slice_limit).min(slice.layer_range.1);
523 let layers_in_chunk = end - start;
524 let mut chunk =
525 TensorSlice::new((start, end), avg_size * layers_in_chunk as f32);
526 chunk.num_weights = slice.num_weights;
527 chunked.push(chunk);
528 start = end;
529 }
530 }
531 (node_id, chunked)
532 })
533 .collect();
534
535 LoadDistributionPlan {
537 distributions,
538 total_layers: plan.total_layers,
539 participating_nodes: plan.participating_nodes,
540 }
541}
542
543#[derive(Clone, Debug, Default)]
545pub struct LoadStats {
546 pub total_layers_distributed: usize,
548 pub total_vram_used_gb: f32,
550 pub avg_load_balance_ratio: f32,
552 pub rebalancing_count: usize,
554 balance_ratio_initialized: bool,
556}
557
558impl LoadStats {
559 pub fn new() -> Self {
561 Self::default()
562 }
563
564 pub fn record_distribution(&mut self, num_layers: usize, vram_gb: f32) {
566 self.total_layers_distributed += num_layers;
567 self.total_vram_used_gb += vram_gb;
568 }
569
570 pub fn update_balance_ratio(&mut self, ratio: f32) {
572 if !self.balance_ratio_initialized {
573 self.avg_load_balance_ratio = ratio;
575 self.balance_ratio_initialized = true;
576 } else {
577 self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
579 }
580 }
581
582 pub fn record_rebalancing(&mut self) {
584 self.rebalancing_count += 1;
585 }
586
587 pub fn report(&self) -> String {
589 format!(
590 "Load Statistics\n\
591 ==========\n\
592 Total layers distributed: {}\n\
593 Total VRAM used: {:.1} GB\n\
594 Avg load balance ratio: {:.2}\n\
595 Rebalancing operations: {}",
596 self.total_layers_distributed,
597 self.total_vram_used_gb,
598 self.avg_load_balance_ratio,
599 self.rebalancing_count
600 )
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::cluster::ClusterState;
608 use crate::host::AccelerationMode;
609 use crate::protocol::NodeResources;
610 use std::sync::Arc;
611
612 fn sample_layers(count: usize, vram_gb: f32) -> Vec<crate::planning::LayerSpec> {
613 (0..count)
614 .map(|index| crate::planning::LayerSpec {
615 index,
616 vram_gb,
617 num_weights: 0,
618 })
619 .collect()
620 }
621
622 fn sample_nodes(count: usize, base_vram: f32) -> Vec<NodeResources> {
623 (0..count)
624 .map(|i| {
625 NodeResources::new(
626 format!("node-{i}"),
627 base_vram + (i as f32 * 6.0),
628 64.0,
629 "8.9".to_string(),
630 None,
631 )
632 })
633 .collect()
634 }
635
636 #[test]
637 fn load_balancer_distributes_layers() {
638 let cluster = ClusterState::new();
639 for node in sample_nodes(2, 24.0) {
640 cluster.register(node);
641 }
642
643 let layers = sample_layers(33, 1.0);
644 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
645
646 let plan = balancer.distribute_layers(&layers).unwrap();
647 assert_eq!(plan.total_layers, 33);
648 }
649
650 #[test]
651 fn load_balancer_generates_summary() {
652 let cluster = ClusterState::new();
653 for node in sample_nodes(2, 24.0) {
654 cluster.register(node);
655 }
656
657 let layers = sample_layers(33, 1.0);
658 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
659
660 let plan = balancer.distribute_layers(&layers).unwrap();
661 let summary = plan.summary();
662
663 assert!(summary.contains("Total layers: 33"));
664 }
665
666 #[test]
667 fn load_stats_records_distribution() {
668 let mut stats = LoadStats::new();
669
670 stats.record_distribution(24, 24.0);
671 stats.record_distribution(9, 9.0);
672
673 assert_eq!(stats.total_layers_distributed, 33);
674 assert!((stats.total_vram_used_gb - 33.0).abs() < 0.01);
675 }
676
677 #[test]
678 fn load_stats_updates_balance_ratio() {
679 let mut stats = LoadStats::new();
680
681 stats.update_balance_ratio(0.95);
682 assert!((stats.avg_load_balance_ratio - 0.95).abs() < 1e-6);
683
684 stats.update_balance_ratio(0.85);
685 assert!((stats.avg_load_balance_ratio - 0.94).abs() < 1e-6);
687 }
688
689 #[test]
690 fn load_stats_first_balance_ratio_not_diluted_by_prior_distribution() {
691 let mut stats = LoadStats::new();
699
700 stats.record_distribution(24, 24.0);
701 stats.update_balance_ratio(0.95);
702
703 assert!(
704 (stats.avg_load_balance_ratio - 0.95).abs() < 1e-6,
705 "first balance ratio sample must be set directly, got {}",
706 stats.avg_load_balance_ratio
707 );
708 }
709
710 #[test]
711 fn load_stats_reports() {
712 let mut stats = LoadStats::new();
713
714 stats.record_distribution(24, 24.0);
715 stats.update_balance_ratio(0.95);
716
717 let report = stats.report();
718 assert!(report.contains("Total layers distributed: 24"));
719 }
720
721 #[test]
722 fn autotuned_config_reflects_runtime_profile() {
723 let profile = RuntimeProfile {
724 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
725 logical_cores: 16,
726 recommended_workers: 6,
727 acceleration_mode: AccelerationMode::Gpu,
728 gpu_backend: crate::host::GpuBackend::Cuda,
729 xdp_supported: true,
730 detection_source: String::from("test"),
731 probe_mode: crate::host::ProbeMode::Fast,
732 };
733
734 let config = LoadBalanceConfig::autotuned(&profile);
735 assert_eq!(config.lock_timeout_us, 500);
736 assert_eq!(config.max_layers_per_assignment, 12);
737 assert_eq!(config.max_concurrent_rebalances, 6);
738 assert!(config.min_load_threshold > 1.0);
739 }
740
741 #[test]
742 fn autotuned_cpu_threshold_stays_above_one() {
743 let profile = RuntimeProfile {
747 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
748 logical_cores: 4,
749 recommended_workers: 2,
750 acceleration_mode: AccelerationMode::Generic,
751 gpu_backend: crate::host::GpuBackend::Cpu,
752 xdp_supported: false,
753 detection_source: String::from("test"),
754 probe_mode: crate::host::ProbeMode::Fast,
755 };
756 let config = LoadBalanceConfig::autotuned(&profile);
757 assert!(
758 config.min_load_threshold > 1.0,
759 "CPU-tier threshold {} must exceed skew_ratio's mathematical floor of 1.0",
760 config.min_load_threshold
761 );
762 }
763
764 #[test]
765 fn rebalance_does_not_always_trigger_on_balanced_cpu_cluster() {
766 let cluster = Arc::new(ClusterState::new());
767 cluster.register(NodeResources::new("node-a", 0.0, 16.0, "generic", None));
768 cluster.register(NodeResources::new("node-b", 0.0, 16.0, "generic", None));
769
770 let profile = RuntimeProfile {
771 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
772 logical_cores: 4,
773 recommended_workers: 2,
774 acceleration_mode: AccelerationMode::Generic,
775 gpu_backend: crate::host::GpuBackend::Cpu,
776 xdp_supported: false,
777 detection_source: String::from("test"),
778 probe_mode: crate::host::ProbeMode::Fast,
779 };
780 let balancer = LoadBalancer::new(cluster, LoadBalanceConfig::autotuned(&profile));
781 assert!(
782 !balancer.rebalance(),
783 "a perfectly balanced (zero-VRAM) CPU cluster must not be reported as needing rebalance"
784 );
785 }
786
787 #[test]
788 fn runtime_profile_chunks_large_distribution_slices() {
789 let plan = LoadDistributionPlan::new(
790 vec![("node-a".into(), vec![TensorSlice::new((0, 10), 10.0)])],
791 10,
792 );
793
794 let chunked = chunk_distribution_plan(plan, 4);
795 assert_eq!(chunked.distributions[0].1.len(), 3);
796 assert_eq!(chunked.distributions[0].1[0].layer_range, (0, 4));
797 assert_eq!(chunked.distributions[0].1[2].layer_range, (8, 10));
798 }
799
800 #[test]
801 fn shed_load_moves_from_low_headroom_to_high_headroom_nodes() {
802 let cluster = ClusterState::new();
803 cluster.register(NodeResources::new("node-hot", 24.0, 64.0, "8.9", None));
804 cluster.register(NodeResources::new("node-cool", 24.0, 64.0, "8.9", None));
805
806 cluster.get_metrics_mut("node-hot", |m| {
808 m.available_vram_gb = 2.0;
809 m.total_vram_gb = 24.0;
810 });
811
812 cluster.get_metrics_mut("node-cool", |m| {
814 m.available_vram_gb = 20.0;
815 m.total_vram_gb = 24.0;
816 });
817
818 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
819 let transfers = balancer.shed_load();
820
821 assert_eq!(transfers.len(), 1);
822 assert_eq!(transfers[0].0, "node-hot");
823 assert_eq!(transfers[0].1, "node-cool");
824 }
825}