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        self.distribute_layers_internal(layers, None)
187    }
188
189    fn distribute_layers_internal(
190        &self,
191        layers: &[crate::planning::LayerSpec],
192        max_layers_per_slice: Option<usize>,
193    ) -> Result<LoadDistributionPlan, String> {
194        let nodes_snapshot = self.cluster.nodes_snapshot();
195        if nodes_snapshot.is_empty() {
196            return Err("no nodes available".into());
197        }
198
199        // OPTIMIZATION: Use `sort_unstable_by` when sorting node references by VRAM capacity.
200        // `sort_unstable_by` avoids allocating scratch memory buffers and executing stability overhead,
201        // reducing sorting latency on node resource references during greedy load distribution.
202        let mut sorted_nodes: Vec<&crate::protocol::NodeResources> =
203            nodes_snapshot.iter().collect();
204        sorted_nodes.sort_unstable_by(|a, b| {
205            b.vram_gb
206                .partial_cmp(&a.vram_gb)
207                .unwrap_or(std::cmp::Ordering::Equal)
208        });
209
210        // Zero-copy shortcut: check if layers are already sorted (nearly always true).
211        // If already sorted, borrow instead of allocating/sorting a new vector.
212        let is_sorted = layers.windows(2).all(|w| w[0].index <= w[1].index);
213        let sorted_layers: std::borrow::Cow<'_, [crate::planning::LayerSpec]> = if is_sorted {
214            std::borrow::Cow::Borrowed(layers)
215        } else {
216            let mut all_layers = layers.to_vec();
217            // OPTIMIZATION: Unstable sorting avoids scratch buffer allocations and stability tracking overhead.
218            all_layers.sort_unstable_by_key(|l| l.index);
219            std::borrow::Cow::Owned(all_layers)
220        };
221        let total_layer_count = sorted_layers.len();
222
223        // Greedy assignment: assign contiguous layers to nodes based on VRAM using O(1) indices
224        let mut distributions = Vec::with_capacity(sorted_nodes.len());
225        let mut participating_nodes = Vec::with_capacity(sorted_nodes.len());
226        let mut current_layer_idx = 0usize;
227        let slice_limit = max_layers_per_slice.unwrap_or(usize::MAX).max(1);
228
229        for node in &sorted_nodes {
230            if current_layer_idx >= sorted_layers.len() {
231                break;
232            }
233
234            let mut used_vram = 0.0f32;
235            let start_idx = current_layer_idx;
236            let mut end_idx = current_layer_idx;
237
238            for layer in &sorted_layers[current_layer_idx..] {
239                if used_vram + layer.vram_gb > node.vram_gb {
240                    break;
241                }
242                end_idx += 1;
243                used_vram += layer.vram_gb;
244            }
245
246            if end_idx > start_idx {
247                let total_node_layers = end_idx - start_idx;
248                let node_id = node.id.clone();
249                participating_nodes.push(node_id.clone());
250
251                if total_node_layers <= slice_limit {
252                    let start_layer = sorted_layers[start_idx].index;
253                    let end_layer = sorted_layers[end_idx - 1].index + 1;
254                    let slice = TensorSlice::new((start_layer, end_layer), used_vram);
255                    distributions.push((node_id, vec![slice]));
256                } else {
257                    let chunks_count = total_node_layers.div_ceil(slice_limit);
258                    let mut slices = Vec::with_capacity(chunks_count);
259                    let avg_size = used_vram / total_node_layers as f32;
260
261                    let mut chunk_start_idx = start_idx;
262                    while chunk_start_idx < end_idx {
263                        let chunk_end_idx = (chunk_start_idx + slice_limit).min(end_idx);
264                        let chunk_layers = chunk_end_idx - chunk_start_idx;
265                        let start_layer = sorted_layers[chunk_start_idx].index;
266                        let end_layer = sorted_layers[chunk_end_idx - 1].index + 1;
267                        slices.push(TensorSlice::new(
268                            (start_layer, end_layer),
269                            avg_size * chunk_layers as f32,
270                        ));
271                        chunk_start_idx = chunk_end_idx;
272                    }
273                    distributions.push((node_id, slices));
274                }
275
276                current_layer_idx = end_idx;
277            }
278        }
279
280        if current_layer_idx >= sorted_layers.len() {
281            Ok(LoadDistributionPlan {
282                distributions,
283                total_layers: total_layer_count,
284                participating_nodes,
285            })
286        } else {
287            Err(format!(
288                "insufficient VRAM: {} layers remain",
289                sorted_layers.len() - current_layer_idx
290            ))
291        }
292    }
293
294    /// Distribute layers and directly chunk large node allocations to match worker parallelism.
295    ///
296    /// OPTIMIZATION: Delegates directly to `distribute_layers_internal` with `Some(max_layers_per_slice)`
297    /// to generate chunked tensor slices in a single pass during greedy assignment. This completely
298    /// eliminates intermediate single-slice vector allocations (`vec![slice]`), avoids intermediate
299    /// `LoadDistributionPlan` construction, and bypasses multi-pass post-processing traversals.
300    pub fn distribute_layers_with_runtime_profile(
301        &self,
302        layers: &[crate::planning::LayerSpec],
303        profile: &RuntimeProfile,
304    ) -> Result<LoadDistributionPlan, String> {
305        let cfg = self.config();
306        let backend = ExecutionBackend::from_runtime_profile(profile);
307        let vector_bias = (backend.vector_width_bits / 128).max(1);
308        let max_layers_per_slice = cfg
309            .max_layers_per_assignment
310            .min(profile.recommended_workers.max(1).saturating_mul(2))
311            .min(backend.preferred_batch_size / vector_bias)
312            .max(1);
313
314        self.distribute_layers_internal(layers, Some(max_layers_per_slice))
315    }
316
317    /// Rebalance load based on current node metrics
318    pub fn rebalance(&self) -> bool {
319        let cfg = self.config();
320
321        let (min_available, max_available, has_nodes) = {
322            let metrics = self
323                .cluster
324                .metrics
325                .lock()
326                .unwrap_or_else(|poison| poison.into_inner());
327
328            let mut min_val = f32::MAX;
329            let mut max_val = 0.0_f32;
330            let mut count = 0usize;
331
332            for node in metrics.values() {
333                if node.status == crate::cluster::NodeStatus::Active {
334                    min_val = min_val.min(node.available_vram_gb.max(0.0));
335                    max_val = max_val.max(node.available_vram_gb.max(0.0));
336                    count += 1;
337                }
338            }
339            (min_val, max_val, count > 0)
340        };
341
342        if !has_nodes {
343            return false;
344        }
345
346        // A large spread in available VRAM indicates load imbalance.
347        let skew_ratio = if min_available <= f32::EPSILON {
348            if max_available > 0.0 {
349                f32::INFINITY
350            } else {
351                1.0
352            }
353        } else {
354            max_available / min_available
355        };
356
357        if skew_ratio >= cfg.min_load_threshold {
358            tracing::info!(
359                "Detected load skew ratio {:.2} (threshold {:.2})",
360                skew_ratio,
361                cfg.min_load_threshold
362            );
363            return true;
364        }
365
366        false
367    }
368
369    /// Shed load from overloaded nodes to underloaded ones.
370    ///
371    /// OPTIMIZATION: Collects overloaded and underloaded node candidates in a single-pass loop
372    /// over cluster metrics under lock, completely eliminating intermediate `active_nodes` vector
373    /// allocations and inlining best target lookup.
374    pub fn shed_load(&self) -> Vec<(String, String)> {
375        let cfg = self.config();
376        let mut transfers: Vec<(String, String)> = Vec::new();
377
378        let metrics = self
379            .cluster
380            .metrics
381            .lock()
382            .unwrap_or_else(|poison| poison.into_inner());
383
384        let mut overloaded_nodes: Vec<&String> = Vec::new();
385        let mut underloaded_nodes: Vec<(&String, f32)> = Vec::new();
386
387        for m in metrics.values() {
388            if m.status == crate::cluster::NodeStatus::Active {
389                if m.available_vram_gb < m.total_vram_gb * 0.2 {
390                    overloaded_nodes.push(&m.name);
391                } else if m.available_vram_gb > m.total_vram_gb * 0.5 {
392                    underloaded_nodes.push((&m.name, m.available_vram_gb));
393                }
394            }
395        }
396
397        for &overloaded_name in &overloaded_nodes {
398            if transfers.len() >= cfg.max_concurrent_rebalances {
399                break;
400            }
401
402            let mut best_target: Option<&String> = None;
403            let mut max_available = 0.0f32;
404
405            for &(name, available) in &underloaded_nodes {
406                if name != overloaded_name && available > max_available {
407                    max_available = available;
408                    best_target = Some(name);
409                }
410            }
411
412            if let Some(target_name) = best_target {
413                transfers.push((overloaded_name.clone(), target_name.clone()));
414            }
415        }
416
417        transfers
418    }
419
420    // ------------------------------------------------------------------
421    // SystemProfileWatcher integration
422    // ------------------------------------------------------------------
423
424    /// Re-tune load-balancing thresholds from a system profile detected at runtime.
425    pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
426        let rp: RuntimeProfile = profile.into();
427        if let Ok(mut guard) = self.config.lock() {
428            *guard = LoadBalanceConfig::autotuned(&rp);
429        }
430    }
431
432    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
433    /// automatically re-tune whenever hardware changes are detected.
434    ///
435    /// Returns a `JoinHandle` that can be cancelled by dropping it.
436    pub fn subscribe_to_watcher(
437        self: &Arc<Self>,
438        watcher: &crate::watcher::SystemProfileWatcher,
439    ) -> tokio::task::JoinHandle<()> {
440        let this = self.clone();
441        let mut rx = watcher.subscribe();
442        tokio::spawn(async move {
443            while let Ok(event) = rx.recv().await {
444                if let ProfileChange::Updated(profile) = event {
445                    this.reconfigure_from_system_profile(&profile);
446                    tracing::info!(
447                        "load balancer re-tuned from profile: {} workers, {:?} accel",
448                        profile.recommended_workers,
449                        profile.acceleration_mode,
450                    );
451                }
452            }
453        })
454    }
455
456    /// Distribute layers with deadlock prevention
457    pub fn distribute_with_deadlock_prevention(
458        &self,
459        layers: &[crate::planning::LayerSpec],
460    ) -> Result<LoadDistributionPlan, String> {
461        let cfg = self.config();
462        // Use timeout-based acquisition to prevent deadlocks
463        let start_time = std::time::Instant::now();
464
465        while start_time.elapsed().as_micros() < cfg.lock_timeout_us as u128 {
466            match self.distribute_layers(layers) {
467                Ok(plan) => return Ok(plan),
468                Err(_) => {
469                    // Retry with backoff
470                    std::thread::sleep(std::time::Duration::from_millis(10));
471                }
472            }
473        }
474
475        Err("deadlock prevention timeout".into())
476    }
477}
478
479/// Split large tensor slices in an existing distribution plan into smaller contiguous chunks.
480pub fn chunk_distribution_plan(
481    plan: LoadDistributionPlan,
482    max_layers_per_slice: usize,
483) -> LoadDistributionPlan {
484    let slice_limit = max_layers_per_slice.max(1);
485
486    // Fast path: Avoid rebuilding distribution tuples and vector allocations if no slice exceeds the limit.
487    let needs_chunking = plan.distributions.iter().any(|(_, slices)| {
488        slices
489            .iter()
490            .any(|s| s.layer_range.1.saturating_sub(s.layer_range.0) > slice_limit)
491    });
492    if !needs_chunking {
493        return plan;
494    }
495
496    let distributions = plan
497        .distributions
498        .into_iter()
499        .map(|(node_id, slices)| {
500            let estimated_chunks = slices
501                .iter()
502                .map(|slice| {
503                    let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
504                    total_layers.div_ceil(slice_limit).max(1)
505                })
506                .sum();
507            let mut chunked = Vec::with_capacity(estimated_chunks);
508            for slice in slices {
509                let total_layers = slice.layer_range.1.saturating_sub(slice.layer_range.0);
510                if total_layers <= slice_limit {
511                    chunked.push(slice);
512                    continue;
513                }
514
515                let avg_size = if total_layers == 0 {
516                    0.0
517                } else {
518                    slice.size_gb / total_layers as f32
519                };
520                let mut start = slice.layer_range.0;
521                while start < slice.layer_range.1 {
522                    let end = (start + slice_limit).min(slice.layer_range.1);
523                    let layers_in_chunk = end - start;
524                    let mut chunk =
525                        TensorSlice::new((start, end), avg_size * layers_in_chunk as f32);
526                    chunk.num_weights = slice.num_weights;
527                    chunked.push(chunk);
528                    start = end;
529                }
530            }
531            (node_id, chunked)
532        })
533        .collect();
534
535    // Optimize: Reuse existing owned participating_nodes vector from input plan rather than re-cloning string IDs from distributions.
536    LoadDistributionPlan {
537        distributions,
538        total_layers: plan.total_layers,
539        participating_nodes: plan.participating_nodes,
540    }
541}
542
543/// Load statistics collector
544#[derive(Clone, Debug, Default)]
545pub struct LoadStats {
546    /// Total layers distributed
547    pub total_layers_distributed: usize,
548    /// Total VRAM used across cluster
549    pub total_vram_used_gb: f32,
550    /// Average load balance ratio (0.0 to 1.0)
551    pub avg_load_balance_ratio: f32,
552    /// Number of rebalancing operations
553    pub rebalancing_count: usize,
554    /// Whether avg_load_balance_ratio has received its first sample yet.
555    balance_ratio_initialized: bool,
556}
557
558impl LoadStats {
559    /// Create new statistics collector
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    /// Record layer distribution
565    pub fn record_distribution(&mut self, num_layers: usize, vram_gb: f32) {
566        self.total_layers_distributed += num_layers;
567        self.total_vram_used_gb += vram_gb;
568    }
569
570    /// Update load balance ratio
571    pub fn update_balance_ratio(&mut self, ratio: f32) {
572        if !self.balance_ratio_initialized {
573            // First call - initialize directly
574            self.avg_load_balance_ratio = ratio;
575            self.balance_ratio_initialized = true;
576        } else {
577            // EMA with alpha=0.1
578            self.avg_load_balance_ratio = self.avg_load_balance_ratio * 0.9 + ratio * 0.1;
579        }
580    }
581
582    /// Record rebalancing operation
583    pub fn record_rebalancing(&mut self) {
584        self.rebalancing_count += 1;
585    }
586
587    /// Get load report
588    pub fn report(&self) -> String {
589        format!(
590            "Load Statistics\n\
591             ==========\n\
592             Total layers distributed: {}\n\
593             Total VRAM used: {:.1} GB\n\
594             Avg load balance ratio: {:.2}\n\
595             Rebalancing operations: {}",
596            self.total_layers_distributed,
597            self.total_vram_used_gb,
598            self.avg_load_balance_ratio,
599            self.rebalancing_count
600        )
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::cluster::ClusterState;
608    use crate::host::AccelerationMode;
609    use crate::protocol::NodeResources;
610    use std::sync::Arc;
611
612    fn sample_layers(count: usize, vram_gb: f32) -> Vec<crate::planning::LayerSpec> {
613        (0..count)
614            .map(|index| crate::planning::LayerSpec {
615                index,
616                vram_gb,
617                num_weights: 0,
618            })
619            .collect()
620    }
621
622    fn sample_nodes(count: usize, base_vram: f32) -> Vec<NodeResources> {
623        (0..count)
624            .map(|i| {
625                NodeResources::new(
626                    format!("node-{i}"),
627                    base_vram + (i as f32 * 6.0),
628                    64.0,
629                    "8.9".to_string(),
630                    None,
631                )
632            })
633            .collect()
634    }
635
636    #[test]
637    fn load_balancer_distributes_layers() {
638        let cluster = ClusterState::new();
639        for node in sample_nodes(2, 24.0) {
640            cluster.register(node);
641        }
642
643        let layers = sample_layers(33, 1.0);
644        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
645
646        let plan = balancer.distribute_layers(&layers).unwrap();
647        assert_eq!(plan.total_layers, 33);
648    }
649
650    #[test]
651    fn load_balancer_generates_summary() {
652        let cluster = ClusterState::new();
653        for node in sample_nodes(2, 24.0) {
654            cluster.register(node);
655        }
656
657        let layers = sample_layers(33, 1.0);
658        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
659
660        let plan = balancer.distribute_layers(&layers).unwrap();
661        let summary = plan.summary();
662
663        assert!(summary.contains("Total layers: 33"));
664    }
665
666    #[test]
667    fn load_stats_records_distribution() {
668        let mut stats = LoadStats::new();
669
670        stats.record_distribution(24, 24.0);
671        stats.record_distribution(9, 9.0);
672
673        assert_eq!(stats.total_layers_distributed, 33);
674        assert!((stats.total_vram_used_gb - 33.0).abs() < 0.01);
675    }
676
677    #[test]
678    fn load_stats_updates_balance_ratio() {
679        let mut stats = LoadStats::new();
680
681        stats.update_balance_ratio(0.95);
682        assert!((stats.avg_load_balance_ratio - 0.95).abs() < 1e-6);
683
684        stats.update_balance_ratio(0.85);
685        // EMA: 0.95 * 0.9 + 0.85 * 0.1 = 0.855 + 0.085 = 0.94
686        assert!((stats.avg_load_balance_ratio - 0.94).abs() < 1e-6);
687    }
688
689    #[test]
690    fn load_stats_first_balance_ratio_not_diluted_by_prior_distribution() {
691        // Regression: the "first call" check for update_balance_ratio used
692        // total_layers_distributed == 0 as a proxy for "never updated
693        // before". If record_distribution() ran first (the normal order:
694        // record layer placements, then periodically report a balance
695        // ratio), the very first update_balance_ratio() call fell through
696        // to the EMA branch and diluted the true first sample by 10x
697        // (0.0 * 0.9 + ratio * 0.1) instead of setting it directly.
698        let mut stats = LoadStats::new();
699
700        stats.record_distribution(24, 24.0);
701        stats.update_balance_ratio(0.95);
702
703        assert!(
704            (stats.avg_load_balance_ratio - 0.95).abs() < 1e-6,
705            "first balance ratio sample must be set directly, got {}",
706            stats.avg_load_balance_ratio
707        );
708    }
709
710    #[test]
711    fn load_stats_reports() {
712        let mut stats = LoadStats::new();
713
714        stats.record_distribution(24, 24.0);
715        stats.update_balance_ratio(0.95);
716
717        let report = stats.report();
718        assert!(report.contains("Total layers distributed: 24"));
719    }
720
721    #[test]
722    fn autotuned_config_reflects_runtime_profile() {
723        let profile = RuntimeProfile {
724            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
725            logical_cores: 16,
726            recommended_workers: 6,
727            acceleration_mode: AccelerationMode::Gpu,
728            gpu_backend: crate::host::GpuBackend::Cuda,
729            xdp_supported: true,
730            detection_source: String::from("test"),
731            probe_mode: crate::host::ProbeMode::Fast,
732        };
733
734        let config = LoadBalanceConfig::autotuned(&profile);
735        assert_eq!(config.lock_timeout_us, 500);
736        assert_eq!(config.max_layers_per_assignment, 12);
737        assert_eq!(config.max_concurrent_rebalances, 6);
738        assert!(config.min_load_threshold > 1.0);
739    }
740
741    #[test]
742    fn autotuned_cpu_threshold_stays_above_one() {
743        // Regression: the CPU/generic tier's min_load_threshold was 0.95,
744        // below the mathematical minimum of skew_ratio (always >= 1.0), so
745        // rebalance() always returned true regardless of actual balance.
746        let profile = RuntimeProfile {
747            node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
748            logical_cores: 4,
749            recommended_workers: 2,
750            acceleration_mode: AccelerationMode::Generic,
751            gpu_backend: crate::host::GpuBackend::Cpu,
752            xdp_supported: false,
753            detection_source: String::from("test"),
754            probe_mode: crate::host::ProbeMode::Fast,
755        };
756        let config = LoadBalanceConfig::autotuned(&profile);
757        assert!(
758            config.min_load_threshold > 1.0,
759            "CPU-tier threshold {} must exceed skew_ratio's mathematical floor of 1.0",
760            config.min_load_threshold
761        );
762    }
763
764    #[test]
765    fn rebalance_does_not_always_trigger_on_balanced_cpu_cluster() {
766        let cluster = Arc::new(ClusterState::new());
767        cluster.register(NodeResources::new("node-a", 0.0, 16.0, "generic", None));
768        cluster.register(NodeResources::new("node-b", 0.0, 16.0, "generic", None));
769
770        let profile = RuntimeProfile {
771            node_resources: NodeResources::new("node-a", 0.0, 16.0, "generic", None),
772            logical_cores: 4,
773            recommended_workers: 2,
774            acceleration_mode: AccelerationMode::Generic,
775            gpu_backend: crate::host::GpuBackend::Cpu,
776            xdp_supported: false,
777            detection_source: String::from("test"),
778            probe_mode: crate::host::ProbeMode::Fast,
779        };
780        let balancer = LoadBalancer::new(cluster, LoadBalanceConfig::autotuned(&profile));
781        assert!(
782            !balancer.rebalance(),
783            "a perfectly balanced (zero-VRAM) CPU cluster must not be reported as needing rebalance"
784        );
785    }
786
787    #[test]
788    fn runtime_profile_chunks_large_distribution_slices() {
789        let plan = LoadDistributionPlan::new(
790            vec![("node-a".into(), vec![TensorSlice::new((0, 10), 10.0)])],
791            10,
792        );
793
794        let chunked = chunk_distribution_plan(plan, 4);
795        assert_eq!(chunked.distributions[0].1.len(), 3);
796        assert_eq!(chunked.distributions[0].1[0].layer_range, (0, 4));
797        assert_eq!(chunked.distributions[0].1[2].layer_range, (8, 10));
798    }
799
800    #[test]
801    fn shed_load_moves_from_low_headroom_to_high_headroom_nodes() {
802        let cluster = ClusterState::new();
803        cluster.register(NodeResources::new("node-hot", 24.0, 64.0, "8.9", None));
804        cluster.register(NodeResources::new("node-cool", 24.0, 64.0, "8.9", None));
805
806        // node-hot is overloaded (very low available VRAM)
807        cluster.get_metrics_mut("node-hot", |m| {
808            m.available_vram_gb = 2.0;
809            m.total_vram_gb = 24.0;
810        });
811
812        // node-cool has enough headroom to receive work
813        cluster.get_metrics_mut("node-cool", |m| {
814            m.available_vram_gb = 20.0;
815            m.total_vram_gb = 24.0;
816        });
817
818        let balancer = LoadBalancer::new(Arc::new(cluster), LoadBalanceConfig::default());
819        let transfers = balancer.shed_load();
820
821        assert_eq!(transfers.len(), 1);
822        assert_eq!(transfers[0].0, "node-hot");
823        assert_eq!(transfers[0].1, "node-cool");
824    }
825}