Skip to main content

torsh_optim/distributed/
async_sgd.rs

1//! Asynchronous SGD optimizer for distributed training
2//!
3//! This module provides an asynchronous SGD optimizer that allows workers to update
4//! parameters independently without waiting for synchronization, which can lead to
5//! faster convergence in scenarios with high communication latency.
6
7use crate::{Optimizer, OptimizerError, OptimizerResult, OptimizerState, ParamGroupState};
8use parking_lot::RwLock;
9use std::collections::HashMap;
10use std::sync::Arc;
11use torsh_core::error::{Result, TorshError};
12use torsh_tensor::Tensor;
13
14/// Asynchronous SGD optimizer for distributed training
15///
16/// This optimizer implements asynchronous SGD for distributed training where
17/// workers can update parameters independently without waiting for synchronization.
18/// This can lead to faster convergence in scenarios with high communication latency.
19pub struct AsyncSGD {
20    /// Learning rate
21    lr: f32,
22    /// Momentum factor (0 for vanilla SGD)
23    momentum: Option<f32>,
24    /// Weight decay (L2 penalty)
25    weight_decay: Option<f32>,
26    /// Dampening for momentum
27    dampening: Option<f32>,
28    /// Whether to enable Nesterov momentum
29    nesterov: bool,
30    /// Parameter tensors
31    params: Vec<Arc<RwLock<Tensor>>>,
32    /// Momentum buffers for each parameter
33    momentum_buffers: HashMap<String, Tensor>,
34    /// Asynchronous update configuration
35    async_config: AsyncConfig,
36    /// Parameter staleness tracking
37    staleness_tracker: StalenessTracker,
38}
39
40/// Configuration for asynchronous SGD
41#[derive(Debug, Clone)]
42pub struct AsyncConfig {
43    /// Maximum allowed staleness (number of updates a parameter can lag behind)
44    pub max_staleness: usize,
45    /// Staleness penalty factor (reduces effective learning rate for stale parameters)
46    pub staleness_penalty: f32,
47    /// Whether to use bounded staleness
48    pub bounded_staleness: bool,
49    /// Asynchronous update frequency (updates per synchronization)
50    pub async_frequency: usize,
51    /// Whether to enable parameter mixing
52    pub parameter_mixing: bool,
53    /// Mixing ratio for parameter averaging
54    pub mixing_ratio: f32,
55}
56
57impl Default for AsyncConfig {
58    fn default() -> Self {
59        Self {
60            max_staleness: 10,
61            staleness_penalty: 0.9,
62            bounded_staleness: true,
63            async_frequency: 1,
64            parameter_mixing: false,
65            mixing_ratio: 0.1,
66        }
67    }
68}
69
70/// Tracks parameter staleness for asynchronous updates
71#[derive(Debug)]
72struct StalenessTracker {
73    /// Global update counter
74    global_updates: u64,
75    /// Per-parameter update counters
76    parameter_updates: HashMap<String, u64>,
77    /// Staleness history for adaptive learning rates
78    staleness_history: HashMap<String, Vec<u64>>,
79}
80
81impl StalenessTracker {
82    fn new() -> Self {
83        Self {
84            global_updates: 0,
85            parameter_updates: HashMap::new(),
86            staleness_history: HashMap::new(),
87        }
88    }
89
90    /// Record a parameter update
91    fn record_update(&mut self, param_id: &str) {
92        self.global_updates += 1;
93        self.parameter_updates
94            .insert(param_id.to_string(), self.global_updates);
95
96        // Update staleness history
97        let history = self
98            .staleness_history
99            .entry(param_id.to_string())
100            .or_insert_with(Vec::new);
101        history.push(self.global_updates);
102
103        // Keep only recent history (last 100 updates)
104        if history.len() > 100 {
105            history.drain(0..history.len() - 100);
106        }
107    }
108
109    /// Get staleness for a parameter
110    fn get_staleness(&self, param_id: &str) -> u64 {
111        if let Some(&last_update) = self.parameter_updates.get(param_id) {
112            self.global_updates.saturating_sub(last_update)
113        } else {
114            self.global_updates
115        }
116    }
117
118    /// Get adaptive learning rate based on staleness
119    fn get_adaptive_lr(&self, param_id: &str, base_lr: f32, config: &AsyncConfig) -> f32 {
120        let staleness = self.get_staleness(param_id);
121
122        if staleness == 0 {
123            return base_lr;
124        }
125
126        // Apply staleness penalty
127        let penalty = config.staleness_penalty.powf(staleness as f32);
128        base_lr * penalty
129    }
130}
131
132impl AsyncSGD {
133    /// Create a new AsyncSGD optimizer
134    pub fn new(
135        params: Vec<Arc<RwLock<Tensor>>>,
136        lr: f32,
137        momentum: Option<f32>,
138        weight_decay: Option<f32>,
139        dampening: Option<f32>,
140        nesterov: bool,
141        async_config: Option<AsyncConfig>,
142    ) -> Self {
143        Self {
144            lr,
145            momentum,
146            weight_decay,
147            dampening,
148            nesterov,
149            params,
150            momentum_buffers: HashMap::new(),
151            async_config: async_config.unwrap_or_default(),
152            staleness_tracker: StalenessTracker::new(),
153        }
154    }
155
156    /// Create AsyncSGD with default asynchronous configuration
157    pub fn new_async(params: Vec<Arc<RwLock<Tensor>>>, lr: f32) -> Self {
158        Self::new(params, lr, None, None, None, false, None)
159    }
160
161    /// Perform asynchronous parameter update
162    pub fn async_step(&mut self, param_id: &str) -> Result<()> {
163        // Find the parameter by ID
164        let param_arc = self
165            .params
166            .iter()
167            .find(|p| {
168                // Use memory address as ID for now - could be improved with proper naming
169                format!("{:p}", p.as_ref()) == param_id
170            })
171            .ok_or_else(|| {
172                TorshError::InvalidArgument(format!("Parameter with ID {} not found", param_id))
173            })?;
174
175        let mut param = param_arc.write();
176        let grad = param
177            .grad()
178            .ok_or_else(|| TorshError::AutogradError("No gradient available".to_string()))?;
179
180        // Get adaptive learning rate based on staleness
181        let adaptive_lr =
182            self.staleness_tracker
183                .get_adaptive_lr(param_id, self.lr, &self.async_config);
184
185        // Check staleness bounds
186        if self.async_config.bounded_staleness {
187            let staleness = self.staleness_tracker.get_staleness(param_id);
188            if staleness > self.async_config.max_staleness as u64 {
189                // Skip update if too stale
190                return Ok(());
191            }
192        }
193
194        // Apply weight decay if specified
195        let mut effective_grad = grad.clone();
196        if let Some(decay) = self.weight_decay {
197            effective_grad = effective_grad.add(&param.mul_scalar(decay)?)?;
198        }
199
200        // Apply momentum if specified
201        if let Some(momentum) = self.momentum {
202            let param_key = format!("{:p}", param_arc.as_ref());
203
204            if let Some(buf) = self.momentum_buffers.get(&param_key) {
205                let dampening = self.dampening.unwrap_or(0.0);
206                let new_buf = buf
207                    .mul_scalar(momentum)?
208                    .add(&effective_grad.mul_scalar(1.0 - dampening)?)?;
209
210                effective_grad = if self.nesterov {
211                    effective_grad.add(&new_buf.mul_scalar(momentum)?)?
212                } else {
213                    new_buf.clone()
214                };
215
216                self.momentum_buffers.insert(param_key, new_buf);
217            } else {
218                self.momentum_buffers
219                    .insert(param_key, effective_grad.clone());
220            }
221        }
222
223        // Update parameters
224        let update = effective_grad.mul_scalar(adaptive_lr)?;
225        crate::param_update::sub_assign(&mut param, &update)?;
226
227        // Record the update
228        self.staleness_tracker.record_update(param_id);
229
230        Ok(())
231    }
232
233    /// Get staleness information for all parameters
234    pub fn staleness_info(&self) -> HashMap<String, u64> {
235        self.params
236            .iter()
237            .map(|p| {
238                let param_id = format!("{:p}", p.as_ref());
239                let staleness = self.staleness_tracker.get_staleness(&param_id);
240                (param_id, staleness)
241            })
242            .collect()
243    }
244
245    /// Get asynchronous configuration
246    pub fn async_config(&self) -> &AsyncConfig {
247        &self.async_config
248    }
249
250    /// Update asynchronous configuration
251    pub fn set_async_config(&mut self, config: AsyncConfig) {
252        self.async_config = config;
253    }
254
255    /// Perform parameter mixing (average with other workers)
256    pub fn mix_parameters(&mut self, other_params: &[Arc<RwLock<Tensor>>]) -> Result<()> {
257        if !self.async_config.parameter_mixing {
258            return Ok(());
259        }
260
261        let mixing_ratio = self.async_config.mixing_ratio;
262
263        for (param_arc, other_param_arc) in self.params.iter().zip(other_params.iter()) {
264            let mut param = param_arc.write();
265            let other_param = other_param_arc.read();
266
267            // Mix parameters: param = (1 - ratio) * param + ratio * other_param
268            let mixed = param
269                .mul_scalar(1.0 - mixing_ratio)?
270                .add(&other_param.mul_scalar(mixing_ratio)?)?;
271
272            crate::param_update::assign(&mut param, &mixed)?;
273        }
274
275        Ok(())
276    }
277}
278
279impl Optimizer for AsyncSGD {
280    fn step(&mut self) -> OptimizerResult<()> {
281        // For synchronous step, update all parameters
282        let param_ids: Vec<String> = self
283            .params
284            .iter()
285            .map(|param_arc| format!("{:p}", param_arc.as_ref()))
286            .collect();
287        for param_id in param_ids {
288            self.async_step(&param_id)?;
289        }
290        Ok(())
291    }
292
293    fn zero_grad(&mut self) {
294        for param in &self.params {
295            param.write().zero_grad();
296        }
297    }
298
299    fn get_lr(&self) -> Vec<f32> {
300        vec![self.lr]
301    }
302
303    fn set_lr(&mut self, lr: f32) {
304        self.lr = lr;
305    }
306
307    fn add_param_group(
308        &mut self,
309        params: Vec<Arc<RwLock<Tensor>>>,
310        _options: HashMap<String, f32>,
311    ) {
312        self.params.extend(params);
313    }
314
315    fn parameters(&self) -> Vec<Arc<RwLock<Tensor>>> {
316        self.params.clone()
317    }
318
319    fn state_dict(&self) -> OptimizerResult<OptimizerState> {
320        let param_group = ParamGroupState {
321            lr: self.lr,
322            options: [
323                ("momentum".to_string(), self.momentum.unwrap_or(0.0)),
324                ("weight_decay".to_string(), self.weight_decay.unwrap_or(0.0)),
325                ("dampening".to_string(), self.dampening.unwrap_or(0.0)),
326                (
327                    "nesterov".to_string(),
328                    if self.nesterov { 1.0 } else { 0.0 },
329                ),
330            ]
331            .iter()
332            .cloned()
333            .collect(),
334            param_count: self.params.len(),
335        };
336
337        // Include momentum buffers in state
338        let mut state = HashMap::new();
339        for (param_id, momentum_buffer) in &self.momentum_buffers {
340            let mut param_state = HashMap::new();
341            param_state.insert("momentum_buffer".to_string(), momentum_buffer.clone());
342            state.insert(param_id.clone(), param_state);
343        }
344
345        // Include async configuration and staleness tracking in global state
346        let mut global_state = HashMap::new();
347
348        // AsyncConfig fields
349        global_state.insert(
350            "max_staleness".to_string(),
351            self.async_config.max_staleness as f32,
352        );
353        global_state.insert(
354            "staleness_penalty".to_string(),
355            self.async_config.staleness_penalty,
356        );
357        global_state.insert(
358            "bounded_staleness".to_string(),
359            if self.async_config.bounded_staleness {
360                1.0
361            } else {
362                0.0
363            },
364        );
365        global_state.insert(
366            "async_frequency".to_string(),
367            self.async_config.async_frequency as f32,
368        );
369        global_state.insert(
370            "parameter_mixing".to_string(),
371            if self.async_config.parameter_mixing {
372                1.0
373            } else {
374                0.0
375            },
376        );
377        global_state.insert("mixing_ratio".to_string(), self.async_config.mixing_ratio);
378
379        // Staleness tracking state
380        global_state.insert(
381            "global_updates".to_string(),
382            self.staleness_tracker.global_updates as f32,
383        );
384
385        Ok(OptimizerState {
386            optimizer_type: "AsyncSGD".to_string(),
387            version: "0.1.0".to_string(),
388            param_groups: vec![param_group],
389            state,
390            global_state,
391        })
392    }
393
394    fn load_state_dict(&mut self, state: OptimizerState) -> OptimizerResult<()> {
395        // Validate optimizer type
396        if state.optimizer_type != "AsyncSGD" {
397            return Err(OptimizerError::InvalidParameter(format!(
398                "Expected AsyncSGD optimizer state, got {}",
399                state.optimizer_type
400            )));
401        }
402
403        // Load parameter group state
404        if let Some(param_group) = state.param_groups.first() {
405            self.lr = param_group.lr;
406
407            // Load hyperparameters from options
408            if let Some(&momentum) = param_group.options.get("momentum") {
409                self.momentum = if momentum > 0.0 { Some(momentum) } else { None };
410            }
411            if let Some(&weight_decay) = param_group.options.get("weight_decay") {
412                self.weight_decay = if weight_decay > 0.0 {
413                    Some(weight_decay)
414                } else {
415                    None
416                };
417            }
418            if let Some(&dampening) = param_group.options.get("dampening") {
419                self.dampening = if dampening > 0.0 {
420                    Some(dampening)
421                } else {
422                    None
423                };
424            }
425            if let Some(&nesterov) = param_group.options.get("nesterov") {
426                self.nesterov = nesterov > 0.0;
427            }
428
429            // Validate parameter count
430            if param_group.param_count != self.params.len() {
431                return Err(OptimizerError::InvalidParameter(format!(
432                    "Parameter count mismatch: expected {}, got {}",
433                    self.params.len(),
434                    param_group.param_count
435                )));
436            }
437        } else {
438            return Err(OptimizerError::InvalidParameter(
439                "No parameter groups found in state".to_string(),
440            ));
441        }
442
443        // Load momentum buffers from state
444        self.momentum_buffers.clear();
445        for (param_id, param_state) in state.state {
446            if let Some(momentum_buffer) = param_state.get("momentum_buffer") {
447                self.momentum_buffers
448                    .insert(param_id, momentum_buffer.clone());
449            }
450        }
451
452        // Load async configuration from global state
453        if let Some(&max_staleness) = state.global_state.get("max_staleness") {
454            self.async_config.max_staleness = max_staleness as usize;
455        }
456        if let Some(&staleness_penalty) = state.global_state.get("staleness_penalty") {
457            self.async_config.staleness_penalty = staleness_penalty;
458        }
459        if let Some(&bounded_staleness) = state.global_state.get("bounded_staleness") {
460            self.async_config.bounded_staleness = bounded_staleness > 0.0;
461        }
462        if let Some(&async_frequency) = state.global_state.get("async_frequency") {
463            self.async_config.async_frequency = async_frequency as usize;
464        }
465        if let Some(&parameter_mixing) = state.global_state.get("parameter_mixing") {
466            self.async_config.parameter_mixing = parameter_mixing > 0.0;
467        }
468        if let Some(&mixing_ratio) = state.global_state.get("mixing_ratio") {
469            self.async_config.mixing_ratio = mixing_ratio;
470        }
471
472        // Load staleness tracking state
473        if let Some(&global_updates) = state.global_state.get("global_updates") {
474            self.staleness_tracker.global_updates = global_updates as u64;
475        }
476
477        // Note: parameter_updates and staleness_history are reset since they're
478        // runtime tracking state that will be rebuilt during training
479        self.staleness_tracker.parameter_updates.clear();
480        self.staleness_tracker.staleness_history.clear();
481
482        Ok(())
483    }
484}
485
486/// Utility functions for asynchronous distributed training
487pub mod utils {
488    use super::*;
489
490    /// Create AsyncSGD with commonly used settings for distributed training
491    pub fn create_async_sgd_distributed(
492        params: Vec<Arc<RwLock<Tensor>>>,
493        lr: f32,
494        world_size: usize,
495    ) -> AsyncSGD {
496        let async_config = AsyncConfig {
497            max_staleness: world_size * 2, // Allow up to 2x world size staleness
498            staleness_penalty: 0.9,
499            bounded_staleness: true,
500            async_frequency: 1,
501            parameter_mixing: world_size > 4, // Enable mixing for large clusters
502            mixing_ratio: 0.1 / (world_size as f32).sqrt(), // Adaptive mixing ratio
503        };
504
505        AsyncSGD::new(
506            params,
507            lr,
508            Some(0.9),
509            Some(1e-4),
510            None,
511            false,
512            Some(async_config),
513        )
514    }
515
516    /// Synchronize AsyncSGD optimizers across workers (simplified simulation)
517    pub fn synchronize_async_sgd_workers(workers: &mut [AsyncSGD]) -> Result<()> {
518        if workers.is_empty() {
519            return Ok(());
520        }
521
522        // Simple parameter averaging across workers
523        let num_workers = workers.len();
524
525        // Get parameter references from first worker to determine structure
526        let param_count = workers[0].params.len();
527
528        // For each parameter position
529        for param_idx in 0..param_count {
530            // Collect all parameter values from all workers
531            let mut param_sum: Option<Tensor> = None;
532
533            for worker in workers.iter() {
534                if param_idx < worker.params.len() {
535                    let param = worker.params[param_idx].read();
536                    if let Some(ref mut sum) = param_sum {
537                        *sum = sum.add(&*param)?;
538                    } else {
539                        param_sum = Some(param.clone());
540                    }
541                }
542            }
543
544            // Average and distribute back to all workers
545            if let Some(sum) = param_sum {
546                let average = sum.div_scalar(num_workers as f32)?;
547
548                for worker in workers.iter_mut() {
549                    if param_idx < worker.params.len() {
550                        let mut param = worker.params[param_idx].write();
551                        crate::param_update::assign(&mut param, &average)?;
552                    }
553                }
554            }
555        }
556
557        Ok(())
558    }
559
560    /// Calculate global staleness statistics across workers
561    pub fn global_staleness_stats(workers: &[AsyncSGD]) -> HashMap<String, f32> {
562        let mut stats = HashMap::new();
563
564        if workers.is_empty() {
565            return stats;
566        }
567
568        let mut total_staleness = 0.0;
569        let mut max_staleness: f32 = 0.0;
570        let mut param_count = 0;
571
572        for worker in workers {
573            let staleness_info = worker.staleness_info();
574            for staleness in staleness_info.values() {
575                let staleness_f32 = *staleness as f32;
576                total_staleness += staleness_f32;
577                max_staleness = max_staleness.max(staleness_f32);
578                param_count += 1;
579            }
580        }
581
582        if param_count > 0 {
583            stats.insert(
584                "average_staleness".to_string(),
585                total_staleness / param_count as f32,
586            );
587            stats.insert("max_staleness".to_string(), max_staleness);
588            stats.insert("total_parameters".to_string(), param_count as f32);
589        }
590
591        stats
592    }
593}