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, NodeMetrics};
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    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        // Sort nodes by VRAM capacity (descending)
187        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        // Collect all layers into a single sorted vector (by index)
195        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        // Greedy assignment: assign contiguous layers to nodes based on VRAM
200        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                // Remove assigned layers from remaining
234                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    /// Distribute layers and then chunk large node allocations to match worker parallelism.
249    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    /// Rebalance load based on current node metrics
267    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        // A large spread in available VRAM indicates load imbalance.
284        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    /// Shed load from overloaded nodes to underloaded ones
307    pub fn shed_load(&self) -> Vec<(String, String)> {
308        let cfg = self.config();
309        let mut transfers: Vec<(String, String)> = Vec::new();
310
311        // Overloaded nodes are close to exhausted VRAM (low available headroom).
312        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        // Underloaded nodes have enough available headroom to receive load.
320        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 each overloaded node, find a suitable underloaded target
328        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    /// Find best target node for load transfer
341    fn find_best_target(
342        &self,
343        targets: &[NodeMetrics],
344        source: &NodeMetrics,
345    ) -> Option<NodeMetrics> {
346        // Find node with most available VRAM that's different from source
347        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    // ------------------------------------------------------------------
365    // SystemProfileWatcher integration
366    // ------------------------------------------------------------------
367
368    /// Re-tune load-balancing thresholds from a system profile detected at runtime.
369    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    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
377    /// automatically re-tune whenever hardware changes are detected.
378    ///
379    /// Returns a `JoinHandle` that can be cancelled by dropping it.
380    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    /// Distribute layers with deadlock prevention
401    pub fn distribute_with_deadlock_prevention(
402        &self,
403        layers: &[crate::planning::LayerSpec],
404    ) -> Result<LoadDistributionPlan, String> {
405        let cfg = self.config();
406        // Use timeout-based acquisition to prevent deadlocks
407        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                    // Retry with backoff
414                    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/// Load statistics collector
471#[derive(Clone, Debug, Default)]
472pub struct LoadStats {
473    /// Total layers distributed
474    pub total_layers_distributed: usize,
475    /// Total VRAM used across cluster
476    pub total_vram_used_gb: f32,
477    /// Average load balance ratio (0.0 to 1.0)
478    pub avg_load_balance_ratio: f32,
479    /// Number of rebalancing operations
480    pub rebalancing_count: usize,
481    /// Whether avg_load_balance_ratio has received its first sample yet.
482    balance_ratio_initialized: bool,
483}
484
485impl LoadStats {
486    /// Create new statistics collector
487    pub fn new() -> Self {
488        Self::default()
489    }
490
491    /// Record layer distribution
492    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    /// Update load balance ratio
498    pub fn update_balance_ratio(&mut self, ratio: f32) {
499        if !self.balance_ratio_initialized {
500            // First call - initialize directly
501            self.avg_load_balance_ratio = ratio;
502            self.balance_ratio_initialized = true;
503        } else {
504            // EMA with alpha=0.1
505            self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
506        }
507    }
508
509    /// Record rebalancing operation
510    pub fn record_rebalancing(&mut self) {
511        self.rebalancing_count += 1;
512    }
513
514    /// Get load report
515    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        // EMA: 0.95 * 0.9 + 0.85 * 0.1 = 0.855 + 0.085 = 0.94
613        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        // Regression: the "first call" check for update_balance_ratio used
619        // total_layers_distributed == 0 as a proxy for "never updated
620        // before". If record_distribution() ran first (the normal order:
621        // record layer placements, then periodically report a balance
622        // ratio), the very first update_balance_ratio() call fell through
623        // to the EMA branch and diluted the true first sample by 10x
624        // (0.0 * 0.9 + ratio * 0.1) instead of setting it directly.
625        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        // Regression: the CPU/generic tier's min_load_threshold was 0.95,
671        // below the mathematical minimum of skew_ratio (always >= 1.0), so
672        // rebalance() always returned true regardless of actual balance.
673        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        // node-hot is overloaded (very low available VRAM)
734        cluster.get_metrics_mut("node-hot", |m| {
735            m.available_vram_gb = 2.0;
736            m.total_vram_gb = 24.0;
737        });
738
739        // node-cool has enough headroom to receive work
740        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}