1#![allow(dead_code)]
59
60pub mod checkpoint_format;
61
62use crate::enhanced_distributed_training::{DistributedConfig, PerformanceMetrics};
63use serde::{Deserialize, Serialize};
64use std::collections::{HashMap, VecDeque};
65use std::path::PathBuf;
66use std::sync::{Arc, Mutex};
67use std::time::{Duration, Instant, SystemTime};
68use trustformers_core::errors::{Result, TrustformersError};
69use trustformers_core::tensor::Tensor;
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct AutoScalerConfig {
74 pub min_nodes: usize,
76 pub max_nodes: usize,
78 pub strategy: ScalingStrategy,
80 pub scale_up_threshold: f32,
82 pub scale_down_threshold: f32,
84 pub scaling_cooldown: Duration,
86 pub predictive_scaling: bool,
88 pub cost_priority: f32,
90}
91
92impl Default for AutoScalerConfig {
93 fn default() -> Self {
94 Self {
95 min_nodes: 1,
96 max_nodes: 16,
97 strategy: ScalingStrategy::Performance,
98 scale_up_threshold: 0.85,
99 scale_down_threshold: 0.6,
100 scaling_cooldown: Duration::from_secs(300), predictive_scaling: true,
102 cost_priority: 0.3, }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub enum ScalingStrategy {
110 Performance,
112 QueueBased,
114 Predictive,
116 CostOptimized,
118 Custom(String),
120}
121
122pub trait NodeProvider: Send + Sync {
137 fn provision_nodes(&self, count: usize) -> Result<usize>;
142
143 fn terminate_nodes(&self, count: usize) -> Result<usize>;
146}
147
148#[derive(Debug, Default, Clone, Copy)]
157pub struct SimulatedNodeProvider;
158
159impl SimulatedNodeProvider {
160 pub fn new() -> Self {
161 Self
162 }
163}
164
165impl NodeProvider for SimulatedNodeProvider {
166 fn provision_nodes(&self, count: usize) -> Result<usize> {
167 Ok(count)
168 }
169
170 fn terminate_nodes(&self, count: usize) -> Result<usize> {
171 Ok(count)
172 }
173}
174
175pub struct AutoScaler {
177 config: AutoScalerConfig,
178 current_nodes: usize,
179 last_scaling_action: Instant,
180 performance_history: VecDeque<PerformanceMetrics>,
181 scaling_history: Vec<ScalingEvent>,
182 workload_predictor: WorkloadPredictor,
183 cost_optimizer: CostOptimizer,
184 node_provider: Option<Arc<dyn NodeProvider>>,
187}
188
189impl AutoScaler {
190 pub fn new(config: AutoScalerConfig) -> Self {
191 Self {
192 current_nodes: config.min_nodes,
193 config,
194 last_scaling_action: Instant::now(),
195 performance_history: VecDeque::with_capacity(1000),
196 scaling_history: Vec::new(),
197 workload_predictor: WorkloadPredictor::new(),
198 cost_optimizer: CostOptimizer::new(),
199 node_provider: None,
200 }
201 }
202
203 #[must_use]
208 pub fn with_node_provider(mut self, provider: Arc<dyn NodeProvider>) -> Self {
209 self.node_provider = Some(provider);
210 self
211 }
212
213 pub fn with_min_nodes(mut self, min_nodes: usize) -> Self {
215 self.config.min_nodes = min_nodes;
216 if self.current_nodes < min_nodes {
218 self.current_nodes = min_nodes;
219 }
220 self
221 }
222
223 pub fn with_max_nodes(mut self, max_nodes: usize) -> Self {
224 self.config.max_nodes = max_nodes;
225 self
226 }
227
228 pub fn with_scaling_strategy(mut self, strategy: ScalingStrategy) -> Self {
229 self.config.strategy = strategy;
230 self
231 }
232
233 pub fn with_scale_up_threshold(mut self, threshold: f32) -> Self {
234 self.config.scale_up_threshold = threshold;
235 self
236 }
237
238 pub fn with_scale_down_threshold(mut self, threshold: f32) -> Self {
239 self.config.scale_down_threshold = threshold;
240 self
241 }
242
243 pub fn update_and_scale(&mut self, metrics: &PerformanceMetrics) -> Result<ScalingDecision> {
251 self.performance_history.push_back(metrics.clone());
253 if self.performance_history.len() > 1000 {
254 self.performance_history.pop_front();
255 }
256
257 if self.last_scaling_action.elapsed() < self.config.scaling_cooldown {
259 return Ok(ScalingDecision::NoAction);
260 }
261
262 let avg_utilization =
264 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32;
265 let _avg_memory =
266 metrics.memory_usage.iter().sum::<f32>() / metrics.memory_usage.len() as f32;
267
268 let (decision, reason) = match &self.config.strategy {
274 ScalingStrategy::Performance => self.performance_based_scaling(avg_utilization)?,
275 ScalingStrategy::QueueBased => self.queue_based_scaling(metrics)?,
276 ScalingStrategy::Predictive => self.predictive_scaling(metrics)?,
277 ScalingStrategy::CostOptimized => {
278 self.cost_optimized_scaling(avg_utilization, metrics)?
279 },
280 ScalingStrategy::Custom(name) => self.custom_scaling(name, metrics)?,
281 };
282
283 match &decision {
285 ScalingDecision::ScaleUp(nodes) => {
286 self.execute_scale_up(*nodes, &reason)?;
287 },
288 ScalingDecision::ScaleDown(nodes) => {
289 self.execute_scale_down(*nodes, &reason)?;
290 },
291 ScalingDecision::NoAction => {},
292 }
293
294 Ok(decision)
295 }
296
297 fn performance_based_scaling(&self, avg_utilization: f32) -> Result<(ScalingDecision, String)> {
302 if avg_utilization > self.config.scale_up_threshold
303 && self.current_nodes < self.config.max_nodes
304 {
305 let target_utilization = 0.75; let utilization_ratio = avg_utilization / target_utilization;
308 let nodes_to_add =
309 ((utilization_ratio - 1.0) * self.current_nodes as f32).ceil() as usize;
310 let nodes_to_add = nodes_to_add.min(self.config.max_nodes - self.current_nodes);
311
312 let reason = format!(
313 "Performance strategy: GPU utilization {avg_utilization:.2} exceeds the \
314 scale-up threshold {:.2}",
315 self.config.scale_up_threshold
316 );
317 Ok((ScalingDecision::ScaleUp(nodes_to_add), reason))
318 } else if avg_utilization < self.config.scale_down_threshold
319 && self.current_nodes > self.config.min_nodes
320 {
321 let target_utilization = 0.8; let required_nodes =
324 (avg_utilization * self.current_nodes as f32 / target_utilization).ceil() as usize;
325 let nodes_to_remove = self.current_nodes.saturating_sub(required_nodes);
326 let nodes_to_remove = nodes_to_remove.min(self.current_nodes - self.config.min_nodes);
327
328 if nodes_to_remove > 0 {
329 let reason = format!(
330 "Performance strategy: GPU utilization {avg_utilization:.2} is below the \
331 scale-down threshold {:.2}",
332 self.config.scale_down_threshold
333 );
334 Ok((ScalingDecision::ScaleDown(nodes_to_remove), reason))
335 } else {
336 Ok((ScalingDecision::NoAction, String::new()))
337 }
338 } else {
339 Ok((ScalingDecision::NoAction, String::new()))
340 }
341 }
342
343 fn queue_based_scaling(
344 &self,
345 metrics: &PerformanceMetrics,
346 ) -> Result<(ScalingDecision, String)> {
347 let throughput_ratio = metrics.throughput / 1000.0; if throughput_ratio < 0.5 && self.current_nodes < self.config.max_nodes {
351 let reason = format!(
352 "Queue-based strategy: throughput ratio {throughput_ratio:.2} is below 0.5 \
353 (queue backlog signal)"
354 );
355 Ok((ScalingDecision::ScaleUp(1), reason))
356 } else if throughput_ratio > 2.0 && self.current_nodes > self.config.min_nodes {
357 let reason = format!(
358 "Queue-based strategy: throughput ratio {throughput_ratio:.2} is above 2.0 \
359 (excess capacity signal)"
360 );
361 Ok((ScalingDecision::ScaleDown(1), reason))
362 } else {
363 Ok((ScalingDecision::NoAction, String::new()))
364 }
365 }
366
367 fn predictive_scaling(
368 &mut self,
369 metrics: &PerformanceMetrics,
370 ) -> Result<(ScalingDecision, String)> {
371 if !self.config.predictive_scaling {
372 let (decision, reason) = self.performance_based_scaling(
373 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32,
374 )?;
375 return Ok((
376 decision,
377 format!("Predictive strategy disabled by config; used performance-based fallback -- {reason}"),
378 ));
379 }
380
381 self.workload_predictor.update_metrics(metrics);
383
384 if !self.workload_predictor.can_predict() {
389 let (decision, reason) = self.performance_based_scaling(
390 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32,
391 )?;
392 return Ok((
393 decision,
394 format!(
395 "Predictive strategy: not enough history to predict yet; used \
396 performance-based fallback -- {reason}"
397 ),
398 ));
399 }
400
401 let predicted_load = self.workload_predictor.predict_workload(Duration::from_secs(600))?;
403
404 let target = self.config.scale_up_threshold.clamp(0.05, 1.0);
408
409 if predicted_load > self.config.scale_up_threshold * 1.1 && self.current_nodes < self.config.max_nodes
412 {
413 let required = (predicted_load / target * self.current_nodes as f32).ceil() as usize;
414 let nodes_to_add = required.saturating_sub(self.current_nodes).max(1);
415 let reason = format!(
416 "Predictive strategy: predicted load {predicted_load:.2} over the next 10 \
417 minutes exceeds the scale-up threshold {:.2} (10% buffer applied)",
418 self.config.scale_up_threshold
419 );
420 Ok((
421 ScalingDecision::ScaleUp(
422 nodes_to_add.min(self.config.max_nodes - self.current_nodes),
423 ),
424 reason,
425 ))
426 } else if predicted_load < self.config.scale_down_threshold * 0.9 && self.current_nodes > self.config.min_nodes
428 {
429 let target_nodes =
430 ((predicted_load / target * self.current_nodes as f32).ceil() as usize).max(1);
431 let nodes_to_remove = self.current_nodes.saturating_sub(target_nodes);
432 if nodes_to_remove > 0 {
433 let reason = format!(
434 "Predictive strategy: predicted load {predicted_load:.2} over the next 10 \
435 minutes is below the scale-down threshold {:.2} (10% buffer applied)",
436 self.config.scale_down_threshold
437 );
438 Ok((
439 ScalingDecision::ScaleDown(
440 nodes_to_remove.min(self.current_nodes - self.config.min_nodes),
441 ),
442 reason,
443 ))
444 } else {
445 Ok((ScalingDecision::NoAction, String::new()))
446 }
447 } else {
448 Ok((ScalingDecision::NoAction, String::new()))
449 }
450 }
451
452 fn cost_optimized_scaling(
453 &mut self,
454 avg_utilization: f32,
455 metrics: &PerformanceMetrics,
456 ) -> Result<(ScalingDecision, String)> {
457 let current_cost = self.cost_optimizer.calculate_current_cost(self.current_nodes, metrics);
459
460 if avg_utilization > self.config.scale_up_threshold
462 && self.current_nodes < self.config.max_nodes
463 {
464 let scale_up_cost =
465 self.cost_optimizer.calculate_scale_up_cost(self.current_nodes + 1, metrics);
466 let cost_benefit_ratio = current_cost / scale_up_cost;
467
468 if cost_benefit_ratio > (1.0 - self.config.cost_priority) {
469 let reason = format!(
470 "Cost-optimized strategy: GPU utilization {avg_utilization:.2} exceeds the \
471 scale-up threshold {:.2} and the cost-benefit ratio {cost_benefit_ratio:.2} \
472 favors scaling up",
473 self.config.scale_up_threshold
474 );
475 Ok((ScalingDecision::ScaleUp(1), reason))
476 } else {
477 Ok((ScalingDecision::NoAction, String::new()))
478 }
479 } else if avg_utilization < self.config.scale_down_threshold
480 && self.current_nodes > self.config.min_nodes
481 {
482 let scale_down_cost =
483 self.cost_optimizer.calculate_scale_down_cost(self.current_nodes - 1, metrics);
484 let cost_savings = current_cost - scale_down_cost;
485
486 if cost_savings > current_cost * 0.1 {
487 let savings_pct =
489 if current_cost > 0.0 { cost_savings / current_cost * 100.0 } else { 0.0 };
490 let reason = format!(
491 "Cost-optimized strategy: GPU utilization {avg_utilization:.2} is below the \
492 scale-down threshold {:.2} and scaling down projects {savings_pct:.1}% cost \
493 savings (over the 10% minimum)",
494 self.config.scale_down_threshold
495 );
496 Ok((ScalingDecision::ScaleDown(1), reason))
497 } else {
498 Ok((ScalingDecision::NoAction, String::new()))
499 }
500 } else {
501 Ok((ScalingDecision::NoAction, String::new()))
502 }
503 }
504
505 fn custom_scaling(
514 &self,
515 name: &str,
516 _metrics: &PerformanceMetrics,
517 ) -> Result<(ScalingDecision, String)> {
518 Err(TrustformersError::not_implemented(format!(
519 "custom scaling strategy `{name}` has no implementation registered; select one of \
520 ScalingStrategy::{{Performance, QueueBased, Predictive, CostOptimized}} or drive the \
521 scaling decision yourself"
522 )))
523 }
524
525 fn execute_scale_up(&mut self, nodes: usize, reason: &str) -> Result<()> {
540 let provider = self.node_provider.as_ref().ok_or_else(|| {
541 TrustformersError::invalid_state(format!(
542 "cannot add {nodes} node(s): no NodeProvider is configured (AutoScaler has no \
543 cluster substrate of its own); attach one via AutoScaler::with_node_provider, \
544 or SimulatedNodeProvider for an explicit dry run"
545 ))
546 })?;
547
548 let provisioned = provider.provision_nodes(nodes)?;
549 self.current_nodes += provisioned;
550 self.last_scaling_action = Instant::now();
551
552 self.scaling_history.push(ScalingEvent {
553 timestamp: SystemTime::now(),
554 action: ScalingAction::ScaleUp,
555 nodes_changed: provisioned,
556 reason: reason.to_string(),
557 });
558
559 log::info!(
560 "scaling up: added {} node(s) (current: {})",
561 provisioned,
562 self.current_nodes
563 );
564
565 if provisioned < nodes {
566 return Err(TrustformersError::invalid_state(format!(
567 "requested {nodes} node(s) but the NodeProvider only provisioned {provisioned}"
568 )));
569 }
570
571 Ok(())
572 }
573
574 fn execute_scale_down(&mut self, nodes: usize, reason: &str) -> Result<()> {
584 let provider = self.node_provider.as_ref().ok_or_else(|| {
585 TrustformersError::invalid_state(format!(
586 "cannot remove {nodes} node(s): no NodeProvider is configured (AutoScaler has \
587 no cluster substrate of its own); attach one via \
588 AutoScaler::with_node_provider, or SimulatedNodeProvider for an explicit dry \
589 run"
590 ))
591 })?;
592
593 let terminated = provider.terminate_nodes(nodes)?;
594 self.current_nodes = self.current_nodes.saturating_sub(terminated);
595 self.last_scaling_action = Instant::now();
596
597 self.scaling_history.push(ScalingEvent {
598 timestamp: SystemTime::now(),
599 action: ScalingAction::ScaleDown,
600 nodes_changed: terminated,
601 reason: reason.to_string(),
602 });
603
604 log::info!(
605 "scaling down: removed {} node(s) (current: {})",
606 terminated,
607 self.current_nodes
608 );
609
610 if terminated < nodes {
611 return Err(TrustformersError::invalid_state(format!(
612 "requested to remove {nodes} node(s) but the NodeProvider only terminated \
613 {terminated}"
614 )));
615 }
616
617 Ok(())
618 }
619
620 pub fn get_current_nodes(&self) -> usize {
621 self.current_nodes
622 }
623
624 pub fn get_scaling_history(&self) -> &[ScalingEvent] {
625 &self.scaling_history
626 }
627}
628
629#[derive(Debug, Clone)]
631pub enum ScalingDecision {
632 ScaleUp(usize),
633 ScaleDown(usize),
634 NoAction,
635}
636
637#[derive(Debug, Clone)]
639pub struct ScalingEvent {
640 pub timestamp: SystemTime,
641 pub action: ScalingAction,
642 pub nodes_changed: usize,
643 pub reason: String,
644}
645
646#[derive(Debug, Clone)]
647pub enum ScalingAction {
648 ScaleUp,
649 ScaleDown,
650}
651
652pub struct WorkloadPredictor {
654 historical_data: VecDeque<(Instant, f32)>, trend_analyzer: TrendAnalyzer,
656 seasonal_analyzer: SeasonalAnalyzer,
657}
658
659impl Default for WorkloadPredictor {
660 fn default() -> Self {
661 Self::new()
662 }
663}
664
665impl WorkloadPredictor {
666 pub fn new() -> Self {
667 Self {
668 historical_data: VecDeque::with_capacity(10000),
669 trend_analyzer: TrendAnalyzer::new(),
670 seasonal_analyzer: SeasonalAnalyzer::new(),
671 }
672 }
673
674 pub const MIN_SAMPLES: usize = 10;
676
677 pub fn update_metrics(&mut self, metrics: &PerformanceMetrics) {
678 if metrics.gpu_utilization.is_empty() {
679 return;
681 }
682 let avg_utilization =
683 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32;
684
685 self.historical_data.push_back((Instant::now(), avg_utilization));
686 if self.historical_data.len() > 10000 {
687 self.historical_data.pop_front();
688 }
689
690 self.trend_analyzer.update(avg_utilization);
691 self.seasonal_analyzer.update(SystemTime::now(), avg_utilization);
692 }
693
694 pub fn sample_count(&self) -> usize {
696 self.historical_data.len()
697 }
698
699 pub fn can_predict(&self) -> bool {
702 self.historical_data.len() >= Self::MIN_SAMPLES
703 }
704
705 pub fn predict_workload(&self, horizon: Duration) -> Result<f32> {
713 if !self.can_predict() {
714 return Err(TrustformersError::invalid_state(format!(
715 "workload prediction needs at least {} utilization samples, have {}",
716 Self::MIN_SAMPLES,
717 self.historical_data.len()
718 )));
719 }
720
721 let trend_prediction = self.trend_analyzer.predict(horizon)?;
723 let seasonal_prediction = self.seasonal_analyzer.predict(horizon)?;
724
725 let prediction = trend_prediction * 0.7 + seasonal_prediction * 0.3;
727
728 Ok(prediction.clamp(0.0, 1.0))
730 }
731}
732
733pub struct TrendAnalyzer {
737 values: VecDeque<f32>,
738 window_size: usize,
739 sample_interval: Duration,
749}
750
751impl Default for TrendAnalyzer {
752 fn default() -> Self {
753 Self::new()
754 }
755}
756
757impl TrendAnalyzer {
758 pub const MIN_SAMPLES: usize = 10;
760
761 pub fn new() -> Self {
762 Self {
763 values: VecDeque::with_capacity(100),
764 window_size: 50,
765 sample_interval: Duration::from_secs(1),
766 }
767 }
768
769 #[must_use]
775 pub fn with_sample_interval(mut self, interval: Duration) -> Self {
776 self.sample_interval = interval;
777 self
778 }
779
780 pub fn update(&mut self, value: f32) {
781 self.values.push_back(value);
782 if self.values.len() > self.window_size {
783 self.values.pop_front();
784 }
785 }
786
787 pub fn predict(&self, horizon: Duration) -> Result<f32> {
803 if self.values.len() < Self::MIN_SAMPLES {
804 return Err(TrustformersError::invalid_state(format!(
805 "trend prediction needs at least {} samples, have {}",
806 Self::MIN_SAMPLES,
807 self.values.len()
808 )));
809 }
810 if self.sample_interval.is_zero() {
811 return Err(TrustformersError::invalid_state(
812 "TrendAnalyzer::sample_interval is zero; there is no sane number of \
813 steps-ahead to convert a horizon into"
814 .to_string(),
815 ));
816 }
817
818 let values: Vec<f32> = self.values.iter().cloned().collect();
820 let n = values.len() as f32;
821
822 let x_sum = (0..values.len()).sum::<usize>() as f32;
823 let y_sum = values.iter().sum::<f32>();
824 let xy_sum = values.iter().enumerate().map(|(i, &y)| i as f32 * y).sum::<f32>();
825 let x2_sum = (0..values.len()).map(|i| (i * i) as f32).sum::<f32>();
826
827 let slope = (n * xy_sum - x_sum * y_sum) / (n * x2_sum - x_sum * x_sum);
829 let intercept = (y_sum - slope * x_sum) / n;
830
831 let steps_ahead = horizon.as_secs_f32() / self.sample_interval.as_secs_f32();
834 let target_x = (values.len() - 1) as f32 + steps_ahead;
835 let prediction = slope * target_x + intercept;
836
837 Ok(prediction)
838 }
839}
840
841pub struct SeasonalAnalyzer {
848 hourly_patterns: HashMap<u32, Vec<f32>>, last_update: Option<SystemTime>,
850}
851
852impl Default for SeasonalAnalyzer {
853 fn default() -> Self {
854 Self::new()
855 }
856}
857
858impl SeasonalAnalyzer {
859 pub fn new() -> Self {
860 Self {
861 hourly_patterns: HashMap::new(),
862 last_update: None,
863 }
864 }
865
866 pub fn hour_of_day(at: SystemTime) -> u32 {
871 let seconds = at
872 .duration_since(SystemTime::UNIX_EPOCH)
873 .map(|elapsed| elapsed.as_secs())
874 .unwrap_or(0);
875 ((seconds / 3600) % 24) as u32
876 }
877
878 pub fn update(&mut self, at: SystemTime, value: f32) {
880 let hour = Self::hour_of_day(at);
881 let bucket = self.hourly_patterns.entry(hour).or_default();
882 bucket.push(value);
883 if bucket.len() > 100 {
885 let excess = bucket.len() - 100;
886 bucket.drain(0..excess);
887 }
888
889 self.last_update = Some(at);
890 }
891
892 pub fn predict(&self, horizon: Duration) -> Result<f32> {
903 self.predict_at(SystemTime::now() + horizon)
904 }
905
906 pub fn predict_at(&self, at: SystemTime) -> Result<f32> {
911 if self.hourly_patterns.is_empty() {
912 return Err(TrustformersError::invalid_state(
913 "seasonal prediction requires at least one recorded sample".to_string(),
914 ));
915 }
916
917 let target_hour = Self::hour_of_day(at);
918 if let Some(values) = self.hourly_patterns.get(&target_hour) {
919 if !values.is_empty() {
920 return Ok(values.iter().sum::<f32>() / values.len() as f32);
921 }
922 }
923
924 let mut total = 0.0f32;
925 let mut count = 0usize;
926 for values in self.hourly_patterns.values() {
927 total += values.iter().sum::<f32>();
928 count += values.len();
929 }
930 if count == 0 {
931 return Err(TrustformersError::invalid_state(
932 "seasonal prediction requires at least one recorded sample".to_string(),
933 ));
934 }
935 Ok(total / count as f32)
936 }
937}
938
939pub struct CostOptimizer {
941 cost_model: CostModel,
942 performance_model: PerformanceModel,
943}
944
945impl Default for CostOptimizer {
946 fn default() -> Self {
947 Self::new()
948 }
949}
950
951impl CostOptimizer {
952 pub fn new() -> Self {
953 Self {
954 cost_model: CostModel::new(),
955 performance_model: PerformanceModel::new(),
956 }
957 }
958
959 pub fn calculate_current_cost(&self, nodes: usize, metrics: &PerformanceMetrics) -> f32 {
960 self.cost_model.calculate_cost(nodes, metrics)
961 }
962
963 pub fn calculate_scale_up_cost(&self, new_nodes: usize, metrics: &PerformanceMetrics) -> f32 {
964 self.cost_model.calculate_cost(new_nodes, metrics)
965 }
966
967 pub fn calculate_scale_down_cost(&self, new_nodes: usize, metrics: &PerformanceMetrics) -> f32 {
968 self.cost_model.calculate_cost(new_nodes, metrics)
969 }
970}
971
972pub struct CostModel {
974 cost_per_node_hour: f32,
975 bandwidth_cost_factor: f32,
976}
977
978impl Default for CostModel {
979 fn default() -> Self {
980 Self::new()
981 }
982}
983
984impl CostModel {
985 pub fn new() -> Self {
986 Self {
987 cost_per_node_hour: 3.0, bandwidth_cost_factor: 0.1, }
990 }
991
992 pub fn calculate_cost(&self, nodes: usize, metrics: &PerformanceMetrics) -> f32 {
993 let compute_cost = nodes as f32 * self.cost_per_node_hour;
994 let bandwidth_cost = metrics.bandwidth_utilization * self.bandwidth_cost_factor;
995 compute_cost + bandwidth_cost
996 }
997}
998
999pub struct PerformanceModel {
1001 scaling_efficiency: f32,
1002}
1003
1004impl Default for PerformanceModel {
1005 fn default() -> Self {
1006 Self::new()
1007 }
1008}
1009
1010impl PerformanceModel {
1011 pub fn new() -> Self {
1012 Self {
1013 scaling_efficiency: 0.85, }
1015 }
1016
1017 pub fn predict_performance(&self, nodes: usize, base_throughput: f32) -> f32 {
1018 base_throughput * nodes as f32 * self.scaling_efficiency
1019 }
1020}
1021
1022pub struct SmartCheckpointManager {
1024 config: CheckpointConfig,
1025 checkpoint_history: Vec<CheckpointInfo>,
1026 compression_enabled: bool,
1027 validation_enabled: bool,
1028 differential_enabled: bool,
1029 checkpoint_dir: PathBuf,
1030 baseline_state: HashMap<String, Tensor>,
1033 differential_threshold: f32,
1035}
1036
1037#[derive(Debug, Clone)]
1038pub struct CheckpointConfig {
1039 pub base_frequency: usize,
1041 pub adaptive_frequency: bool,
1043 pub max_file_size_mb: usize,
1045 pub retention_count: usize,
1047 pub compression: bool,
1049 pub validation: bool,
1051 pub differential: bool,
1053}
1054
1055impl Default for CheckpointConfig {
1056 fn default() -> Self {
1057 Self {
1058 base_frequency: 1000,
1059 adaptive_frequency: true,
1060 max_file_size_mb: 1024, retention_count: 5,
1062 compression: true,
1063 validation: true,
1064 differential: true,
1065 }
1066 }
1067}
1068
1069#[derive(Debug, Clone)]
1070pub struct CheckpointInfo {
1071 pub step: usize,
1072 pub timestamp: SystemTime,
1073 pub file_path: PathBuf,
1074 pub file_size: usize,
1075 pub validation_passed: bool,
1076 pub is_differential: bool,
1077 pub base_checkpoint: Option<usize>, }
1079
1080impl SmartCheckpointManager {
1081 pub fn new(config: CheckpointConfig, checkpoint_dir: PathBuf) -> Result<Self> {
1082 std::fs::create_dir_all(&checkpoint_dir)?;
1083
1084 let compression_enabled = config.compression;
1085 let validation_enabled = config.validation;
1086 let differential_enabled = config.differential;
1087
1088 Ok(Self {
1089 config,
1090 checkpoint_history: Vec::new(),
1091 compression_enabled,
1092 validation_enabled,
1093 differential_enabled,
1094 checkpoint_dir,
1095 baseline_state: HashMap::new(),
1096 differential_threshold: 0.0,
1097 })
1098 }
1099
1100 pub fn with_differential_threshold(mut self, threshold: f32) -> Self {
1104 self.differential_threshold = threshold.max(0.0);
1105 self
1106 }
1107
1108 pub fn baseline_state(&self) -> &HashMap<String, Tensor> {
1110 &self.baseline_state
1111 }
1112
1113 pub fn should_checkpoint(&self, step: usize, performance_metrics: &PerformanceMetrics) -> bool {
1114 if step.is_multiple_of(self.config.base_frequency) {
1115 return true;
1116 }
1117
1118 if self.config.adaptive_frequency {
1119 self.adaptive_checkpoint_decision(step, performance_metrics)
1121 } else {
1122 false
1123 }
1124 }
1125
1126 fn adaptive_checkpoint_decision(&self, _step: usize, metrics: &PerformanceMetrics) -> bool {
1127 let avg_gpu_util =
1129 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32;
1130 let performance_variance = self.calculate_performance_variance(metrics);
1131
1132 performance_variance > 0.1 || avg_gpu_util < 0.5
1134 }
1135
1136 fn calculate_performance_variance(&self, metrics: &PerformanceMetrics) -> f32 {
1137 if metrics.gpu_utilization.is_empty() {
1138 return 0.0;
1139 }
1140
1141 let mean =
1142 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32;
1143 let variance = metrics.gpu_utilization.iter().map(|x| (x - mean).powi(2)).sum::<f32>()
1144 / metrics.gpu_utilization.len() as f32;
1145
1146 variance.sqrt()
1147 }
1148
1149 pub fn create_checkpoint(
1150 &mut self,
1151 step: usize,
1152 model_state: &HashMap<String, Tensor>,
1153 ) -> Result<CheckpointInfo> {
1154 let timestamp = SystemTime::now();
1155
1156 let is_differential = self.differential_enabled && !self.checkpoint_history.is_empty();
1158 let base_checkpoint = if is_differential {
1159 self.checkpoint_history.last().map(|c| c.step)
1160 } else {
1161 None
1162 };
1163
1164 let filename = if is_differential {
1166 let base = base_checkpoint.ok_or_else(|| {
1167 TrustformersError::invalid_state(
1168 "Base checkpoint must exist when differential checkpointing is enabled"
1169 .to_string(),
1170 )
1171 })?;
1172 format!("checkpoint_step_{}_diff_{}.ckpt", step, base)
1173 } else {
1174 format!("checkpoint_step_{}_full.ckpt", step)
1175 };
1176 let file_path = self.checkpoint_dir.join(filename);
1177
1178 let checkpoint_data = if is_differential {
1180 self.create_differential_checkpoint(model_state)?
1181 } else {
1182 self.create_full_checkpoint(model_state)?
1183 };
1184
1185 let final_data = if self.compression_enabled {
1187 self.compress_checkpoint(&checkpoint_data)?
1188 } else {
1189 checkpoint_data
1190 };
1191
1192 std::fs::write(&file_path, &final_data)?;
1194 let file_size = final_data.len();
1195
1196 let validation_passed = if self.validation_enabled {
1198 self.validate_checkpoint(&file_path)?
1199 } else {
1200 true
1201 };
1202
1203 let checkpoint_info = CheckpointInfo {
1204 step,
1205 timestamp,
1206 file_path,
1207 file_size,
1208 validation_passed,
1209 is_differential,
1210 base_checkpoint,
1211 };
1212
1213 self.checkpoint_history.push(checkpoint_info.clone());
1214
1215 self.baseline_state = model_state.clone();
1218
1219 self.cleanup_old_checkpoints()?;
1221
1222 log::info!(
1223 "checkpoint created: step {}, {:.2} MiB, {}",
1224 step,
1225 file_size as f32 / (1024.0 * 1024.0),
1226 if is_differential { "differential" } else { "full" }
1227 );
1228
1229 Ok(checkpoint_info)
1230 }
1231
1232 fn create_full_checkpoint(&self, model_state: &HashMap<String, Tensor>) -> Result<Vec<u8>> {
1238 checkpoint_format::encode_full(model_state)
1239 }
1240
1241 fn create_differential_checkpoint(
1248 &self,
1249 model_state: &HashMap<String, Tensor>,
1250 ) -> Result<Vec<u8>> {
1251 let base_step = self.checkpoint_history.last().map(|c| c.step).ok_or_else(|| {
1252 TrustformersError::invalid_state(
1253 "differential checkpointing requires a previous checkpoint".to_string(),
1254 )
1255 })?;
1256
1257 checkpoint_format::encode_differential(
1258 model_state,
1259 &self.baseline_state,
1260 base_step,
1261 self.differential_threshold,
1262 )
1263 }
1264
1265 fn compress_checkpoint(&self, data: &[u8]) -> Result<Vec<u8>> {
1267 Ok(checkpoint_format::compress(data))
1268 }
1269
1270 fn validate_checkpoint(&self, file_path: &PathBuf) -> Result<bool> {
1276 let raw = std::fs::read(file_path)?;
1277 let payload = match checkpoint_format::decompress(&raw) {
1278 Ok(payload) => payload,
1279 Err(_) => return Ok(false),
1280 };
1281
1282 if checkpoint_format::is_differential(&payload) {
1283 Ok(checkpoint_format::decode_differential(&payload, &self.baseline_state).is_ok())
1284 } else {
1285 Ok(checkpoint_format::decode_full(&payload).is_ok())
1286 }
1287 }
1288
1289 pub fn load_checkpoint(&self, step: usize) -> Result<HashMap<String, Tensor>> {
1294 let target =
1295 self.checkpoint_history
1296 .iter()
1297 .position(|info| info.step == step)
1298 .ok_or_else(|| {
1299 TrustformersError::invalid_input(format!(
1300 "no checkpoint recorded for step {step}"
1301 ))
1302 })?;
1303
1304 let mut anchor = target;
1306 while self.checkpoint_history[anchor].is_differential {
1307 if anchor == 0 {
1308 return Err(TrustformersError::invalid_state(
1309 "checkpoint history starts with a differential checkpoint; the base is gone"
1310 .to_string(),
1311 ));
1312 }
1313 anchor -= 1;
1314 }
1315
1316 let mut state = checkpoint_format::decode_full(&self.read_payload(anchor)?)?;
1317 for index in (anchor + 1)..=target {
1318 let payload = self.read_payload(index)?;
1319 let (_, next) = checkpoint_format::decode_differential(&payload, &state)?;
1320 state = next;
1321 }
1322
1323 Ok(state)
1324 }
1325
1326 fn read_payload(&self, index: usize) -> Result<Vec<u8>> {
1327 let info = self.checkpoint_history.get(index).ok_or_else(|| {
1328 TrustformersError::invalid_input(format!("checkpoint index {index} is out of range"))
1329 })?;
1330 let raw = std::fs::read(&info.file_path)?;
1331 checkpoint_format::decompress(&raw)
1332 }
1333
1334 fn cleanup_old_checkpoints(&mut self) -> Result<()> {
1340 if self.checkpoint_history.len() <= self.config.retention_count {
1341 return Ok(());
1342 }
1343
1344 let mut to_remove = self.checkpoint_history.len() - self.config.retention_count;
1345 while to_remove > 0 {
1346 let next_is_dependent =
1347 self.checkpoint_history.get(1).is_some_and(|info| info.is_differential);
1348 if next_is_dependent {
1349 log::debug!(
1350 "retaining checkpoint at step {} because later differential checkpoints \
1351 depend on it",
1352 self.checkpoint_history[0].step
1353 );
1354 break;
1355 }
1356
1357 let removed = self.checkpoint_history.remove(0);
1358 if let Err(err) = std::fs::remove_file(&removed.file_path) {
1359 log::warn!(
1360 "failed to remove old checkpoint {}: {err}",
1361 removed.file_path.display()
1362 );
1363 }
1364 to_remove -= 1;
1365 }
1366
1367 Ok(())
1368 }
1369
1370 pub fn get_latest_checkpoint(&self) -> Option<&CheckpointInfo> {
1371 self.checkpoint_history.last()
1372 }
1373
1374 pub fn get_checkpoint_history(&self) -> &[CheckpointInfo] {
1375 &self.checkpoint_history
1376 }
1377}
1378
1379pub struct PerformanceMLOptimizer {
1381 config: MLOptimizerConfig,
1382 performance_model: Arc<Mutex<MLPerformanceModel>>,
1383 optimization_history: Vec<OptimizationResult>,
1384 last_optimization: Instant,
1385}
1386
1387#[derive(Debug, Clone)]
1388pub struct MLOptimizerConfig {
1389 pub prediction_horizon: usize,
1391 pub optimization_frequency: usize,
1393 pub auto_tuning: bool,
1395 pub model_learning_rate: f32,
1397 pub feature_engineering: bool,
1399}
1400
1401impl Default for MLOptimizerConfig {
1402 fn default() -> Self {
1403 Self {
1404 prediction_horizon: 100,
1405 optimization_frequency: 50,
1406 auto_tuning: true,
1407 model_learning_rate: 0.001,
1408 feature_engineering: true,
1409 }
1410 }
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct OptimizationResult {
1415 pub timestamp: SystemTime,
1416 pub optimization_type: OptimizationType,
1417 pub performance_improvement: f32,
1427 pub parameters_changed: HashMap<String, f32>,
1428}
1429
1430#[derive(Debug, Clone)]
1431pub enum OptimizationType {
1432 BatchSizeOptimization,
1433 LearningRateScheduling,
1434 CommunicationPatternOptimization,
1435 MemoryOptimization,
1436 CompressionOptimization,
1437}
1438
1439impl PerformanceMLOptimizer {
1440 pub fn new(config: MLOptimizerConfig) -> Self {
1441 Self {
1442 config,
1443 performance_model: Arc::new(Mutex::new(MLPerformanceModel::new())),
1444 optimization_history: Vec::new(),
1445 last_optimization: Instant::now() - Duration::from_secs(120),
1447 }
1448 }
1449
1450 pub fn with_prediction_horizon(mut self, horizon: usize) -> Self {
1451 self.config.prediction_horizon = horizon;
1452 self
1453 }
1454
1455 pub fn with_optimization_frequency(mut self, frequency: usize) -> Self {
1456 self.config.optimization_frequency = frequency;
1457 self
1458 }
1459
1460 pub fn should_optimize(&self, step: usize) -> bool {
1461 step.is_multiple_of(self.config.optimization_frequency)
1462 && self.last_optimization.elapsed() > Duration::from_secs(60) }
1464
1465 pub fn optimize_performance(
1466 &mut self,
1467 current_metrics: &PerformanceMetrics,
1468 training_config: &mut DistributedConfig,
1469 ) -> Result<Vec<OptimizationResult>> {
1470 let mut optimizations = Vec::new();
1471
1472 {
1474 let mut model = self.performance_model.lock().map_err(|_| {
1475 TrustformersError::lock_error("performance model mutex poisoned".to_string())
1476 })?;
1477 model.update_training_data(current_metrics)?;
1478 }
1479
1480 if self.config.auto_tuning {
1482 if let Some(result) = self.optimize_batch_sizes(current_metrics, training_config)? {
1484 optimizations.push(result);
1485 }
1486
1487 if let Some(result) = self.optimize_compression(current_metrics, training_config)? {
1489 optimizations.push(result);
1490 }
1491
1492 if let Some(result) = self.optimize_communication(current_metrics, training_config)? {
1494 optimizations.push(result);
1495 }
1496 }
1497
1498 self.optimization_history.extend(optimizations.clone());
1499 self.last_optimization = Instant::now();
1500
1501 Ok(optimizations)
1502 }
1503
1504 pub const SLOW_INTERCONNECT_MBPS: f32 = 125.0;
1507
1508 fn optimize_batch_sizes(
1509 &self,
1510 metrics: &PerformanceMetrics,
1511 config: &mut DistributedConfig,
1512 ) -> Result<Option<OptimizationResult>> {
1513 let avg_utilization =
1514 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32;
1515 let avg_memory =
1516 metrics.memory_usage.iter().sum::<f32>() / metrics.memory_usage.len() as f32;
1517
1518 let model = self.performance_model.lock().map_err(|_| {
1520 TrustformersError::lock_error("performance model mutex poisoned".to_string())
1521 })?;
1522 let predicted_optimal_batch =
1523 model.predict_optimal_batch_size(avg_utilization, avg_memory)?;
1524
1525 let current_batch = config.dynamic_batching.initial_batch_size as f32;
1526 if !(current_batch > 0.0 && predicted_optimal_batch > 0.0) {
1527 return Ok(None);
1528 }
1529 let size_change = (predicted_optimal_batch - current_batch) / current_batch;
1530
1531 if size_change.abs() <= 0.1 {
1532 return Ok(None);
1534 }
1535
1536 let applied_batch = predicted_optimal_batch as usize;
1539 if applied_batch == 0 {
1540 return Ok(None);
1541 }
1542 config.dynamic_batching.initial_batch_size = applied_batch;
1543 let applied = applied_batch as f32;
1544
1545 let predicted = if applied > current_batch {
1551 (metrics.communication_overhead * (1.0 - current_batch / applied)).clamp(0.0, 1.0)
1552 } else {
1553 0.0
1554 };
1555
1556 let mut params_changed = HashMap::new();
1557 params_changed.insert("batch_size".to_string(), applied);
1558
1559 Ok(Some(OptimizationResult {
1560 timestamp: SystemTime::now(),
1561 optimization_type: OptimizationType::BatchSizeOptimization,
1562 performance_improvement: predicted,
1563 parameters_changed: params_changed,
1564 }))
1565 }
1566
1567 fn optimize_compression(
1576 &self,
1577 metrics: &PerformanceMetrics,
1578 config: &mut DistributedConfig,
1579 ) -> Result<Option<OptimizationResult>> {
1580 const FLOOR: f32 = 0.05;
1581 const TIGHTEN: f32 = 0.8;
1582
1583 if metrics.communication_overhead <= 0.3 {
1584 return Ok(None);
1585 }
1586
1587 let old_ratio = config.compression.target_ratio;
1588 let new_ratio = (old_ratio * TIGHTEN).max(FLOOR);
1589 if !(old_ratio.is_finite() && old_ratio > 0.0) || new_ratio >= old_ratio {
1590 return Ok(None);
1593 }
1594 config.compression.target_ratio = new_ratio;
1595
1596 let payload_reduction = 1.0 - new_ratio / old_ratio;
1597 let predicted = (metrics.communication_overhead * payload_reduction).clamp(0.0, 1.0);
1598
1599 let mut params_changed = HashMap::new();
1600 params_changed.insert("compression_ratio".to_string(), new_ratio);
1601
1602 Ok(Some(OptimizationResult {
1603 timestamp: SystemTime::now(),
1604 optimization_type: OptimizationType::CompressionOptimization,
1605 performance_improvement: predicted,
1606 parameters_changed: params_changed,
1607 }))
1608 }
1609
1610 fn optimize_communication(
1621 &self,
1622 metrics: &PerformanceMetrics,
1623 config: &mut DistributedConfig,
1624 ) -> Result<Option<OptimizationResult>> {
1625 if metrics.bandwidth_utilization >= Self::SLOW_INTERCONNECT_MBPS
1626 || config.compression.enabled
1627 {
1628 return Ok(None);
1629 }
1630
1631 config.compression.enabled = true;
1632
1633 let ratio = config.compression.target_ratio.clamp(0.0, 1.0);
1636 let predicted = (metrics.communication_overhead * (1.0 - ratio)).clamp(0.0, 1.0);
1637
1638 let mut params_changed = HashMap::new();
1639 params_changed.insert("compression_enabled".to_string(), 1.0);
1640 params_changed.insert("compression_ratio".to_string(), ratio);
1641
1642 Ok(Some(OptimizationResult {
1643 timestamp: SystemTime::now(),
1644 optimization_type: OptimizationType::CommunicationPatternOptimization,
1645 performance_improvement: predicted,
1646 parameters_changed: params_changed,
1647 }))
1648 }
1649
1650 pub fn get_optimization_history(&self) -> &[OptimizationResult] {
1651 &self.optimization_history
1652 }
1653}
1654
1655pub struct MLPerformanceModel {
1657 training_data: Vec<(Vec<f32>, f32)>, model_weights: Vec<f32>,
1659 learning_rate: f32,
1660}
1661
1662impl Default for MLPerformanceModel {
1663 fn default() -> Self {
1664 Self::new()
1665 }
1666}
1667
1668impl MLPerformanceModel {
1669 pub fn new() -> Self {
1670 Self {
1671 training_data: Vec::new(),
1672 model_weights: vec![0.5, 0.3, 0.2, 0.1], learning_rate: 0.001,
1674 }
1675 }
1676
1677 pub fn update_training_data(&mut self, metrics: &PerformanceMetrics) -> Result<()> {
1678 let features = vec![
1680 metrics.gpu_utilization.iter().sum::<f32>() / metrics.gpu_utilization.len() as f32,
1681 metrics.memory_usage.iter().sum::<f32>() / metrics.memory_usage.len() as f32,
1682 metrics.communication_overhead,
1683 metrics.bandwidth_utilization,
1684 ];
1685
1686 let target = metrics.throughput;
1687
1688 self.training_data.push((features, target));
1689
1690 if self.training_data.len() > 1000 {
1692 self.training_data.drain(0..500);
1693 }
1694
1695 if self.training_data.len() > 10 {
1697 self.update_model_weights()?;
1698 }
1699
1700 Ok(())
1701 }
1702
1703 fn update_model_weights(&mut self) -> Result<()> {
1704 if self.training_data.is_empty() {
1705 return Ok(());
1706 }
1707
1708 for (features, target) in &self.training_data {
1710 let prediction = self.predict_with_features(features)?;
1711 let error = target - prediction;
1712
1713 for i in 0..self.model_weights.len().min(features.len()) {
1715 self.model_weights[i] += self.learning_rate * error * features[i];
1716 }
1717 }
1718
1719 Ok(())
1720 }
1721
1722 pub fn predict_optimal_batch_size(
1723 &self,
1724 gpu_utilization: f32,
1725 memory_usage: f32,
1726 ) -> Result<f32> {
1727 let utilization_factor = if gpu_utilization < 0.7 {
1729 1.2
1730 } else if gpu_utilization > 0.9 {
1731 0.8
1732 } else {
1733 1.0
1734 };
1735 let memory_factor = if memory_usage > 0.9 {
1736 0.7
1737 } else if memory_usage < 0.5 {
1738 1.3
1739 } else {
1740 1.0
1741 };
1742
1743 let base_batch_size = 32.0_f32;
1744 let optimal_batch: f32 = base_batch_size * utilization_factor * memory_factor;
1745
1746 Ok(optimal_batch.clamp(8.0_f32, 256.0_f32)) }
1748
1749 fn predict_with_features(&self, features: &[f32]) -> Result<f32> {
1750 let prediction = features
1751 .iter()
1752 .zip(self.model_weights.iter())
1753 .map(|(&f, &w)| f * w)
1754 .sum::<f32>();
1755
1756 Ok(prediction.max(0.0)) }
1758}
1759
1760#[cfg(test)]
1764mod trend_analyzer_honesty_tests {
1765 use super::*;
1766
1767 fn linear_trend(analyzer: &mut TrendAnalyzer, start: f32, step: f32, count: usize) {
1768 for i in 0..count {
1769 analyzer.update(start + step * i as f32);
1770 }
1771 }
1772
1773 #[test]
1777 fn predict_below_min_samples_returns_a_structured_error_not_a_fabricated_constant() {
1778 let mut analyzer = TrendAnalyzer::new();
1779 for i in 0..(TrendAnalyzer::MIN_SAMPLES - 1) {
1780 analyzer.update(i as f32);
1781 }
1782 let result = analyzer.predict(Duration::from_secs(1));
1783 assert!(
1784 result.is_err(),
1785 "fewer than MIN_SAMPLES samples must refuse, not fabricate a value; got {result:?}"
1786 );
1787 }
1788
1789 #[test]
1790 fn predict_at_exactly_min_samples_succeeds() {
1791 let mut analyzer = TrendAnalyzer::new();
1792 linear_trend(&mut analyzer, 0.0, 0.1, TrendAnalyzer::MIN_SAMPLES);
1793 assert!(analyzer.predict(Duration::from_secs(1)).is_ok());
1794 }
1795
1796 #[test]
1803 fn predict_extrapolates_further_for_a_longer_horizon() {
1804 let mut analyzer = TrendAnalyzer::new();
1805 linear_trend(&mut analyzer, 0.0, 0.1, 20);
1806
1807 let near = analyzer.predict(Duration::from_secs(1)).expect("near prediction");
1808 let far = analyzer.predict(Duration::from_secs(100)).expect("far prediction");
1809
1810 assert!(
1811 far > near,
1812 "a longer horizon must extrapolate further along a positive trend: \
1813 near={near}, far={far}"
1814 );
1815 }
1816
1817 #[test]
1822 fn with_sample_interval_scales_the_horizon_conversion_consistently() {
1823 let mut default_interval = TrendAnalyzer::new();
1824 linear_trend(&mut default_interval, 0.0, 0.1, 20);
1825 let baseline =
1826 default_interval.predict(Duration::from_secs(5)).expect("baseline prediction");
1827
1828 let mut doubled_interval =
1829 TrendAnalyzer::new().with_sample_interval(Duration::from_secs(2));
1830 linear_trend(&mut doubled_interval, 0.0, 0.1, 20);
1831 let scaled = doubled_interval.predict(Duration::from_secs(10)).expect("scaled prediction");
1832
1833 assert!(
1834 (baseline - scaled).abs() < 1e-4,
1835 "5s at a 1s interval and 10s at a 2s interval are both \"5 steps ahead\": \
1836 baseline={baseline}, scaled={scaled}"
1837 );
1838 }
1839
1840 #[test]
1844 fn predict_with_zero_sample_interval_returns_a_structured_error() {
1845 let mut analyzer = TrendAnalyzer::new().with_sample_interval(Duration::from_secs(0));
1846 linear_trend(&mut analyzer, 0.0, 0.1, 20);
1847 assert!(analyzer.predict(Duration::from_secs(1)).is_err());
1848 }
1849
1850 fn utilization_metrics(value: f32) -> PerformanceMetrics {
1851 PerformanceMetrics {
1852 throughput: 100.0,
1853 gpu_utilization: vec![value],
1854 memory_usage: vec![0.5],
1855 communication_overhead: 0.1,
1856 compression_ratio: 1.0,
1857 bandwidth_utilization: 100.0,
1858 step_time: Duration::from_millis(10),
1859 }
1860 }
1861
1862 #[test]
1870 fn workload_predictor_predicts_differently_for_different_horizons() {
1871 let mut predictor = WorkloadPredictor::new();
1872 for i in 0..WorkloadPredictor::MIN_SAMPLES {
1873 predictor.update_metrics(&utilization_metrics(0.40 + 0.01 * i as f32));
1874 }
1875 assert!(predictor.can_predict());
1876
1877 let near = predictor
1878 .predict_workload(Duration::from_secs(1))
1879 .expect("near-horizon prediction");
1880 let far = predictor
1881 .predict_workload(Duration::from_secs(10))
1882 .expect("far-horizon prediction");
1883
1884 assert!(
1885 near < 1.0 && far < 1.0,
1886 "both predictions must stay below the clamp boundary for this to be a \
1887 meaningful comparison: near={near}, far={far}"
1888 );
1889 assert!(
1890 far > near + 0.01,
1891 "a longer horizon must predict measurably higher utilization along this \
1892 increasing trend: near={near}, far={far}"
1893 );
1894 }
1895}
1896
1897#[cfg(test)]
1898mod tests;