Skip to main content

trustformers_optim/
cpu_offload.rs

1//! # CPU-Offloaded Optimizers
2//!
3//! This module provides CPU-offloaded versions of optimizers for memory efficiency.
4//! When training very large models, optimizer states can consume significant GPU memory.
5//! CPU offloading moves optimizer states to system RAM, reducing GPU memory usage
6//! at the cost of some performance overhead.
7//!
8//! ## Benefits
9//! - Reduces GPU memory usage by 50-75%
10//! - Enables training of larger models on limited GPU memory
11//! - Maintains numerical accuracy
12//!
13//! ## Trade-offs
14//! - Adds CPU-GPU transfer overhead (5-15% performance impact)
15//! - Requires sufficient system RAM
16//! - May create CPU bottlenecks with very fast GPUs
17
18// reason: research-stage module — reserved API/scaffolding fields and methods
19// retained intentionally for in-progress features; not yet on active call paths.
20#![allow(dead_code)]
21
22use crate::StatefulOptimizer;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use trustformers_core::errors::Result;
26use trustformers_core::tensor::Tensor;
27use trustformers_core::traits::Optimizer;
28
29/// Configuration for CPU offloading behavior.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CPUOffloadConfig {
32    /// Whether to offload optimizer states to CPU
33    pub offload_optimizer_states: bool,
34    /// Whether to offload gradients to CPU during backward pass
35    pub offload_gradients: bool,
36    /// Whether to offload parameters when not in use
37    pub offload_parameters: bool,
38    /// Overlap CPU-GPU transfers with computation
39    pub overlap_transfers: bool,
40    /// Pin CPU memory for faster transfers
41    pub pin_memory: bool,
42    /// Threshold for tensor size to offload (bytes)
43    pub offload_threshold: usize,
44}
45
46impl Default for CPUOffloadConfig {
47    fn default() -> Self {
48        Self {
49            offload_optimizer_states: true,
50            offload_gradients: false,
51            offload_parameters: false,
52            overlap_transfers: true,
53            pin_memory: true,
54            offload_threshold: 1024 * 1024, // 1MB
55        }
56    }
57}
58
59/// A wrapper that enables CPU offloading for any optimizer.
60pub struct CPUOffloadedOptimizer<T: Optimizer> {
61    base_optimizer: T,
62    config: CPUOffloadConfig,
63    cpu_states: HashMap<String, Tensor>,
64    gpu_states: HashMap<String, Tensor>,
65    transfer_stream: Option<usize>, // Stream ID for async transfers
66    memory_stats: CPUOffloadStats,
67}
68
69#[derive(Debug, Default)]
70pub struct CPUOffloadStats {
71    pub total_cpu_memory_bytes: usize,
72    pub total_gpu_memory_bytes: usize,
73    pub transfers_to_cpu: usize,
74    pub transfers_to_gpu: usize,
75    pub transfer_time_ms: f64,
76}
77
78impl<T: Optimizer + StatefulOptimizer> CPUOffloadedOptimizer<T> {
79    /// Creates a new CPU-offloaded optimizer wrapper.
80    pub fn new(base_optimizer: T, config: CPUOffloadConfig) -> Self {
81        Self {
82            base_optimizer,
83            config,
84            cpu_states: HashMap::new(),
85            gpu_states: HashMap::new(),
86            transfer_stream: None,
87            memory_stats: CPUOffloadStats::default(),
88        }
89    }
90
91    /// Creates a CPU-offloaded optimizer with default configuration.
92    pub fn with_default_config(base_optimizer: T) -> Self {
93        Self::new(base_optimizer, CPUOffloadConfig::default())
94    }
95
96    /// Get the current memory statistics.
97    pub fn get_memory_stats(&self) -> &CPUOffloadStats {
98        &self.memory_stats
99    }
100
101    /// Get the total memory savings (GPU memory freed).
102    pub fn get_memory_savings_bytes(&self) -> usize {
103        self.memory_stats.total_cpu_memory_bytes
104    }
105
106    /// Get the memory savings as a percentage of total optimizer memory.
107    pub fn get_memory_savings_percent(&self) -> f32 {
108        let total_memory =
109            self.memory_stats.total_cpu_memory_bytes + self.memory_stats.total_gpu_memory_bytes;
110        if total_memory == 0 {
111            0.0
112        } else {
113            (self.memory_stats.total_cpu_memory_bytes as f32 / total_memory as f32) * 100.0
114        }
115    }
116
117    /// Offload a tensor to CPU memory.
118    fn offload_to_cpu(&mut self, key: &str, tensor: Tensor) -> Result<()> {
119        if tensor.size_bytes() >= self.config.offload_threshold {
120            let start_time = std::time::Instant::now();
121
122            // Move tensor to CPU
123            let cpu_tensor = tensor.to_device("cpu")?;
124            self.cpu_states.insert(key.to_string(), cpu_tensor);
125
126            // Update statistics
127            self.memory_stats.total_cpu_memory_bytes += tensor.size_bytes();
128            self.memory_stats.transfers_to_cpu += 1;
129            self.memory_stats.transfer_time_ms += start_time.elapsed().as_secs_f64() * 1000.0;
130
131            // Remove from GPU if it was there
132            if let Some(gpu_tensor) = self.gpu_states.remove(key) {
133                self.memory_stats.total_gpu_memory_bytes -= gpu_tensor.size_bytes();
134            }
135        } else {
136            // Keep small tensors on GPU
137            self.memory_stats.total_gpu_memory_bytes += tensor.size_bytes();
138            self.gpu_states.insert(key.to_string(), tensor);
139        }
140
141        Ok(())
142    }
143
144    /// Retrieve a tensor from CPU to GPU for computation.
145    fn retrieve_from_cpu(&mut self, key: &str, target_device: &str) -> Result<Option<Tensor>> {
146        if let Some(cpu_tensor) = self.cpu_states.get(key) {
147            let start_time = std::time::Instant::now();
148
149            // Move tensor back to GPU
150            let gpu_tensor = cpu_tensor.to_device(target_device)?;
151            let tensor_size = gpu_tensor.size_bytes();
152
153            // Cache on GPU for immediate use
154            self.gpu_states.insert(key.to_string(), gpu_tensor.clone());
155
156            // Update statistics
157            self.memory_stats.total_gpu_memory_bytes += tensor_size;
158            self.memory_stats.transfers_to_gpu += 1;
159            self.memory_stats.transfer_time_ms += start_time.elapsed().as_secs_f64() * 1000.0;
160
161            Ok(Some(gpu_tensor))
162        } else {
163            // Check if already on GPU
164            Ok(self.gpu_states.get(key).cloned())
165        }
166    }
167
168    /// Prefetch tensors that will be needed soon.
169    pub fn prefetch_states(&mut self, keys: &[String], device: &str) -> Result<()> {
170        if !self.config.overlap_transfers {
171            return Ok(());
172        }
173
174        for key in keys {
175            if self.cpu_states.contains_key(key) && !self.gpu_states.contains_key(key) {
176                // Asynchronously transfer to GPU
177                self.retrieve_from_cpu(key, device)?;
178            }
179        }
180
181        Ok(())
182    }
183
184    /// Clean up GPU cache of states that won't be needed soon.
185    pub fn evict_unused_states(&mut self, keep_keys: &[String]) -> Result<()> {
186        let mut to_remove = Vec::new();
187
188        for key in self.gpu_states.keys() {
189            if !keep_keys.contains(&key.to_string()) && self.cpu_states.contains_key(key) {
190                to_remove.push(key.clone());
191            }
192        }
193
194        for key in to_remove {
195            if let Some(tensor) = self.gpu_states.remove(&key) {
196                self.memory_stats.total_gpu_memory_bytes -= tensor.size_bytes();
197            }
198        }
199
200        Ok(())
201    }
202
203    /// Get the configuration.
204    pub fn get_config(&self) -> &CPUOffloadConfig {
205        &self.config
206    }
207
208    /// Update the configuration.
209    pub fn set_config(&mut self, config: CPUOffloadConfig) {
210        self.config = config;
211    }
212}
213
214impl<T: Optimizer> Optimizer for CPUOffloadedOptimizer<T> {
215    fn update(&mut self, parameter: &mut Tensor, grad: &Tensor) -> Result<()> {
216        self.base_optimizer.update(parameter, grad)
217    }
218
219    fn zero_grad(&mut self) {
220        self.base_optimizer.zero_grad()
221    }
222
223    fn step(&mut self) {
224        self.base_optimizer.step()
225    }
226
227    fn get_lr(&self) -> f32 {
228        self.base_optimizer.get_lr()
229    }
230
231    fn set_lr(&mut self, lr: f32) {
232        self.base_optimizer.set_lr(lr)
233    }
234}
235
236impl<T: Optimizer + StatefulOptimizer> CPUOffloadedOptimizer<T> {
237    fn state_dict(&self) -> Result<HashMap<String, Tensor>> {
238        // Combine CPU and GPU states
239        let mut state = self.base_optimizer.state_dict()?;
240
241        // Add CPU-stored states
242        for (key, tensor) in &self.cpu_states {
243            state.insert(format!("cpu_{}", key), tensor.clone());
244        }
245
246        Ok(state)
247    }
248
249    fn load_state_dict(&mut self, state: HashMap<String, Tensor>) -> Result<()> {
250        let mut base_state = HashMap::new();
251        let mut cpu_state = HashMap::new();
252
253        // Separate CPU and base optimizer states
254        for (key, tensor) in state {
255            if let Some(cpu_key) = key.strip_prefix("cpu_") {
256                cpu_state.insert(cpu_key.to_string(), tensor);
257            } else {
258                base_state.insert(key, tensor);
259            }
260        }
261
262        // Load base optimizer state
263        self.base_optimizer.load_state_dict(base_state)?;
264
265        // Load CPU states
266        self.cpu_states = cpu_state;
267
268        Ok(())
269    }
270}
271
272impl<T: Optimizer + StatefulOptimizer> CPUOffloadedOptimizer<T> {
273    /// Helper method to offload states after optimization step.
274    /// Accesses the optimizer's internal states and offloads them to CPU.
275    fn offload_states_after_step(&mut self, param_names: &[String]) -> Result<()> {
276        if !self.config.offload_optimizer_states {
277            return Ok(());
278        }
279
280        // Get the current optimizer states
281        let current_states = self.base_optimizer.state_dict()?;
282
283        // Offload states for specified parameters
284        for param_name in param_names {
285            // Look for optimizer states related to this parameter
286            for (state_key, state_tensor) in &current_states {
287                // Check if this state belongs to the current parameter
288                // Common patterns: "param_name.momentum", "param_name.variance", etc.
289                if state_key.starts_with(param_name) || state_key.contains(param_name) {
290                    // Only offload if the tensor is large enough and currently on GPU
291                    if state_tensor.size_bytes() >= self.config.offload_threshold {
292                        let device = state_tensor.device();
293
294                        // If tensor is on GPU, offload it to CPU
295                        if device.starts_with("cuda") || device.starts_with("gpu") {
296                            self.offload_to_cpu(state_key, state_tensor.clone())?;
297
298                            // Update statistics (already handled in offload_to_cpu method)
299                        }
300                    }
301                }
302            }
303
304            // Also handle any existing GPU states for this parameter
305            let keys_to_offload: Vec<String> = self
306                .gpu_states
307                .keys()
308                .filter(|key| key.starts_with(param_name) || key.contains(param_name))
309                .cloned()
310                .collect();
311
312            for key in keys_to_offload {
313                if let Some(gpu_tensor) = self.gpu_states.get(&key).cloned() {
314                    self.offload_to_cpu(&key, gpu_tensor)?;
315                }
316            }
317        }
318
319        Ok(())
320    }
321}
322
323/// Convenience function to create a CPU-offloaded Adam optimizer.
324pub fn create_cpu_offloaded_adam(
325    learning_rate: f32,
326    beta1: f32,
327    beta2: f32,
328    epsilon: f32,
329    weight_decay: f32,
330    config: Option<CPUOffloadConfig>,
331) -> CPUOffloadedOptimizer<crate::adam::Adam> {
332    let adam = crate::adam::Adam::new(learning_rate, (beta1, beta2), epsilon, weight_decay);
333    CPUOffloadedOptimizer::new(adam, config.unwrap_or_default())
334}
335
336/// Convenience function to create a CPU-offloaded AdamW optimizer.
337pub fn create_cpu_offloaded_adamw(
338    learning_rate: f32,
339    beta1: f32,
340    beta2: f32,
341    epsilon: f32,
342    weight_decay: f32,
343    config: Option<CPUOffloadConfig>,
344) -> CPUOffloadedOptimizer<crate::adam::AdamW> {
345    let adamw = crate::adam::AdamW::new(learning_rate, (beta1, beta2), epsilon, weight_decay);
346    CPUOffloadedOptimizer::new(adamw, config.unwrap_or_default())
347}
348
349/// Convenience function to create a CPU-offloaded SGD optimizer.
350pub fn create_cpu_offloaded_sgd(
351    learning_rate: f32,
352    momentum: f32,
353    _dampening: f32,
354    weight_decay: f32,
355    nesterov: bool,
356    config: Option<CPUOffloadConfig>,
357) -> CPUOffloadedOptimizer<crate::sgd::SGD> {
358    let sgd = crate::sgd::SGD::new(learning_rate, momentum, weight_decay, nesterov);
359    CPUOffloadedOptimizer::new(sgd, config.unwrap_or_default())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_cpu_offload_config_default() {
368        let config = CPUOffloadConfig::default();
369        assert!(config.offload_optimizer_states);
370        assert!(!config.offload_gradients);
371        assert!(!config.offload_parameters);
372        assert!(config.overlap_transfers);
373        assert!(config.pin_memory);
374        assert_eq!(config.offload_threshold, 1024 * 1024);
375    }
376
377    #[test]
378    fn test_memory_stats() {
379        let adam = crate::adam::Adam::new(1e-3, (0.9, 0.999), 1e-8, 0.01);
380        let optimizer = CPUOffloadedOptimizer::new(adam, CPUOffloadConfig::default());
381
382        let stats = optimizer.get_memory_stats();
383        assert_eq!(stats.total_cpu_memory_bytes, 0);
384        assert_eq!(stats.total_gpu_memory_bytes, 0);
385        assert_eq!(stats.transfers_to_cpu, 0);
386        assert_eq!(stats.transfers_to_gpu, 0);
387    }
388
389    #[test]
390    fn test_memory_savings_calculation() {
391        let adam = crate::adam::Adam::new(1e-3, (0.9, 0.999), 1e-8, 0.01);
392        let optimizer = CPUOffloadedOptimizer::new(adam, CPUOffloadConfig::default());
393
394        // With no memory allocated, savings should be 0%
395        assert_eq!(optimizer.get_memory_savings_percent(), 0.0);
396        assert_eq!(optimizer.get_memory_savings_bytes(), 0);
397    }
398
399    #[test]
400    fn test_convenience_functions() {
401        let _adam_offload = create_cpu_offloaded_adam(1e-3, 0.9, 0.999, 1e-8, 0.01, None);
402        let _adamw_offload = create_cpu_offloaded_adamw(1e-3, 0.9, 0.999, 1e-8, 0.01, None);
403        let _sgd_offload = create_cpu_offloaded_sgd(1e-2, 0.9, 0.0, 1e-4, false, None);
404
405        // Test passes if no panics occur during construction
406    }
407
408    #[test]
409    fn test_config_update() {
410        let adam = crate::adam::Adam::new(1e-3, (0.9, 0.999), 1e-8, 0.01);
411        let mut optimizer = CPUOffloadedOptimizer::new(adam, CPUOffloadConfig::default());
412
413        let new_config = CPUOffloadConfig {
414            offload_gradients: true,
415            offload_threshold: 2048,
416            ..CPUOffloadConfig::default()
417        };
418
419        optimizer.set_config(new_config.clone());
420
421        assert!(optimizer.get_config().offload_gradients);
422        assert_eq!(optimizer.get_config().offload_threshold, 2048);
423    }
424}