Skip to main content

optirs_core/
memory_efficient_optimizer.rs

1//! Memory-efficient optimizer operations
2//!
3//! This module provides memory-efficient optimization for very large models
4//! through gradient accumulation, chunked processing, and memory usage estimation.
5//!
6//! # Features
7//!
8//! - Gradient accumulation to reduce memory pressure
9//! - Chunked parameter processing for large models
10//! - Memory usage estimation and recommendations
11//! - Streaming gradient computation
12//!
13//! # Performance
14//!
15//! Enables optimization of models with billions of parameters through efficient memory management.
16
17use scirs2_core::ndarray::{s, Array1, ArrayView1, Ix1, ScalarOperand};
18use scirs2_core::numeric::{Float, Zero};
19use std::fmt::Debug;
20
21use crate::error::Result;
22use crate::optimizers::Optimizer;
23use crate::utils::try_scalar;
24
25/// Gradient accumulator for memory-efficient training
26///
27/// Accumulates gradients over multiple micro-batches before applying updates,
28/// reducing memory requirements for large batch training.
29///
30/// # Examples
31///
32/// ```
33/// use scirs2_core::ndarray::Array1;
34/// use optirs_core::memory_efficient_optimizer::GradientAccumulator;
35///
36/// let mut accumulator = GradientAccumulator::<f32>::new(1000);
37///
38/// // Accumulate gradients from 4 micro-batches
39/// for _ in 0..4 {
40///     let micro_batch_grads = Array1::from_elem(1000, 0.1);
41///     accumulator.accumulate(&micro_batch_grads.view()).expect("shapes match the accumulator");
42/// }
43///
44/// // Get averaged gradients
45/// let avg_grads = accumulator.average().expect("at least one micro-batch was accumulated");
46/// ```
47pub struct GradientAccumulator<A: Float> {
48    accumulated: Array1<A>,
49    count: usize,
50}
51
52impl<A: Float + ScalarOperand + Debug + Zero> GradientAccumulator<A> {
53    /// Creates a new gradient accumulator
54    ///
55    /// # Arguments
56    ///
57    /// * `size` - Size of gradient vectors
58    pub fn new(size: usize) -> Self {
59        Self {
60            accumulated: Array1::zeros(size),
61            count: 0,
62        }
63    }
64
65    /// Accumulate a gradient vector
66    ///
67    /// # Arguments
68    ///
69    /// * `gradients` - Gradients to accumulate
70    pub fn accumulate(&mut self, gradients: &ArrayView1<A>) -> Result<()> {
71        if gradients.len() != self.accumulated.len() {
72            return Err(crate::error::OptimError::DimensionMismatch(format!(
73                "Gradient size ({}) doesn't match accumulator size ({})",
74                gradients.len(),
75                self.accumulated.len()
76            )));
77        }
78
79        self.accumulated = &self.accumulated + gradients;
80        self.count += 1;
81
82        Ok(())
83    }
84
85    /// Get the number of accumulated gradients
86    pub fn count(&self) -> usize {
87        self.count
88    }
89
90    /// Compute the average of accumulated gradients
91    ///
92    /// Returns the averaged gradients and resets the accumulator.
93    pub fn average(&mut self) -> Result<Array1<A>> {
94        if self.count == 0 {
95            return Err(crate::error::OptimError::InvalidConfig(
96                "No gradients accumulated".to_string(),
97            ));
98        }
99
100        let scale = try_scalar::<A, _>(self.count)?;
101        let averaged = &self.accumulated / scale;
102
103        // Reset accumulator
104        self.reset();
105
106        Ok(averaged)
107    }
108
109    /// Reset the accumulator
110    pub fn reset(&mut self) {
111        self.accumulated.fill(A::zero());
112        self.count = 0;
113    }
114
115    /// Check if accumulator has reached target count
116    ///
117    /// # Arguments
118    ///
119    /// * `target` - Target number of accumulations
120    pub fn is_ready(&self, target: usize) -> bool {
121        self.count >= target
122    }
123}
124
125/// Chunked optimizer for processing large parameter arrays in chunks
126///
127/// Enables optimization of very large models by processing parameters
128/// in manageable chunks, reducing peak memory usage.
129pub struct ChunkedOptimizer<O, A>
130where
131    O: Optimizer<A, Ix1> + Clone,
132    A: Float + ScalarOperand + Debug,
133{
134    base_optimizer: O,
135    chunk_size: usize,
136    _phantom: std::marker::PhantomData<A>,
137}
138
139impl<O, A> ChunkedOptimizer<O, A>
140where
141    O: Optimizer<A, Ix1> + Clone,
142    A: Float + ScalarOperand + Debug,
143{
144    /// Creates a new chunked optimizer
145    ///
146    /// # Arguments
147    ///
148    /// * `base_optimizer` - Base optimizer to use for each chunk
149    /// * `chunk_size` - Size of each chunk (default: 1M elements)
150    pub fn new(base_optimizer: O, chunk_size: Option<usize>) -> Self {
151        let chunk_size = chunk_size.unwrap_or(1_000_000);
152
153        Self {
154            base_optimizer,
155            chunk_size,
156            _phantom: std::marker::PhantomData,
157        }
158    }
159
160    /// Process parameters in chunks
161    ///
162    /// # Arguments
163    ///
164    /// * `params` - Full parameter array
165    /// * `gradients` - Full gradient array
166    ///
167    /// # Returns
168    ///
169    /// Updated parameters
170    pub fn step_chunked(&mut self, params: &Array1<A>, gradients: &Array1<A>) -> Result<Array1<A>> {
171        if params.len() != gradients.len() {
172            return Err(crate::error::OptimError::DimensionMismatch(format!(
173                "Parameters ({}) and gradients ({}) must have same size",
174                params.len(),
175                gradients.len()
176            )));
177        }
178
179        let total_size = params.len();
180        let mut updated = Array1::zeros(total_size);
181
182        // Process in chunks
183        let num_chunks = total_size.div_ceil(self.chunk_size);
184
185        for chunk_idx in 0..num_chunks {
186            let start = chunk_idx * self.chunk_size;
187            let end = (start + self.chunk_size).min(total_size);
188
189            // Extract chunk views
190            let params_chunk = params.slice(s![start..end]).to_owned();
191            let grads_chunk = gradients.slice(s![start..end]).to_owned();
192
193            // Update chunk
194            let updated_chunk = self.base_optimizer.step(&params_chunk, &grads_chunk)?;
195
196            // Copy back to result
197            updated.slice_mut(s![start..end]).assign(&updated_chunk);
198        }
199
200        Ok(updated)
201    }
202
203    /// Get the chunk size
204    pub fn chunk_size(&self) -> usize {
205        self.chunk_size
206    }
207
208    /// Calculate number of chunks for given size
209    pub fn num_chunks(&self, total_size: usize) -> usize {
210        total_size.div_ceil(self.chunk_size)
211    }
212}
213
214/// Memory usage estimator for optimizers
215///
216/// Provides utilities for estimating memory requirements and recommending
217/// optimal configurations for different optimizer types.
218pub struct MemoryUsageEstimator;
219
220impl MemoryUsageEstimator {
221    /// Estimate memory usage for SGD without momentum
222    ///
223    /// # Arguments
224    ///
225    /// * `num_params` - Number of parameters
226    /// * `dtype_size` - Size of data type in bytes (4 for f32, 8 for f64)
227    ///
228    /// # Returns
229    ///
230    /// Estimated memory usage in bytes
231    pub fn sgd(num_params: usize, dtype_size: usize) -> usize {
232        // Parameters + gradients
233        num_params * dtype_size * 2
234    }
235
236    /// Estimate memory usage for SGD with momentum
237    ///
238    /// # Arguments
239    ///
240    /// * `num_params` - Number of parameters
241    /// * `dtype_size` - Size of data type in bytes (4 for f32, 8 for f64)
242    ///
243    /// # Returns
244    ///
245    /// Estimated memory usage in bytes
246    pub fn sgd_with_momentum(num_params: usize, dtype_size: usize) -> usize {
247        // Parameters + gradients + velocity
248        num_params * dtype_size * 3
249    }
250
251    /// Estimate memory usage for Adam optimizer
252    ///
253    /// # Arguments
254    ///
255    /// * `num_params` - Number of parameters
256    /// * `dtype_size` - Size of data type in bytes (4 for f32, 8 for f64)
257    ///
258    /// # Returns
259    ///
260    /// Estimated memory usage in bytes
261    pub fn adam(num_params: usize, dtype_size: usize) -> usize {
262        // Parameters + gradients + first moment + second moment
263        num_params * dtype_size * 4
264    }
265
266    /// Recommend chunk size based on available memory
267    ///
268    /// # Arguments
269    ///
270    /// * `total_params` - Total number of parameters
271    /// * `available_memory_bytes` - Available memory in bytes
272    /// * `dtype_size` - Size of data type in bytes (4 for f32, 8 for f64)
273    /// * `optimizer_state_multiplier` - Memory multiplier for optimizer state
274    ///
275    /// # Returns
276    ///
277    /// Recommended chunk size
278    pub fn recommend_chunk_size(
279        total_params: usize,
280        available_memory_bytes: usize,
281        dtype_size: usize,
282        optimizer_state_multiplier: usize,
283    ) -> usize {
284        let memory_per_param = dtype_size * optimizer_state_multiplier;
285        let max_params = available_memory_bytes / memory_per_param;
286
287        // Use 80% of available memory to leave headroom
288        let safe_params = (max_params * 80) / 100;
289
290        safe_params.min(total_params).max(1024)
291    }
292
293    /// Get recommended accumulation steps for given batch size
294    ///
295    /// # Arguments
296    ///
297    /// * `target_batch_size` - Desired effective batch size
298    /// * `max_micro_batch_size` - Maximum micro-batch that fits in memory
299    ///
300    /// # Returns
301    ///
302    /// Number of gradient accumulation steps
303    pub fn recommend_accumulation_steps(
304        target_batch_size: usize,
305        max_micro_batch_size: usize,
306    ) -> usize {
307        target_batch_size.div_ceil(max_micro_batch_size)
308    }
309
310    /// Estimate peak memory usage during training
311    ///
312    /// # Arguments
313    ///
314    /// * `num_params` - Number of parameters
315    /// * `batch_size` - Batch size
316    /// * `sequence_length` - Sequence length (for transformers, 1 otherwise)
317    /// * `dtype_size` - Size of data type in bytes
318    /// * `optimizer_type` - Type of optimizer ("sgd", "adam", etc.)
319    ///
320    /// # Returns
321    ///
322    /// Estimated peak memory in bytes
323    pub fn estimate_peak_memory(
324        num_params: usize,
325        batch_size: usize,
326        sequence_length: usize,
327        dtype_size: usize,
328        optimizer_type: &str,
329    ) -> usize {
330        // Model parameters
331        let param_memory = num_params * dtype_size;
332
333        // Gradients
334        let grad_memory = num_params * dtype_size;
335
336        // Optimizer state
337        let optimizer_memory = match optimizer_type {
338            "sgd" => num_params * dtype_size,
339            "adam" | "adamw" => num_params * dtype_size * 2,
340            _ => num_params * dtype_size,
341        };
342
343        // Activations (rough estimate: batch_size * sequence_length * hidden_dim)
344        let hidden_dim = (num_params as f64).sqrt() as usize;
345        let activation_memory = batch_size * sequence_length * hidden_dim * dtype_size;
346
347        param_memory + grad_memory + optimizer_memory + activation_memory
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::optimizers::SGD;
355    use approx::assert_relative_eq;
356
357    #[test]
358    fn test_gradient_accumulator() {
359        let mut accumulator = GradientAccumulator::<f32>::new(100);
360
361        // Accumulate some gradients
362        let grad1 = Array1::from_elem(100, 1.0);
363        let grad2 = Array1::from_elem(100, 2.0);
364
365        accumulator
366            .accumulate(&grad1.view())
367            .expect("unwrap failed");
368        accumulator
369            .accumulate(&grad2.view())
370            .expect("unwrap failed");
371
372        assert_eq!(accumulator.count(), 2);
373        assert!(accumulator.is_ready(2));
374
375        // Get average
376        let avg = accumulator.average().expect("unwrap failed");
377        assert_relative_eq!(avg[0], 1.5, epsilon = 1e-6);
378
379        // After average, accumulator should be reset
380        assert_eq!(accumulator.count(), 0);
381    }
382
383    #[test]
384    fn test_chunked_optimizer() {
385        let optimizer = SGD::new(0.01);
386        let mut chunked_opt = ChunkedOptimizer::new(optimizer, Some(10));
387
388        let params = Array1::from_vec((0..25).map(|i| i as f32).collect());
389        let gradients = Array1::from_elem(25, 0.1);
390
391        let updated = chunked_opt
392            .step_chunked(&params, &gradients)
393            .expect("unwrap failed");
394
395        // Verify updates
396        assert_eq!(updated.len(), 25);
397        assert_relative_eq!(updated[0], 0.0 - 0.01 * 0.1, epsilon = 1e-6);
398
399        // Check number of chunks
400        assert_eq!(chunked_opt.num_chunks(25), 3);
401    }
402
403    #[test]
404    fn test_memory_estimator_sgd() {
405        // SGD for 1M parameters (f32)
406        let mem = MemoryUsageEstimator::sgd(1_000_000, 4);
407        assert_eq!(mem, 8_000_000); // 8 MB
408
409        // SGD with momentum
410        let mem = MemoryUsageEstimator::sgd_with_momentum(1_000_000, 4);
411        assert_eq!(mem, 12_000_000); // 12 MB
412    }
413
414    #[test]
415    fn test_memory_estimator_adam() {
416        // Adam for 1M parameters (f32)
417        let mem = MemoryUsageEstimator::adam(1_000_000, 4);
418        assert_eq!(mem, 16_000_000); // 16 MB
419    }
420
421    #[test]
422    fn test_recommend_chunk_size() {
423        // 1GB available, f32, Adam optimizer
424        let chunk_size = MemoryUsageEstimator::recommend_chunk_size(
425            100_000_000,   // 100M total params
426            1_000_000_000, // 1GB available
427            4,             // f32
428            4,             // Adam state multiplier
429        );
430
431        // Should be around 50M params (80% of 62.5M that fits in 1GB)
432        assert!(chunk_size > 40_000_000);
433        assert!(chunk_size < 60_000_000);
434    }
435
436    #[test]
437    fn test_recommend_accumulation_steps() {
438        let steps = MemoryUsageEstimator::recommend_accumulation_steps(128, 32);
439        assert_eq!(steps, 4);
440
441        let steps = MemoryUsageEstimator::recommend_accumulation_steps(100, 32);
442        assert_eq!(steps, 4); // Rounds up
443    }
444
445    #[test]
446    fn test_estimate_peak_memory() {
447        let peak = MemoryUsageEstimator::estimate_peak_memory(
448            10_000_000, // 10M params
449            32,         // batch size
450            512,        // sequence length
451            4,          // f32
452            "adam",
453        );
454
455        // Should be substantial (model + optimizer + activations)
456        assert!(peak > 100_000_000); // > 100MB
457    }
458}