1use crate::accelerator::ExecutionBackend;
10use crate::cluster::{ClusterState, NodeMetrics};
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(
178 &self,
179 layers: &[crate::planning::LayerSpec],
180 ) -> Result<LoadDistributionPlan, String> {
181 let nodes_snapshot = self.cluster.nodes_snapshot();
182 if nodes_snapshot.is_empty() {
183 return Err("no nodes available".into());
184 }
185
186 let mut sorted_nodes: Vec<_> = nodes_snapshot.iter().cloned().collect();
188 sorted_nodes.sort_by(|a, b| {
189 b.vram_gb
190 .partial_cmp(&a.vram_gb)
191 .unwrap_or(std::cmp::Ordering::Equal)
192 });
193
194 let mut all_layers: Vec<_> = layers.to_vec();
196 all_layers.sort_by_key(|l| l.index);
197 let total_layer_count = all_layers.len();
198
199 let mut distributions = Vec::new();
201 let mut remaining_layers = all_layers;
202
203 for node in &sorted_nodes {
204 if remaining_layers.is_empty() {
205 break;
206 }
207
208 let mut used_vram = 0.0f32;
209 let mut start_enum = usize::MAX;
210 let mut end_enum = 0usize;
211
212 for (enum_idx, layer) in remaining_layers.iter().enumerate() {
213 if used_vram + layer.vram_gb > node.vram_gb {
214 break;
215 }
216
217 if start_enum == usize::MAX {
218 start_enum = enum_idx;
219 }
220 end_enum = enum_idx + 1;
221
222 used_vram += layer.vram_gb;
223 }
224
225 if start_enum != usize::MAX {
226 let slices: Vec<TensorSlice> = remaining_layers[start_enum..end_enum]
227 .iter()
228 .map(|l| TensorSlice::new((l.index, l.index + 1), l.vram_gb))
229 .collect();
230
231 distributions.push((node.id.clone(), slices));
232
233 remaining_layers.drain(start_enum..end_enum);
235 }
236 }
237
238 if remaining_layers.is_empty() {
239 Ok(LoadDistributionPlan::new(distributions, total_layer_count))
240 } else {
241 Err(format!(
242 "insufficient VRAM: {} layers remain",
243 remaining_layers.len()
244 ))
245 }
246 }
247
248 pub fn distribute_layers_with_runtime_profile(
250 &self,
251 layers: &[crate::planning::LayerSpec],
252 profile: &RuntimeProfile,
253 ) -> Result<LoadDistributionPlan, String> {
254 let cfg = self.config();
255 let plan = self.distribute_layers(layers)?;
256 let backend = ExecutionBackend::from_runtime_profile(profile);
257 let vector_bias = (backend.vector_width_bits / 128).max(1);
258 let max_layers_per_slice = cfg
259 .max_layers_per_assignment
260 .min(profile.recommended_workers.max(1).saturating_mul(2))
261 .min(backend.preferred_batch_size / vector_bias)
262 .max(1);
263 Ok(chunk_distribution_plan(plan, max_layers_per_slice))
264 }
265
266 pub fn rebalance(&self) -> bool {
268 let cfg = self.config();
269 let active_nodes = self.cluster.active_nodes();
270
271 if active_nodes.is_empty() {
272 return false;
273 }
274
275 let mut min_available = f32::MAX;
276 let mut max_available = 0.0_f32;
277
278 for node in &active_nodes {
279 min_available = min_available.min(node.available_vram_gb.max(0.0));
280 max_available = max_available.max(node.available_vram_gb.max(0.0));
281 }
282
283 let skew_ratio = if min_available <= f32::EPSILON {
285 if max_available > 0.0 {
286 f32::INFINITY
287 } else {
288 1.0
289 }
290 } else {
291 max_available / min_available
292 };
293
294 if skew_ratio >= cfg.min_load_threshold {
295 tracing::info!(
296 "Detected load skew ratio {:.2} (threshold {:.2})",
297 skew_ratio,
298 cfg.min_load_threshold
299 );
300 return true;
301 }
302
303 false
304 }
305
306 pub fn shed_load(&self) -> Vec<(String, String)> {
308 let cfg = self.config();
309 let mut transfers: Vec<(String, String)> = Vec::new();
310
311 let overloaded_nodes: Vec<_> = self
313 .cluster
314 .active_nodes()
315 .into_iter()
316 .filter(|m| m.available_vram_gb < m.total_vram_gb * 0.2)
317 .collect();
318
319 let underloaded_nodes: Vec<_> = self
321 .cluster
322 .active_nodes()
323 .into_iter()
324 .filter(|m| m.available_vram_gb > m.total_vram_gb * 0.5)
325 .collect();
326
327 for overloaded in &overloaded_nodes {
329 if transfers.len() >= cfg.max_concurrent_rebalances {
330 break;
331 }
332 if let Some(target) = self.find_best_target(underloaded_nodes.as_slice(), overloaded) {
333 transfers.push((overloaded.name.clone(), target.name.clone()));
334 }
335 }
336
337 transfers
338 }
339
340 fn find_best_target(
342 &self,
343 targets: &[NodeMetrics],
344 source: &NodeMetrics,
345 ) -> Option<NodeMetrics> {
346 let mut best_target: Option<&NodeMetrics> = None;
348 let mut max_available = 0.0f32;
349
350 for target in targets {
351 if target.name == source.name {
352 continue;
353 }
354
355 if target.available_vram_gb > max_available {
356 max_available = target.available_vram_gb;
357 best_target = Some(target);
358 }
359 }
360
361 best_target.cloned()
362 }
363
364 pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
370 let rp: RuntimeProfile = profile.into();
371 if let Ok(mut guard) = self.config.lock() {
372 *guard = LoadBalanceConfig::autotuned(&rp);
373 }
374 }
375
376 pub fn subscribe_to_watcher(
381 self: &Arc<Self>,
382 watcher: &crate::watcher::SystemProfileWatcher,
383 ) -> tokio::task::JoinHandle<()> {
384 let this = self.clone();
385 let mut rx = watcher.subscribe();
386 tokio::spawn(async move {
387 while let Ok(event) = rx.recv().await {
388 if let ProfileChange::Updated(profile) = event {
389 this.reconfigure_from_system_profile(&profile);
390 tracing::info!(
391 "load balancer re-tuned from profile: {} workers, {:?} accel",
392 profile.recommended_workers,
393 profile.acceleration_mode,
394 );
395 }
396 }
397 })
398 }
399
400 pub fn distribute_with_deadlock_prevention(
402 &self,
403 layers: &[crate::planning::LayerSpec],
404 ) -> Result<LoadDistributionPlan, String> {
405 let cfg = self.config();
406 let start_time = std::time::Instant::now();
408
409 while start_time.elapsed().as_micros() < cfg.lock_timeout_us as u128 {
410 match self.distribute_layers(layers) {
411 Ok(plan) => return Ok(plan),
412 Err(_) => {
413 std::thread::sleep(std::time::Duration::from_millis(10));
415 }
416 }
417 }
418
419 Err("deadlock prevention timeout".into())
420 }
421}
422
423fn chunk_distribution_plan(
424 plan: LoadDistributionPlan,
425 max_layers_per_slice: usize,
426) -> LoadDistributionPlan {
427 let slice_limit = max_layers_per_slice.max(1);
428 let distributions = plan
429 .distributions
430 .into_iter()
431 .map(|(node_id, slices)| {
432 let estimated_chunks = slices
433 .iter()
434 .map(|slice| {
435 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
436 total_layers.div_ceil(slice_limit).max(1)
437 })
438 .sum();
439 let mut chunked = Vec::with_capacity(estimated_chunks);
440 for slice in slices {
441 let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
442 if total_layers <= slice_limit {
443 chunked.push(slice);
444 continue;
445 }
446
447 let avg_size = if total_layers == 0 {
448 0.0
449 } else {
450 slice.size_gb / total_layers as f32
451 };
452 let mut start = slice.layer_range.0;
453 while start < slice.layer_range.1 {
454 let end = (start + slice_limit).min(slice.layer_range.1);
455 let layers_in_chunk = end - start;
456 let mut chunk =
457 TensorSlice::new((start, end), avg_size * layers_in_chunk as f32);
458 chunk.num_weights = slice.num_weights;
459 chunked.push(chunk);
460 start = end;
461 }
462 }
463 (node_id, chunked)
464 })
465 .collect();
466
467 LoadDistributionPlan::new(distributions, plan.total_layers)
468}
469
470#[derive(Clone, Debug, Default)]
472pub struct LoadStats {
473 pub total_layers_distributed: usize,
475 pub total_vram_used_gb: f32,
477 pub avg_load_balance_ratio: f32,
479 pub rebalancing_count: usize,
481 balance_ratio_initialized: bool,
483}
484
485impl LoadStats {
486 pub fn new() -> Self {
488 Self::default()
489 }
490
491 pub fn record_distribution(&mut self, num_layers: usize, vram_gb: f32) {
493 self.total_layers_distributed += num_layers;
494 self.total_vram_used_gb += vram_gb;
495 }
496
497 pub fn update_balance_ratio(&mut self, ratio: f32) {
499 if !self.balance_ratio_initialized {
500 self.avg_load_balance_ratio = ratio;
502 self.balance_ratio_initialized = true;
503 } else {
504 self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
506 }
507 }
508
509 pub fn record_rebalancing(&mut self) {
511 self.rebalancing_count += 1;
512 }
513
514 pub fn report(&self) -> String {
516 format!(
517 "Load Statistics\n\
518 ==========\n\
519 Total layers distributed: {}\n\
520 Total VRAM used: {:.1} GB\n\
521 Avg load balance ratio: {:.2}\n\
522 Rebalancing operations: {}",
523 self.total_layers_distributed,
524 self.total_vram_used_gb,
525 self.avg_load_balance_ratio,
526 self.rebalancing_count
527 )
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::cluster::ClusterState;
535 use crate::host::AccelerationMode;
536 use crate::protocol::NodeResources;
537 use std::sync::Arc;
538
539 fn sample_layers(count: usize, vram_gb: f32) -> Vec<crate::planning::LayerSpec> {
540 (0..count)
541 .map(|index| crate::planning::LayerSpec {
542 index,
543 vram_gb,
544 num_weights: 0,
545 })
546 .collect()
547 }
548
549 fn sample_nodes(count: usize, base_vram: f32) -> Vec<NodeResources> {
550 (0..count)
551 .map(|i| {
552 NodeResources::new(
553 format!("node-{i}"),
554 base_vram + (i as f32 * 6.0),
555 64.0,
556 "8.9".to_string(),
557 None,
558 )
559 })
560 .collect()
561 }
562
563 #[test]
564 fn load_balancer_distributes_layers() {
565 let cluster = ClusterState::new();
566 for node in sample_nodes(2, 24.0) {
567 cluster.register(node);
568 }
569
570 let layers = sample_layers(33, 1.0);
571 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
572
573 let plan = balancer.distribute_layers(&layers).unwrap();
574 assert_eq!(plan.total_layers, 33);
575 }
576
577 #[test]
578 fn load_balancer_generates_summary() {
579 let cluster = ClusterState::new();
580 for node in sample_nodes(2, 24.0) {
581 cluster.register(node);
582 }
583
584 let layers = sample_layers(33, 1.0);
585 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
586
587 let plan = balancer.distribute_layers(&layers).unwrap();
588 let summary = plan.summary();
589
590 assert!(summary.contains("Total layers: 33"));
591 }
592
593 #[test]
594 fn load_stats_records_distribution() {
595 let mut stats = LoadStats::new();
596
597 stats.record_distribution(24, 24.0);
598 stats.record_distribution(9, 9.0);
599
600 assert_eq!(stats.total_layers_distributed, 33);
601 assert!((stats.total_vram_used_gb - 33.0).abs() < 0.01);
602 }
603
604 #[test]
605 fn load_stats_updates_balance_ratio() {
606 let mut stats = LoadStats::new();
607
608 stats.update_balance_ratio(0.95);
609 assert!((stats.avg_load_balance_ratio - 0.95).abs() < 1e-6);
610
611 stats.update_balance_ratio(0.85);
612 assert!((stats.avg_load_balance_ratio - 0.94).abs() < 1e-6);
614 }
615
616 #[test]
617 fn load_stats_first_balance_ratio_not_diluted_by_prior_distribution() {
618 let mut stats = LoadStats::new();
626
627 stats.record_distribution(24, 24.0);
628 stats.update_balance_ratio(0.95);
629
630 assert!(
631 (stats.avg_load_balance_ratio - 0.95).abs() < 1e-6,
632 "first balance ratio sample must be set directly, got {}",
633 stats.avg_load_balance_ratio
634 );
635 }
636
637 #[test]
638 fn load_stats_reports() {
639 let mut stats = LoadStats::new();
640
641 stats.record_distribution(24, 24.0);
642 stats.update_balance_ratio(0.95);
643
644 let report = stats.report();
645 assert!(report.contains("Total layers distributed: 24"));
646 }
647
648 #[test]
649 fn autotuned_config_reflects_runtime_profile() {
650 let profile = RuntimeProfile {
651 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
652 logical_cores: 16,
653 recommended_workers: 6,
654 acceleration_mode: AccelerationMode::Gpu,
655 gpu_backend: crate::host::GpuBackend::Cuda,
656 xdp_supported: true,
657 detection_source: String::from("test"),
658 probe_mode: crate::host::ProbeMode::Fast,
659 };
660
661 let config = LoadBalanceConfig::autotuned(&profile);
662 assert_eq!(config.lock_timeout_us, 500);
663 assert_eq!(config.max_layers_per_assignment, 12);
664 assert_eq!(config.max_concurrent_rebalances, 6);
665 assert!(config.min_load_threshold > 1.0);
666 }
667
668 #[test]
669 fn autotuned_cpu_threshold_stays_above_one() {
670 let profile = RuntimeProfile {
674 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
675 logical_cores: 4,
676 recommended_workers: 2,
677 acceleration_mode: AccelerationMode::Generic,
678 gpu_backend: crate::host::GpuBackend::Cpu,
679 xdp_supported: false,
680 detection_source: String::from("test"),
681 probe_mode: crate::host::ProbeMode::Fast,
682 };
683 let config = LoadBalanceConfig::autotuned(&profile);
684 assert!(
685 config.min_load_threshold > 1.0,
686 "CPU-tier threshold {} must exceed skew_ratio's mathematical floor of 1.0",
687 config.min_load_threshold
688 );
689 }
690
691 #[test]
692 fn rebalance_does_not_always_trigger_on_balanced_cpu_cluster() {
693 let cluster = Arc::new(ClusterState::new());
694 cluster.register(NodeResources::new("node-a", 0.0, 16.0, "generic", None));
695 cluster.register(NodeResources::new("node-b", 0.0, 16.0, "generic", None));
696
697 let profile = RuntimeProfile {
698 node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
699 logical_cores: 4,
700 recommended_workers: 2,
701 acceleration_mode: AccelerationMode::Generic,
702 gpu_backend: crate::host::GpuBackend::Cpu,
703 xdp_supported: false,
704 detection_source: String::from("test"),
705 probe_mode: crate::host::ProbeMode::Fast,
706 };
707 let balancer = LoadBalancer::new(cluster, LoadBalanceConfig::autotuned(&profile));
708 assert!(
709 !balancer.rebalance(),
710 "a perfectly balanced (zero-VRAM) CPU cluster must not be reported as needing rebalance"
711 );
712 }
713
714 #[test]
715 fn runtime_profile_chunks_large_distribution_slices() {
716 let plan = LoadDistributionPlan::new(
717 vec![("node-a".into(), vec![TensorSlice::new((0, 10), 10.0)])],
718 10,
719 );
720
721 let chunked = chunk_distribution_plan(plan, 4);
722 assert_eq!(chunked.distributions[0].1.len(), 3);
723 assert_eq!(chunked.distributions[0].1[0].layer_range, (0, 4));
724 assert_eq!(chunked.distributions[0].1[2].layer_range, (8, 10));
725 }
726
727 #[test]
728 fn shed_load_moves_from_low_headroom_to_high_headroom_nodes() {
729 let cluster = ClusterState::new();
730 cluster.register(NodeResources::new("node-hot", 24.0, 64.0, "8.9", None));
731 cluster.register(NodeResources::new("node-cool", 24.0, 64.0, "8.9", None));
732
733 cluster.get_metrics_mut("node-hot", |m| {
735 m.available_vram_gb = 2.0;
736 m.total_vram_gb = 24.0;
737 });
738
739 cluster.get_metrics_mut("node-cool", |m| {
741 m.available_vram_gb = 20.0;
742 m.total_vram_gb = 24.0;
743 });
744
745 let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
746 let transfers = balancer.shed_load();
747
748 assert_eq!(transfers.len(), 1);
749 assert_eq!(transfers[0].0, "node-hot");
750 assert_eq!(transfers[0].1, "node-cool");
751 }
752}