Skip to main content

trustformers_optim/
advanced_distributed_features.rs

1//! # Advanced Distributed Training Features
2//!
3//! This module provides cutting-edge features for distributed training that extend
4//! the enhanced distributed training framework with:
5//!
6//! - **Auto-Scaling**: Dynamic GPU allocation based on workload and performance
7//! - **Advanced Fault Recovery**: Sophisticated checkpoint management and node recovery
8//! - **Performance Optimization**: ML-based performance tuning and resource optimization
9//! - **Elastic Training**: Dynamic worker scaling during training
10//! - **Communication Optimization**: Advanced topology-aware communication patterns
11//! - **Memory Management**: Advanced memory pressure detection and optimization
12//!
13//! ## Key Features
14//!
15//! 1. **Elastic Scaling**: Automatically add/remove nodes based on workload
16//! 2. **Smart Checkpointing**: Differential checkpoints with automatic validation
17//! 3. **Performance ML**: Machine learning models for performance prediction and optimization
18//! 4. **Network Topology Optimization**: Automatic topology discovery and optimization
19//! 5. **Memory Pressure Management**: Predictive memory management with preemptive optimization
20//! 6. **Load Balancing**: Sophisticated load balancing with performance modeling
21//!
22//! ## Usage Example
23//!
24//! ```rust,no_run
25//! use trustformers_optim::{
26//!     EnhancedDistributedTrainer,
27//!     AutoScaler, AutoScalerConfig, ScalingStrategy,
28//!     PerformanceMLOptimizer, MLOptimizerConfig,
29//! };
30//! # use trustformers_optim::{AveragedAdam, DistributedConfig};
31//!
32//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
33//! // Create auto-scaling configuration
34//! let auto_scaler = AutoScaler::new(AutoScalerConfig::default())
35//!     .with_min_nodes(2)
36//!     .with_max_nodes(64)
37//!     .with_scaling_strategy(ScalingStrategy::Performance)
38//!     .with_scale_up_threshold(0.85)
39//!     .with_scale_down_threshold(0.6);
40//!
41//! // Enable ML-based performance optimization
42//! let ml_optimizer = PerformanceMLOptimizer::new(MLOptimizerConfig::default())
43//!     .with_prediction_horizon(100)
44//!     .with_optimization_frequency(50);
45//!
46//! // Advanced distributed trainer; auto-scaling and ML optimization are applied
47//! // to it independently via `update_and_scale` / `optimize_performance`
48//! # let config = DistributedConfig::new();
49//! # let optimizer = AveragedAdam::for_distributed_training();
50//! let trainer = EnhancedDistributedTrainer::new(config, optimizer)?;
51//! # let _ = (auto_scaler, ml_optimizer, trainer);
52//! # Ok(())
53//! # }
54//! ```
55
56// reason: research-stage module — reserved API/scaffolding fields and methods
57// retained intentionally for in-progress features; not yet on active call paths.
58#![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/// Auto-scaling configuration for dynamic node management
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct AutoScalerConfig {
74    /// Minimum number of nodes
75    pub min_nodes: usize,
76    /// Maximum number of nodes
77    pub max_nodes: usize,
78    /// Scaling strategy
79    pub strategy: ScalingStrategy,
80    /// Threshold for scaling up (GPU utilization %)
81    pub scale_up_threshold: f32,
82    /// Threshold for scaling down (GPU utilization %)
83    pub scale_down_threshold: f32,
84    /// Cooldown period between scaling operations
85    pub scaling_cooldown: Duration,
86    /// Enable predictive scaling
87    pub predictive_scaling: bool,
88    /// Cost optimization priority (0.0 = performance, 1.0 = cost)
89    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), // 5 minutes
101            predictive_scaling: true,
102            cost_priority: 0.3, // Slightly favor performance
103        }
104    }
105}
106
107/// Scaling strategies for auto-scaling
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub enum ScalingStrategy {
110    /// Scale based on performance metrics
111    Performance,
112    /// Scale based on queue length
113    QueueBased,
114    /// Scale based on predicted workload
115    Predictive,
116    /// Scale based on cost-performance optimization
117    CostOptimized,
118    /// Custom scaling strategy
119    Custom(String),
120}
121
122/// Something that can actually provision or terminate compute nodes on a
123/// real cluster substrate (a cloud autoscaling group, Kubernetes, Slurm, an
124/// in-house fleet manager, ...).
125///
126/// [`AutoScaler`] has no such substrate of its own: without one attached via
127/// [`AutoScaler::with_node_provider`], the execution step of a scaling
128/// decision (reached through [`AutoScaler::update_and_scale`]) returns
129/// [`TrustformersError::invalid_state`] instead of mutating
130/// [`AutoScaler::get_current_nodes`] for nodes that were never actually
131/// requested or terminated. For an explicit dry run (benchmarks, demos,
132/// tests) that wants `update_and_scale` to always succeed without a real
133/// substrate, attach [`SimulatedNodeProvider`] instead -- it is honest about
134/// being a simulation because its name says so, not because it pretends to
135/// be real.
136pub trait NodeProvider: Send + Sync {
137    /// Request `count` additional compute nodes. Returns the number that
138    /// were *actually* provisioned -- implementations must not report more
139    /// than what was genuinely started, and may return fewer than `count`
140    /// if capacity is limited.
141    fn provision_nodes(&self, count: usize) -> Result<usize>;
142
143    /// Terminate `count` compute nodes. Returns the number that were
144    /// *actually* terminated.
145    fn terminate_nodes(&self, count: usize) -> Result<usize>;
146}
147
148/// A [`NodeProvider`] that does not talk to any real cluster substrate: it
149/// simply reports every requested node as provisioned/terminated.
150///
151/// Exists so callers that want to exercise [`AutoScaler`]'s scaling
152/// *decisions* end to end (benchmarks, demos, tests) can opt into that
153/// explicitly, instead of [`AutoScaler`] silently fabricating success with
154/// no substrate attached at all. Never attach this where scaling is
155/// expected to have a real effect on a real fleet.
156#[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
175/// Auto-scaler for dynamic node management
176pub 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    /// Optional real cluster substrate. `None` means this `AutoScaler` can
185    /// only compute scaling *decisions* -- see [`NodeProvider`].
186    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    /// Attach a [`NodeProvider`] so scaling decisions can act on a real
204    /// cluster substrate (or an explicit [`SimulatedNodeProvider`]) instead
205    /// of `update_and_scale` returning [`TrustformersError::invalid_state`]
206    /// whenever it decides to scale up or down.
207    #[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    /// Builder pattern for configuration
214    pub fn with_min_nodes(mut self, min_nodes: usize) -> Self {
215        self.config.min_nodes = min_nodes;
216        // Also update current_nodes if it's below the new minimum
217        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    /// Update performance metrics, decide on scaling, and -- when the
244    /// decision is to scale up or down -- execute it. Executing needs a
245    /// [`NodeProvider`] (see [`Self::with_node_provider`]); with none
246    /// attached this returns [`TrustformersError::invalid_state`] whenever
247    /// the decision is [`ScalingDecision::ScaleUp`]/[`ScalingDecision::ScaleDown`]
248    /// rather than silently deciding without acting, or acting without a
249    /// real substrate. [`ScalingDecision::NoAction`] never needs a provider.
250    pub fn update_and_scale(&mut self, metrics: &PerformanceMetrics) -> Result<ScalingDecision> {
251        // Add metrics to history
252        self.performance_history.push_back(metrics.clone());
253        if self.performance_history.len() > 1000 {
254            self.performance_history.pop_front();
255        }
256
257        // Check cooldown period
258        if self.last_scaling_action.elapsed() < self.config.scaling_cooldown {
259            return Ok(ScalingDecision::NoAction);
260        }
261
262        // Analyze current performance
263        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        // Make scaling decision based on strategy. Each strategy fn returns
269        // the REAL reason it decided what it decided, alongside the
270        // decision itself -- `execute_scale_up`/`execute_scale_down` record
271        // that reason verbatim into `ScalingEvent::reason` rather than a
272        // constant that only happens to be accurate for one strategy.
273        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        // Execute scaling decision
284        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    /// `reason` (the second element of the returned tuple) is only ever read
298    /// for the `ScaleUp`/`ScaleDown` variants -- it is genuinely unused for
299    /// `NoAction` and left empty there rather than describing a decision
300    /// that was not made.
301    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            // Calculate number of nodes to add based on utilization
306            let target_utilization = 0.75; // Target 75% utilization
307            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            // Calculate number of nodes to remove
322            let target_utilization = 0.8; // Target 80% utilization when scaling down
323            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        // Simplified queue-based scaling (would integrate with actual queue metrics)
348        let throughput_ratio = metrics.throughput / 1000.0; // Assume baseline 1000 samples/sec
349
350        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        // Update workload predictor
382        self.workload_predictor.update_metrics(metrics);
383
384        // Without enough history there is no prediction to make. Falling back
385        // to the *measured* utilization keeps the decision grounded in real
386        // data; inventing a "conservative 0.75" would fabricate the input the
387        // whole branch is about to act on.
388        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        // Get prediction for next 10 minutes
402        let predicted_load = self.workload_predictor.predict_workload(Duration::from_secs(600))?;
403
404        // Utilization the cluster is sized for. Both branches solve the same
405        // equation — `nodes * target = load * current_nodes` — so the sizing is
406        // derived from the configuration rather than from magic constants.
407        let target = self.config.scale_up_threshold.clamp(0.05, 1.0);
408
409        // Make scaling decision based on prediction
410        if predicted_load > self.config.scale_up_threshold * 1.1 && // Add 10% buffer
411           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 && // Add 10% buffer
427                  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        // Calculate cost-performance ratio
458        let current_cost = self.cost_optimizer.calculate_current_cost(self.current_nodes, metrics);
459
460        // Evaluate scale up cost-benefit
461        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                // At least 10% savings
488                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    /// Dispatch a caller-named scaling strategy.
506    ///
507    /// # Errors
508    ///
509    /// Always. [`ScalingStrategy::Custom`] names a policy this crate does not
510    /// implement and has no callback for; answering
511    /// [`ScalingDecision::NoAction`] would be indistinguishable from a policy
512    /// that ran and decided to do nothing.
513    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    /// Requires a [`NodeProvider`] (see [`Self::with_node_provider`]): this
526    /// `AutoScaler` has no cluster substrate of its own to request new
527    /// nodes from. Without one, returns
528    /// [`TrustformersError::invalid_state`] instead of reporting nodes as
529    /// added that were never requested. `current_nodes` and
530    /// `scaling_history` are updated with exactly the number of nodes the
531    /// provider actually reports provisioning, even when that falls short
532    /// of `nodes` (in which case this still returns an error, but
533    /// `get_current_nodes`/`get_scaling_history` reflect the real partial
534    /// result rather than either the request or nothing at all). `reason`
535    /// is recorded into the resulting `ScalingEvent` verbatim -- it must be
536    /// the real trigger the caller's configured [`ScalingStrategy`] computed
537    /// (see each strategy method's own reason string), never a constant
538    /// that only happens to describe [`ScalingStrategy::Performance`].
539    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    /// Requires a [`NodeProvider`] (see [`Self::with_node_provider`]): this
575    /// `AutoScaler` has no cluster substrate of its own to terminate real
576    /// nodes on. Without one, returns [`TrustformersError::invalid_state`]
577    /// instead of reporting nodes as removed that were never terminated.
578    /// `current_nodes` and `scaling_history` are updated with exactly the
579    /// number of nodes the provider actually reports terminating, even when
580    /// that falls short of `nodes`. `reason` is recorded into the resulting
581    /// `ScalingEvent` verbatim -- see [`Self::execute_scale_up`]'s doc
582    /// comment for why this must be the real, strategy-specific trigger.
583    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/// Scaling decision types
630#[derive(Debug, Clone)]
631pub enum ScalingDecision {
632    ScaleUp(usize),
633    ScaleDown(usize),
634    NoAction,
635}
636
637/// Scaling event for tracking scaling history
638#[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
652/// Workload predictor using simple ML models
653pub struct WorkloadPredictor {
654    historical_data: VecDeque<(Instant, f32)>, // (timestamp, utilization)
655    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    /// Samples required before a prediction is meaningful.
675    pub const MIN_SAMPLES: usize = 10;
676
677    pub fn update_metrics(&mut self, metrics: &PerformanceMetrics) {
678        if metrics.gpu_utilization.is_empty() {
679            // No telemetry was recorded; there is nothing to learn from.
680            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    /// Number of utilization samples observed so far.
695    pub fn sample_count(&self) -> usize {
696        self.historical_data.len()
697    }
698
699    /// Whether enough history has accumulated for
700    /// [`WorkloadPredictor::predict_workload`] to answer.
701    pub fn can_predict(&self) -> bool {
702        self.historical_data.len() >= Self::MIN_SAMPLES
703    }
704
705    /// Predict mean GPU utilization `horizon` from now.
706    ///
707    /// # Errors
708    ///
709    /// When fewer than [`WorkloadPredictor::MIN_SAMPLES`] samples have been
710    /// recorded. Earlier revisions returned a hard-coded `0.75` here, which the
711    /// auto-scaler then acted on as though it were a measurement.
712    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        // Simple prediction combining trend and seasonal components
722        let trend_prediction = self.trend_analyzer.predict(horizon)?;
723        let seasonal_prediction = self.seasonal_analyzer.predict(horizon)?;
724
725        // Weighted combination
726        let prediction = trend_prediction * 0.7 + seasonal_prediction * 0.3;
727
728        // Clamp to reasonable bounds
729        Ok(prediction.clamp(0.0, 1.0))
730    }
731}
732
733/// Simple trend analyzer: fits a line to the most recent `update()`d values
734/// (by their position in the window, not wall-clock time -- see
735/// `sample_interval`) and extrapolates it forward.
736pub struct TrendAnalyzer {
737    values: VecDeque<f32>,
738    window_size: usize,
739    /// The cadence [`Self::update`] is assumed to be called at, used to
740    /// convert a [`Self::predict`] horizon into a number of window
741    /// positions: `steps_ahead = horizon / sample_interval`. This is a
742    /// documented assumption, not a measurement -- `update()` takes no
743    /// timestamp, so there is no real per-sample cadence to observe without
744    /// changing that signature. Configure it with
745    /// [`Self::with_sample_interval`] when the real cadence is known (e.g.
746    /// the caller's monitoring-loop period); the default is a generic
747    /// once-per-second assumption.
748    sample_interval: Duration,
749}
750
751impl Default for TrendAnalyzer {
752    fn default() -> Self {
753        Self::new()
754    }
755}
756
757impl TrendAnalyzer {
758    /// Samples required before [`Self::predict`] has a trend to report.
759    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    /// Sets the assumed cadence [`Self::update`] is called at (see the
770    /// field doc on `Self::sample_interval`). Panics-free for any
771    /// positive `Duration`; a zero interval is rejected by
772    /// [`Self::predict`] instead (there is no sane "steps per zero
773    /// seconds" conversion).
774    #[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    /// Linear-regression trend extrapolation `horizon` into the future.
788    ///
789    /// Fits `value ~ slope * index + intercept` over the retained window
790    /// (`index` is each sample's position in the window, oldest = 0) and
791    /// extrapolates to `index = (window_len - 1) + horizon / sample_interval`
792    /// -- one window position per `Self::sample_interval`, so a longer
793    /// horizon produces a genuinely different prediction instead of always
794    /// predicting "the next sample" regardless of how far ahead the caller
795    /// asked for.
796    ///
797    /// # Errors
798    ///
799    /// When fewer than [`Self::MIN_SAMPLES`] samples have been recorded, or
800    /// `sample_interval` is zero -- there is no honest trend, or no honest
801    /// horizon conversion, to report in either case.
802    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        // Simple linear trend calculation
819        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        // Linear regression slope
828        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        // Extrapolate `horizon` past the most recent sample, in units of
832        // `sample_interval`-sized steps.
833        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
841/// Utilization aggregated by UTC hour-of-day.
842///
843/// Samples are bucketed by the wall-clock hour they were observed at, so a
844/// prediction for a future time reads the bucket that time falls in. An earlier
845/// revision derived the bucket from `Instant::elapsed()`, which measures the
846/// *age* of the sample and therefore put every observation in bucket 0.
847pub struct SeasonalAnalyzer {
848    hourly_patterns: HashMap<u32, Vec<f32>>, // UTC hour-of-day -> values
849    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    /// UTC hour-of-day (`0..24`) that `at` falls in.
867    ///
868    /// Times before the Unix epoch are clamped to hour 0; this crate carries no
869    /// calendar dependency, and hour-of-day needs none.
870    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    /// Record `value` in the bucket for the UTC hour `at` falls in.
879    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        // Bounded history: keep the most recent 100 samples for this hour.
884        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    /// Predicted utilization `horizon` from now.
893    ///
894    /// Reads the bucket for the UTC hour that `now + horizon` falls in. When
895    /// that hour has no samples yet the mean over every recorded hour is
896    /// returned instead — still a measurement, just a coarser one.
897    ///
898    /// # Errors
899    ///
900    /// When nothing has been recorded at all. There is no honest number to
901    /// return in that case.
902    pub fn predict(&self, horizon: Duration) -> Result<f32> {
903        self.predict_at(SystemTime::now() + horizon)
904    }
905
906    /// Predicted utilization for the UTC hour that `at` falls in.
907    ///
908    /// The absolute-time form of [`SeasonalAnalyzer::predict`]; taking the
909    /// instant explicitly makes the bucket selection reproducible.
910    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
939/// Cost optimizer for cost-performance trade-offs
940pub 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
972/// Simple cost model
973pub 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,    // $3 per GPU hour
988            bandwidth_cost_factor: 0.1, // $0.1 per GB
989        }
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
999/// Simple performance model
1000pub 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, // 85% scaling efficiency
1014        }
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
1022/// Smart checkpoint manager with differential checkpointing
1023pub 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    /// Model state as of the most recent checkpoint; the baseline that
1031    /// differential checkpoints are diffed against.
1032    baseline_state: HashMap<String, Tensor>,
1033    /// Absolute change below which an element is considered unchanged.
1034    differential_threshold: f32,
1035}
1036
1037#[derive(Debug, Clone)]
1038pub struct CheckpointConfig {
1039    /// Base checkpoint frequency (steps)
1040    pub base_frequency: usize,
1041    /// Enable adaptive frequency based on performance
1042    pub adaptive_frequency: bool,
1043    /// Maximum checkpoint file size (MB)
1044    pub max_file_size_mb: usize,
1045    /// Number of checkpoints to retain
1046    pub retention_count: usize,
1047    /// Enable checkpoint compression
1048    pub compression: bool,
1049    /// Enable checkpoint validation
1050    pub validation: bool,
1051    /// Enable differential checkpointing
1052    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, // 1GB
1061            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>, // For differential checkpoints
1078}
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    /// Set the absolute change below which an element is treated as unchanged
1101    /// by differential checkpointing. The default, `0.0`, records every element
1102    /// that differs at all (lossless).
1103    pub fn with_differential_threshold(mut self, threshold: f32) -> Self {
1104        self.differential_threshold = threshold.max(0.0);
1105        self
1106    }
1107
1108    /// Model state the next differential checkpoint will be diffed against.
1109    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            // Adaptive checkpointing based on performance trends
1120            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        // Checkpoint more frequently during unstable training
1128        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        // High variance or low utilization suggests potential instability
1133        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        // Determine checkpoint type
1157        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        // Create checkpoint file path
1165        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        // Create checkpoint data
1179        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        // Compress if enabled
1186        let final_data = if self.compression_enabled {
1187            self.compress_checkpoint(&checkpoint_data)?
1188        } else {
1189            checkpoint_data
1190        };
1191
1192        // Write checkpoint file
1193        std::fs::write(&file_path, &final_data)?;
1194        let file_size = final_data.len();
1195
1196        // Validate checkpoint if enabled
1197        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        // The state just written becomes the baseline for the next differential
1216        // checkpoint.
1217        self.baseline_state = model_state.clone();
1218
1219        // Cleanup old checkpoints
1220        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    /// Serialize the complete model state.
1233    ///
1234    /// Tensor payloads are IEEE-754 `f32` little-endian bytes, so a
1235    /// save→load round trip is bit-identical. See
1236    /// [`checkpoint_format`](self::checkpoint_format) for the layout.
1237    fn create_full_checkpoint(&self, model_state: &HashMap<String, Tensor>) -> Result<Vec<u8>> {
1238        checkpoint_format::encode_full(model_state)
1239    }
1240
1241    /// Serialize only the elements that changed since the previous checkpoint.
1242    ///
1243    /// The baseline is the state captured at the last successful
1244    /// [`SmartCheckpointManager::create_checkpoint`]. Elements whose absolute
1245    /// change does not exceed [`SmartCheckpointManager::differential_threshold`]
1246    /// are omitted entirely.
1247    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    /// Losslessly compress a serialized checkpoint (zero-run-length encoding).
1266    fn compress_checkpoint(&self, data: &[u8]) -> Result<Vec<u8>> {
1267        Ok(checkpoint_format::compress(data))
1268    }
1269
1270    /// Validate a checkpoint by fully parsing it back, not by looking at its
1271    /// size.
1272    ///
1273    /// Differential checkpoints are validated against the manager's baseline
1274    /// state, which is exactly what a restore would use.
1275    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    /// Restore the model state recorded at `step`.
1290    ///
1291    /// Differential checkpoints are replayed on top of the nearest preceding
1292    /// full checkpoint, so any step in the retained history can be restored.
1293    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        // Walk back to the most recent full checkpoint.
1305        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    /// Drop the oldest checkpoints beyond the retention count.
1335    ///
1336    /// A full checkpoint is never dropped while a differential checkpoint still
1337    /// depends on it, because doing so would make every dependent checkpoint
1338    /// unrestorable.
1339    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
1379/// Performance ML optimizer using machine learning for performance optimization
1380pub 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    /// Prediction horizon (steps)
1390    pub prediction_horizon: usize,
1391    /// Optimization frequency (steps)
1392    pub optimization_frequency: usize,
1393    /// Enable automatic parameter tuning
1394    pub auto_tuning: bool,
1395    /// Learning rate for ML model updates
1396    pub model_learning_rate: f32,
1397    /// Enable advanced feature engineering
1398    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    /// Fraction of step time this change is *predicted* to save, derived from
1418    /// the metrics that were measured and the parameter change that was
1419    /// actually applied.
1420    ///
1421    /// This is a prediction, never an observation: the change has not run yet
1422    /// when the result is produced. It is always a function of
1423    /// [`PerformanceMetrics`] and the applied delta — never a constant.
1424    /// Compare consecutive [`PerformanceMetrics::step_time`] samples for the
1425    /// realised effect.
1426    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            // Initialize to a time in the past so first optimization can run immediately
1446            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) // At least 1 minute between optimizations
1463    }
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        // Update ML model with current metrics
1473        {
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        // Perform different types of optimizations
1481        if self.config.auto_tuning {
1482            // Batch size optimization
1483            if let Some(result) = self.optimize_batch_sizes(current_metrics, training_config)? {
1484                optimizations.push(result);
1485            }
1486
1487            // Compression optimization
1488            if let Some(result) = self.optimize_compression(current_metrics, training_config)? {
1489                optimizations.push(result);
1490            }
1491
1492            // Communication pattern optimization
1493            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    /// Interconnect bandwidth, in MB/s, below which gradient compression is
1505    /// worth enabling: roughly a single saturated 1 GbE link.
1506    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        // Predict optimal batch size based on utilization and memory
1519        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            // Less than a 10% change is not worth disturbing the schedule for.
1533            return Ok(None);
1534        }
1535
1536        // The batch size is an integer; derive the prediction from the value
1537        // that is actually written, not from the un-truncated estimate.
1538        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        // A larger batch amortizes the fixed per-step collective over more
1546        // samples, so it removes `1 - current/new` of the communication phase.
1547        // A *smaller* batch is chosen to relieve memory pressure and predicts no
1548        // step-time saving at all — reporting the raw size delta as a
1549        // "performance improvement" would invert the sign of a slowdown.
1550        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    /// Tighten the gradient-compression ratio when communication dominates the
1568    /// step.
1569    ///
1570    /// The predicted saving follows directly from the change that is applied:
1571    /// transferred bytes scale with the target ratio, so shrinking it from
1572    /// `old` to `new` removes `1 - new/old` of the communication time, and
1573    /// communication is [`PerformanceMetrics::communication_overhead`] of the
1574    /// step. No constant is invented.
1575    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            // Already at the floor: there is nothing left to tighten, so there
1591            // is no optimization to report.
1592            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    /// Turn gradient compression on when the interconnect is the bottleneck.
1611    ///
1612    /// This is the only communication knob [`DistributedConfig`] exposes — it
1613    /// carries no topology, bucket-size or overlap setting — so when
1614    /// compression is already enabled there is nothing to change and the
1615    /// function reports no optimization rather than an imagined one.
1616    ///
1617    /// `bandwidth_utilization` is a measured MB/s figure; below
1618    /// [`SLOW_INTERCONNECT_MBPS`](Self::SLOW_INTERCONNECT_MBPS) the link is
1619    /// slower than a single 1 GbE hop and compression pays for itself.
1620    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        // Enabling compression removes `1 - target_ratio` of the transferred
1634        // bytes from a phase that takes `communication_overhead` of the step.
1635        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
1655/// Simple ML performance model
1656pub struct MLPerformanceModel {
1657    training_data: Vec<(Vec<f32>, f32)>, // (features, target)
1658    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], // Simple linear model weights
1673            learning_rate: 0.001,
1674        }
1675    }
1676
1677    pub fn update_training_data(&mut self, metrics: &PerformanceMetrics) -> Result<()> {
1678        // Extract features from metrics
1679        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        // Keep only recent training data
1691        if self.training_data.len() > 1000 {
1692            self.training_data.drain(0..500);
1693        }
1694
1695        // Simple online learning update
1696        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        // Simple gradient descent update
1709        for (features, target) in &self.training_data {
1710            let prediction = self.predict_with_features(features)?;
1711            let error = target - prediction;
1712
1713            // Update weights
1714            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        // Simple heuristic for batch size prediction
1728        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)) // Clamp to reasonable range
1747    }
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)) // Ensure non-negative prediction
1757    }
1758}
1759
1760// Kept as a small, separate inline module (rather than appended to the
1761// larger split-out `mod tests` below) so this honesty-regression coverage
1762// stays entirely inside the file it tests.
1763#[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    /// Regression: `predict` used to return `Ok(0.75)` for fewer than 10
1774    /// samples -- a plausible-looking utilization figure that was not
1775    /// measured from anything. It must now refuse with a structured error.
1776    #[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    /// Regression: `predict` used to ignore its `horizon` argument entirely
1797    /// (always extrapolating exactly one sample ahead). A longer horizon
1798    /// must now produce a genuinely different -- for a positive trend,
1799    /// strictly larger -- prediction than a shorter one, fitted from the
1800    /// SAME retained samples (no `update()` between the two `predict()`
1801    /// calls, so only the horizon differs).
1802    #[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    /// `with_sample_interval` is the documented conversion from a horizon to
1818    /// "steps ahead"; doubling the interval and doubling the horizon must
1819    /// land on the same number of steps ahead, and therefore the same
1820    /// prediction.
1821    #[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    /// A zero `sample_interval` has no honest "steps per zero seconds"
1841    /// conversion; it must refuse rather than divide by zero into an
1842    /// infinite or NaN prediction.
1843    #[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    /// End-to-end: `WorkloadPredictor::predict_workload` combines the trend
1863    /// (70%) and seasonal (30%) components and clamps to `[0, 1]`. This
1864    /// confirms the trend fix survives that combination -- a realistic,
1865    /// gently increasing utilization series (values stay within `[0, 1]`,
1866    /// unlike the aggressive `0.0..1.9` series the direct `TrendAnalyzer`
1867    /// tests above use) predicts a measurably different, still-unclamped
1868    /// utilization for a longer horizon than a shorter one.
1869    #[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;