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 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                // Optimize: Reuse `used_vram` directly instead of re-iterating over sorted_layers[start_idx..end_idx] to re-sum vram_gb.
238                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    /// Distribute layers and then chunk large node allocations to match worker parallelism.
261    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    /// Rebalance load based on current node metrics
279    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        // A large spread in available VRAM indicates load imbalance.
308        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    /// Shed load from overloaded nodes to underloaded ones
331    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        // Extract required fields from active nodes to avoid cloning NodeMetrics
342        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        // Overloaded nodes are close to exhausted VRAM (low available headroom).
349        let overloaded_nodes: Vec<_> = active_nodes
350            .iter()
351            .copied()
352            .filter(|&(_, available, total)| available < total * 0.2)
353            .collect();
354
355        // Underloaded nodes have enough available headroom to receive load.
356        let underloaded_nodes: Vec<_> = active_nodes
357            .iter()
358            .copied()
359            .filter(|&(_, available, total)| available > total * 0.5)
360            .collect();
361
362        // For each overloaded node, find a suitable underloaded target
363        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    /// Find best target node name for load transfer
378    fn find_best_target_name<'a>(
379        &self,
380        targets: &[(&'a String, f32, f32)],
381        source_name: &str,
382    ) -> Option<&'a String> {
383        // Find node with most available VRAM that's different from source
384        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    // ------------------------------------------------------------------
402    // SystemProfileWatcher integration
403    // ------------------------------------------------------------------
404
405    /// Re-tune load-balancing thresholds from a system profile detected at runtime.
406    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    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
414    /// automatically re-tune whenever hardware changes are detected.
415    ///
416    /// Returns a `JoinHandle` that can be cancelled by dropping it.
417    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    /// Distribute layers with deadlock prevention
438    pub fn distribute_with_deadlock_prevention(
439        &self,
440        layers: &[crate::planning::LayerSpec],
441    ) -> Result<LoadDistributionPlan, String> {
442        let cfg = self.config();
443        // Use timeout-based acquisition to prevent deadlocks
444        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                    // Retry with backoff
451                    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    // Optimize: Reuse existing owned participating_nodes vector from input plan rather than re-cloning string IDs from distributions.
505    LoadDistributionPlan {
506        distributions,
507        total_layers: plan.total_layers,
508        participating_nodes: plan.participating_nodes,
509    }
510}
511
512/// Load statistics collector
513#[derive(Clone, Debug, Default)]
514pub struct LoadStats {
515    /// Total layers distributed
516    pub total_layers_distributed: usize,
517    /// Total VRAM used across cluster
518    pub total_vram_used_gb: f32,
519    /// Average load balance ratio (0.0 to 1.0)
520    pub avg_load_balance_ratio: f32,
521    /// Number of rebalancing operations
522    pub rebalancing_count: usize,
523    /// Whether avg_load_balance_ratio has received its first sample yet.
524    balance_ratio_initialized: bool,
525}
526
527impl LoadStats {
528    /// Create new statistics collector
529    pub fn new() -> Self {
530        Self::default()
531    }
532
533    /// Record layer distribution
534    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    /// Update load balance ratio
540    pub fn update_balance_ratio(&mut self, ratio: f32) {
541        if !self.balance_ratio_initialized {
542            // First call - initialize directly
543            self.avg_load_balance_ratio = ratio;
544            self.balance_ratio_initialized = true;
545        } else {
546            // EMA with alpha=0.1
547            self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
548        }
549    }
550
551    /// Record rebalancing operation
552    pub fn record_rebalancing(&mut self) {
553        self.rebalancing_count += 1;
554    }
555
556    /// Get load report
557    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        // EMA: 0.95 * 0.9 + 0.85 * 0.1 = 0.855 + 0.085 = 0.94
655        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        // Regression: the "first call" check for update_balance_ratio used
661        // total_layers_distributed == 0 as a proxy for "never updated
662        // before". If record_distribution() ran first (the normal order:
663        // record layer placements, then periodically report a balance
664        // ratio), the very first update_balance_ratio() call fell through
665        // to the EMA branch and diluted the true first sample by 10x
666        // (0.0 * 0.9 + ratio * 0.1) instead of setting it directly.
667        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        // Regression: the CPU/generic tier's min_load_threshold was 0.95,
713        // below the mathematical minimum of skew_ratio (always >= 1.0), so
714        // rebalance() always returned true regardless of actual balance.
715        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        // node-hot is overloaded (very low available VRAM)
776        cluster.get_metrics_mut("node-hot", |m| {
777            m.available_vram_gb = 2.0;
778            m.total_vram_gb = 24.0;
779        });
780
781        // node-cool has enough headroom to receive work
782        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}