Skip to main content

ghostlink_core/
load_balance.rs

1//! Tensor Distribution and Load Balancing for Ghost-Link Cluster
2//!
3//! This module provides:
4//! - Tensor distribution across nodes based on VRAM capacity
5//! - Dynamic load shedding
6//! - Deadlock prevention with timeout
7//! - Dynamic reconfiguration on hardware changes via `SystemProfileWatcher`.
8
9use 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/// Load balancing configuration
17#[derive(Clone, Copy, Debug)]
18pub struct LoadBalanceConfig {
19    /// Maximum time to wait for lock acquisition (microseconds)
20    pub lock_timeout_us: u64,
21    /// Minimum load threshold for rebalancing
22    pub min_load_threshold: f32,
23    /// Maximum layers per node for single assignment
24    pub max_layers_per_assignment: usize,
25    /// Maximum concurrent rebalance transfers.
26    pub max_concurrent_rebalances: usize,
27}
28
29impl Default for LoadBalanceConfig {
30    fn default() -> Self {
31        Self {
32            lock_timeout_us: 1000, // 1ms
33            min_load_threshold: 1.25,
34            max_layers_per_assignment: 100,
35            max_concurrent_rebalances: 1,
36        }
37    }
38}
39
40impl LoadBalanceConfig {
41    /// Auto-tune load balancing thresholds from the detected runtime profile.
42    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        // skew_ratio (max_available / min_available) is mathematically always
49        // >= 1.0, so a threshold below 1.0 here made rebalance() return true
50        // unconditionally for this tier — including the all-zero-VRAM CPU-only
51        // case (skew_ratio pinned to exactly 1.0), which a threshold of 1.0
52        // itself would still have wrongly matched. Must stay strictly > 1.0.
53        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/// Tensor slice specification
70#[derive(Clone, Debug)]
71pub struct TensorSlice {
72    /// Layer index range for this tensor slice
73    pub layer_range: (usize, usize), // (start, end) exclusive
74    /// Size in GB
75    pub size_gb: f32,
76    /// Number of weights
77    pub num_weights: u32,
78}
79
80impl TensorSlice {
81    /// Create new tensor slice
82    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/// Load distribution plan across nodes
92#[derive(Clone, Debug)]
93pub struct LoadDistributionPlan {
94    /// Distribution of tensor slices per node
95    pub distributions: Vec<(String, Vec<TensorSlice>)>,
96    /// Total layers in plan
97    pub total_layers: usize,
98    /// Nodes participating
99    pub participating_nodes: Vec<String>,
100}
101
102impl LoadDistributionPlan {
103    /// Create new distribution plan
104    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    /// Generate human-readable plan summary
118    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/// Load balancer for tensor distribution
144#[derive(Clone, Debug)]
145pub struct LoadBalancer {
146    /// Cluster state
147    cluster: Arc<ClusterState>,
148    /// Configuration (behind Mutex for atomic re-tune from watcher)
149    config: Arc<std::sync::Mutex<LoadBalanceConfig>>,
150}
151
152impl LoadBalancer {
153    /// Create new load balancer
154    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    /// Create a load balancer with configuration derived from the local runtime profile.
162    pub fn with_runtime_profile(cluster: Arc<ClusterState>, profile: &RuntimeProfile) -> Self {
163        Self::new(cluster, LoadBalanceConfig::autotuned(profile))
164    }
165
166    /// Access the current configuration.
167    pub fn config(&self) -> LoadBalanceConfig {
168        self.config
169            .lock()
170            .ok()
171            .as_deref()
172            .copied()
173            .unwrap_or_default()
174    }
175
176    /// Distribute tensor layers across nodes based on VRAM capacity.
177    ///
178    /// Optimized using cursor-based index traversal instead of costly vector draining
179    /// and shifting elements, improving complexity from O(N^2) to O(N).
180    /// Further optimized by sorting references to NodeResources instead of cloning them,
181    /// and dynamically avoiding layer cloning/sorting if they are already in sequential order.
182    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        // Sort references to nodes by VRAM capacity (descending) to avoid copying heap-allocated IDs and strings
192        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        // Zero-copy shortcut: check if layers are already sorted (nearly always true).
201        // If already sorted, borrow instead of allocating/sorting a new vector.
202        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        // Greedy assignment: assign contiguous layers to nodes based on VRAM using O(1) indices
213        let mut distributions = Vec::with_capacity(sorted_nodes.len());
214        let mut current_layer_idx = 0usize;
215
216        for node in &sorted_nodes {
217            if current_layer_idx >= sorted_layers.len() {
218                break;
219            }
220
221            let mut used_vram = 0.0f32;
222            let start_idx = current_layer_idx;
223            let mut end_idx = current_layer_idx;
224
225            for layer in &sorted_layers[current_layer_idx..] {
226                if used_vram + layer.vram_gb > node.vram_gb {
227                    break;
228                }
229                end_idx += 1;
230                used_vram += layer.vram_gb;
231            }
232
233            if end_idx > start_idx {
234                let size_gb: f32 = sorted_layers[start_idx..end_idx]
235                    .iter()
236                    .map(|l| l.vram_gb)
237                    .sum();
238                let start_layer = sorted_layers[start_idx].index;
239                let end_layer = sorted_layers[end_idx - 1].index + 1;
240                let slice = TensorSlice::new((start_layer, end_layer), size_gb);
241                distributions.push((node.id.clone(), vec![slice]));
242                current_layer_idx = end_idx;
243            }
244        }
245
246        if current_layer_idx >= sorted_layers.len() {
247            Ok(LoadDistributionPlan::new(distributions, total_layer_count))
248        } else {
249            Err(format!(
250                "insufficient VRAM: {} layers remain",
251                sorted_layers.len() - current_layer_idx
252            ))
253        }
254    }
255
256    /// Distribute layers and then chunk large node allocations to match worker parallelism.
257    pub fn distribute_layers_with_runtime_profile(
258        &self,
259        layers: &[crate::planning::LayerSpec],
260        profile: &RuntimeProfile,
261    ) -> Result<LoadDistributionPlan, String> {
262        let cfg = self.config();
263        let plan = self.distribute_layers(layers)?;
264        let backend = ExecutionBackend::from_runtime_profile(profile);
265        let vector_bias = (backend.vector_width_bits / 128).max(1);
266        let max_layers_per_slice = cfg
267            .max_layers_per_assignment
268            .min(profile.recommended_workers.max(1).saturating_mul(2))
269            .min(backend.preferred_batch_size / vector_bias)
270            .max(1);
271        Ok(chunk_distribution_plan(plan, max_layers_per_slice))
272    }
273
274    /// Rebalance load based on current node metrics
275    pub fn rebalance(&self) -> bool {
276        let cfg = self.config();
277
278        let (min_available, max_available, has_nodes) = {
279            let metrics = self
280                .cluster
281                .metrics
282                .lock()
283                .unwrap_or_else(|poison| poison.into_inner());
284
285            let mut min_val = f32::MAX;
286            let mut max_val = 0.0_f32;
287            let mut count = 0usize;
288
289            for node in metrics.values() {
290                if node.status == crate::cluster::NodeStatus::Active {
291                    min_val = min_val.min(node.available_vram_gb.max(0.0));
292                    max_val = max_val.max(node.available_vram_gb.max(0.0));
293                    count += 1;
294                }
295            }
296            (min_val, max_val, count > 0)
297        };
298
299        if !has_nodes {
300            return false;
301        }
302
303        // A large spread in available VRAM indicates load imbalance.
304        let skew_ratio = if min_available <= f32::EPSILON {
305            if max_available > 0.0 {
306                f32::INFINITY
307            } else {
308                1.0
309            }
310        } else {
311            max_available / min_available
312        };
313
314        if skew_ratio >= cfg.min_load_threshold {
315            tracing::info!(
316                "Detected load skew ratio {:.2} (threshold {:.2})",
317                skew_ratio,
318                cfg.min_load_threshold
319            );
320            return true;
321        }
322
323        false
324    }
325
326    /// Shed load from overloaded nodes to underloaded ones
327    pub fn shed_load(&self) -> Vec<(String, String)> {
328        let cfg = self.config();
329        let mut transfers: Vec<(String, String)> = Vec::new();
330
331        let metrics = self
332            .cluster
333            .metrics
334            .lock()
335            .unwrap_or_else(|poison| poison.into_inner());
336
337        // Extract required fields from active nodes to avoid cloning NodeMetrics
338        let active_nodes: Vec<(&String, f32, f32)> = metrics
339            .values()
340            .filter(|m| m.status == crate::cluster::NodeStatus::Active)
341            .map(|m| (&m.name, m.available_vram_gb, m.total_vram_gb))
342            .collect();
343
344        // Overloaded nodes are close to exhausted VRAM (low available headroom).
345        let overloaded_nodes: Vec<_> = active_nodes
346            .iter()
347            .copied()
348            .filter(|&(_, available, total)| available < total * 0.2)
349            .collect();
350
351        // Underloaded nodes have enough available headroom to receive load.
352        let underloaded_nodes: Vec<_> = active_nodes
353            .iter()
354            .copied()
355            .filter(|&(_, available, total)| available > total * 0.5)
356            .collect();
357
358        // For each overloaded node, find a suitable underloaded target
359        for &(overloaded_name, _, _) in &overloaded_nodes {
360            if transfers.len() >= cfg.max_concurrent_rebalances {
361                break;
362            }
363            if let Some(target_name) =
364                self.find_best_target_name(&underloaded_nodes, overloaded_name)
365            {
366                transfers.push((overloaded_name.clone(), target_name.clone()));
367            }
368        }
369
370        transfers
371    }
372
373    /// Find best target node name for load transfer
374    fn find_best_target_name<'a>(
375        &self,
376        targets: &[(&'a String, f32, f32)],
377        source_name: &str,
378    ) -> Option<&'a String> {
379        // Find node with most available VRAM that's different from source
380        let mut best_target: Option<&'a String> = None;
381        let mut max_available = 0.0f32;
382
383        for &(name, available, _) in targets {
384            if name == source_name {
385                continue;
386            }
387
388            if available > max_available {
389                max_available = available;
390                best_target = Some(name);
391            }
392        }
393
394        best_target
395    }
396
397    // ------------------------------------------------------------------
398    // SystemProfileWatcher integration
399    // ------------------------------------------------------------------
400
401    /// Re-tune load-balancing thresholds from a system profile detected at runtime.
402    pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
403        let rp: RuntimeProfile = profile.into();
404        if let Ok(mut guard) = self.config.lock() {
405            *guard = LoadBalanceConfig::autotuned(&rp);
406        }
407    }
408
409    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
410    /// automatically re-tune whenever hardware changes are detected.
411    ///
412    /// Returns a `JoinHandle` that can be cancelled by dropping it.
413    pub fn subscribe_to_watcher(
414        self: &Arc<Self>,
415        watcher: &crate::watcher::SystemProfileWatcher,
416    ) -> tokio::task::JoinHandle<()> {
417        let this = self.clone();
418        let mut rx = watcher.subscribe();
419        tokio::spawn(async move {
420            while let Ok(event) = rx.recv().await {
421                if let ProfileChange::Updated(profile) = event {
422                    this.reconfigure_from_system_profile(&profile);
423                    tracing::info!(
424                        "load balancer re-tuned from profile: {} workers, {:?} accel",
425                        profile.recommended_workers,
426                        profile.acceleration_mode,
427                    );
428                }
429            }
430        })
431    }
432
433    /// Distribute layers with deadlock prevention
434    pub fn distribute_with_deadlock_prevention(
435        &self,
436        layers: &[crate::planning::LayerSpec],
437    ) -> Result<LoadDistributionPlan, String> {
438        let cfg = self.config();
439        // Use timeout-based acquisition to prevent deadlocks
440        let start_time = std::time::Instant::now();
441
442        while start_time.elapsed().as_micros() < cfg.lock_timeout_us as u128 {
443            match self.distribute_layers(layers) {
444                Ok(plan) => return Ok(plan),
445                Err(_) => {
446                    // Retry with backoff
447                    std::thread::sleep(std::time::Duration::from_millis(10));
448                }
449            }
450        }
451
452        Err("deadlock prevention timeout".into())
453    }
454}
455
456fn chunk_distribution_plan(
457    plan: LoadDistributionPlan,
458    max_layers_per_slice: usize,
459) -> LoadDistributionPlan {
460    let slice_limit = max_layers_per_slice.max(1);
461    let distributions = plan
462        .distributions
463        .into_iter()
464        .map(|(node_id, slices)| {
465            let estimated_chunks = slices
466                .iter()
467                .map(|slice| {
468                    let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
469                    total_layers.div_ceil(slice_limit).max(1)
470                })
471                .sum();
472            let mut chunked = Vec::with_capacity(estimated_chunks);
473            for slice in slices {
474                let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
475                if total_layers <= slice_limit {
476                    chunked.push(slice);
477                    continue;
478                }
479
480                let avg_size = if total_layers == 0 {
481                    0.0
482                } else {
483                    slice.size_gb / total_layers as f32
484                };
485                let mut start = slice.layer_range.0;
486                while start < slice.layer_range.1 {
487                    let end = (start + slice_limit).min(slice.layer_range.1);
488                    let layers_in_chunk = end - start;
489                    let mut chunk =
490                        TensorSlice::new((start, end), avg_size * layers_in_chunk as f32);
491                    chunk.num_weights = slice.num_weights;
492                    chunked.push(chunk);
493                    start = end;
494                }
495            }
496            (node_id, chunked)
497        })
498        .collect();
499
500    LoadDistributionPlan::new(distributions, plan.total_layers)
501}
502
503/// Load statistics collector
504#[derive(Clone, Debug, Default)]
505pub struct LoadStats {
506    /// Total layers distributed
507    pub total_layers_distributed: usize,
508    /// Total VRAM used across cluster
509    pub total_vram_used_gb: f32,
510    /// Average load balance ratio (0.0 to 1.0)
511    pub avg_load_balance_ratio: f32,
512    /// Number of rebalancing operations
513    pub rebalancing_count: usize,
514    /// Whether avg_load_balance_ratio has received its first sample yet.
515    balance_ratio_initialized: bool,
516}
517
518impl LoadStats {
519    /// Create new statistics collector
520    pub fn new() -> Self {
521        Self::default()
522    }
523
524    /// Record layer distribution
525    pub fn record_distribution(&mut self, num_layers: usize, vram_gb: f32) {
526        self.total_layers_distributed += num_layers;
527        self.total_vram_used_gb += vram_gb;
528    }
529
530    /// Update load balance ratio
531    pub fn update_balance_ratio(&mut self, ratio: f32) {
532        if !self.balance_ratio_initialized {
533            // First call - initialize directly
534            self.avg_load_balance_ratio = ratio;
535            self.balance_ratio_initialized = true;
536        } else {
537            // EMA with alpha=0.1
538            self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
539        }
540    }
541
542    /// Record rebalancing operation
543    pub fn record_rebalancing(&mut self) {
544        self.rebalancing_count += 1;
545    }
546
547    /// Get load report
548    pub fn report(&self) -> String {
549        format!(
550            "Load Statistics\n\
551             ==========\n\
552             Total layers distributed: {}\n\
553             Total VRAM used: {:.1} GB\n\
554             Avg load balance ratio: {:.2}\n\
555             Rebalancing operations: {}",
556            self.total_layers_distributed,
557            self.total_vram_used_gb,
558            self.avg_load_balance_ratio,
559            self.rebalancing_count
560        )
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::cluster::ClusterState;
568    use crate::host::AccelerationMode;
569    use crate::protocol::NodeResources;
570    use std::sync::Arc;
571
572    fn sample_layers(count: usize, vram_gb: f32) -> Vec<crate::planning::LayerSpec> {
573        (0..count)
574            .map(|index| crate::planning::LayerSpec {
575                index,
576                vram_gb,
577                num_weights: 0,
578            })
579            .collect()
580    }
581
582    fn sample_nodes(count: usize, base_vram: f32) -> Vec<NodeResources> {
583        (0..count)
584            .map(|i| {
585                NodeResources::new(
586                    format!("node-{i}"),
587                    base_vram + (i as f32 * 6.0),
588                    64.0,
589                    "8.9".to_string(),
590                    None,
591                )
592            })
593            .collect()
594    }
595
596    #[test]
597    fn load_balancer_distributes_layers() {
598        let cluster = ClusterState::new();
599        for node in sample_nodes(2, 24.0) {
600            cluster.register(node);
601        }
602
603        let layers = sample_layers(33, 1.0);
604        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
605
606        let plan = balancer.distribute_layers(&layers).unwrap();
607        assert_eq!(plan.total_layers, 33);
608    }
609
610    #[test]
611    fn load_balancer_generates_summary() {
612        let cluster = ClusterState::new();
613        for node in sample_nodes(2, 24.0) {
614            cluster.register(node);
615        }
616
617        let layers = sample_layers(33, 1.0);
618        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
619
620        let plan = balancer.distribute_layers(&layers).unwrap();
621        let summary = plan.summary();
622
623        assert!(summary.contains("Total layers: 33"));
624    }
625
626    #[test]
627    fn load_stats_records_distribution() {
628        let mut stats = LoadStats::new();
629
630        stats.record_distribution(24, 24.0);
631        stats.record_distribution(9, 9.0);
632
633        assert_eq!(stats.total_layers_distributed, 33);
634        assert!((stats.total_vram_used_gb - 33.0).abs() < 0.01);
635    }
636
637    #[test]
638    fn load_stats_updates_balance_ratio() {
639        let mut stats = LoadStats::new();
640
641        stats.update_balance_ratio(0.95);
642        assert!((stats.avg_load_balance_ratio - 0.95).abs() < 1e-6);
643
644        stats.update_balance_ratio(0.85);
645        // EMA: 0.95 * 0.9 + 0.85 * 0.1 = 0.855 + 0.085 = 0.94
646        assert!((stats.avg_load_balance_ratio - 0.94).abs() < 1e-6);
647    }
648
649    #[test]
650    fn load_stats_first_balance_ratio_not_diluted_by_prior_distribution() {
651        // Regression: the "first call" check for update_balance_ratio used
652        // total_layers_distributed == 0 as a proxy for "never updated
653        // before". If record_distribution() ran first (the normal order:
654        // record layer placements, then periodically report a balance
655        // ratio), the very first update_balance_ratio() call fell through
656        // to the EMA branch and diluted the true first sample by 10x
657        // (0.0 * 0.9 + ratio * 0.1) instead of setting it directly.
658        let mut stats = LoadStats::new();
659
660        stats.record_distribution(24, 24.0);
661        stats.update_balance_ratio(0.95);
662
663        assert!(
664            (stats.avg_load_balance_ratio - 0.95).abs() < 1e-6,
665            "first balance ratio sample must be set directly, got {}",
666            stats.avg_load_balance_ratio
667        );
668    }
669
670    #[test]
671    fn load_stats_reports() {
672        let mut stats = LoadStats::new();
673
674        stats.record_distribution(24, 24.0);
675        stats.update_balance_ratio(0.95);
676
677        let report = stats.report();
678        assert!(report.contains("Total layers distributed: 24"));
679    }
680
681    #[test]
682    fn autotuned_config_reflects_runtime_profile() {
683        let profile = RuntimeProfile {
684            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
685            logical_cores: 16,
686            recommended_workers: 6,
687            acceleration_mode: AccelerationMode::Gpu,
688            gpu_backend: crate::host::GpuBackend::Cuda,
689            xdp_supported: true,
690            detection_source: String::from("test"),
691            probe_mode: crate::host::ProbeMode::Fast,
692        };
693
694        let config = LoadBalanceConfig::autotuned(&profile);
695        assert_eq!(config.lock_timeout_us, 500);
696        assert_eq!(config.max_layers_per_assignment, 12);
697        assert_eq!(config.max_concurrent_rebalances, 6);
698        assert!(config.min_load_threshold > 1.0);
699    }
700
701    #[test]
702    fn autotuned_cpu_threshold_stays_above_one() {
703        // Regression: the CPU/generic tier's min_load_threshold was 0.95,
704        // below the mathematical minimum of skew_ratio (always >= 1.0), so
705        // rebalance() always returned true regardless of actual balance.
706        let profile = RuntimeProfile {
707            node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
708            logical_cores: 4,
709            recommended_workers: 2,
710            acceleration_mode: AccelerationMode::Generic,
711            gpu_backend: crate::host::GpuBackend::Cpu,
712            xdp_supported: false,
713            detection_source: String::from("test"),
714            probe_mode: crate::host::ProbeMode::Fast,
715        };
716        let config = LoadBalanceConfig::autotuned(&profile);
717        assert!(
718            config.min_load_threshold > 1.0,
719            "CPU-tier threshold {} must exceed skew_ratio's mathematical floor of 1.0",
720            config.min_load_threshold
721        );
722    }
723
724    #[test]
725    fn rebalance_does_not_always_trigger_on_balanced_cpu_cluster() {
726        let cluster = Arc::new(ClusterState::new());
727        cluster.register(NodeResources::new("node-a", 0.0, 16.0, "generic", None));
728        cluster.register(NodeResources::new("node-b", 0.0, 16.0, "generic", None));
729
730        let profile = RuntimeProfile {
731            node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
732            logical_cores: 4,
733            recommended_workers: 2,
734            acceleration_mode: AccelerationMode::Generic,
735            gpu_backend: crate::host::GpuBackend::Cpu,
736            xdp_supported: false,
737            detection_source: String::from("test"),
738            probe_mode: crate::host::ProbeMode::Fast,
739        };
740        let balancer = LoadBalancer::new(cluster, LoadBalanceConfig::autotuned(&profile));
741        assert!(
742            !balancer.rebalance(),
743            "a perfectly balanced (zero-VRAM) CPU cluster must not be reported as needing rebalance"
744        );
745    }
746
747    #[test]
748    fn runtime_profile_chunks_large_distribution_slices() {
749        let plan = LoadDistributionPlan::new(
750            vec![("node-a".into(), vec![TensorSlice::new((0, 10), 10.0)])],
751            10,
752        );
753
754        let chunked = chunk_distribution_plan(plan, 4);
755        assert_eq!(chunked.distributions[0].1.len(), 3);
756        assert_eq!(chunked.distributions[0].1[0].layer_range, (0, 4));
757        assert_eq!(chunked.distributions[0].1[2].layer_range, (8, 10));
758    }
759
760    #[test]
761    fn shed_load_moves_from_low_headroom_to_high_headroom_nodes() {
762        let cluster = ClusterState::new();
763        cluster.register(NodeResources::new("node-hot", 24.0, 64.0, "8.9", None));
764        cluster.register(NodeResources::new("node-cool", 24.0, 64.0, "8.9", None));
765
766        // node-hot is overloaded (very low available VRAM)
767        cluster.get_metrics_mut("node-hot", |m| {
768            m.available_vram_gb = 2.0;
769            m.total_vram_gb = 24.0;
770        });
771
772        // node-cool has enough headroom to receive work
773        cluster.get_metrics_mut("node-cool", |m| {
774            m.available_vram_gb = 20.0;
775            m.total_vram_gb = 24.0;
776        });
777
778        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
779        let transfers = balancer.shed_load();
780
781        assert_eq!(transfers.len(), 1);
782        assert_eq!(transfers[0].0, "node-hot");
783        assert_eq!(transfers[0].1, "node-cool");
784    }
785}