Skip to main content

trustformers_optim/
memory_layout.rs

1//! Memory layout optimizations for improved cache performance.
2//!
3//! This module provides data structures and algorithms optimized for cache-friendly
4//! memory layouts, reducing memory bandwidth usage and improving performance through
5//! better spatial and temporal locality.
6//!
7//! # Key Optimizations
8//!
9//! - **Structure of Arrays (SoA)**: Better vectorization and cache usage
10//! - **Memory Alignment**: Ensure data aligns to cache line boundaries
11//! - **Hot/Cold Data Separation**: Keep frequently accessed data together
12//! - **Prefetch-Friendly Layouts**: Optimize for hardware prefetchers
13//! - **NUMA-Aware Allocation**: Optimize for multi-socket systems
14
15use crate::common::{BiasCorrection, ParameterUpdate};
16use std::alloc::{alloc, dealloc, Layout};
17use std::collections::HashMap;
18use std::ptr::{self, NonNull};
19use trustformers_core::errors::{Result, TrustformersError};
20use trustformers_core::tensor::Tensor;
21use trustformers_core::traits::Optimizer;
22
23/// Memory alignment configuration for optimal cache performance.
24#[derive(Debug, Clone, Copy)]
25pub struct AlignmentConfig {
26    /// Cache line size (typically 64 bytes)
27    pub cache_line_size: usize,
28    /// Vector register size (typically 32 bytes for AVX2, 64 for AVX-512)
29    pub vector_size: usize,
30    /// Page size for large allocations (typically 4KB)
31    pub page_size: usize,
32    /// Enable huge pages for large allocations
33    pub use_huge_pages: bool,
34}
35
36impl Default for AlignmentConfig {
37    fn default() -> Self {
38        Self {
39            cache_line_size: 64,
40            vector_size: 32, // AVX2
41            page_size: 4096,
42            use_huge_pages: false,
43        }
44    }
45}
46
47impl AlignmentConfig {
48    /// Creates configuration optimized for AVX-512.
49    pub fn avx512() -> Self {
50        Self {
51            vector_size: 64,
52            ..Default::default()
53        }
54    }
55
56    /// Creates configuration with huge pages enabled.
57    pub fn with_huge_pages() -> Self {
58        Self {
59            use_huge_pages: true,
60            ..Default::default()
61        }
62    }
63
64    /// Gets the alignment requirement for the given size.
65    pub fn alignment_for_size(&self, size: usize) -> usize {
66        if size >= self.page_size {
67            self.page_size
68        } else if size >= self.cache_line_size {
69            self.cache_line_size
70        } else {
71            self.vector_size.min(size)
72        }
73    }
74}
75
76/// Aligned memory allocator for cache-friendly data structures.
77#[derive(Debug)]
78pub struct AlignedAllocator {
79    config: AlignmentConfig,
80    allocated_blocks: Vec<(NonNull<u8>, Layout)>,
81}
82
83impl AlignedAllocator {
84    /// Creates a new aligned allocator.
85    pub fn new(config: AlignmentConfig) -> Self {
86        Self {
87            config,
88            allocated_blocks: Vec::new(),
89        }
90    }
91
92    /// Allocates aligned memory for the given type and count.
93    pub fn allocate_aligned<T>(&mut self, count: usize) -> Result<NonNull<T>> {
94        let size = count * std::mem::size_of::<T>();
95        let alignment = self.config.alignment_for_size(size);
96
97        let layout = Layout::from_size_align(size, alignment).map_err(|e| {
98            TrustformersError::tensor_op_error(
99                &format!("Invalid layout: {}", e),
100                "allocate_aligned",
101            )
102        })?;
103
104        let ptr = unsafe { alloc(layout) };
105        if ptr.is_null() {
106            return Err(TrustformersError::tensor_op_error(
107                "Memory allocation failed",
108                "allocate_aligned",
109            ));
110        }
111
112        let non_null = NonNull::new(ptr).ok_or_else(|| {
113            TrustformersError::tensor_op_error("Null pointer in allocation", "allocate_aligned")
114        })?;
115
116        self.allocated_blocks.push((non_null, layout));
117
118        // Cast to the target type
119        let typed_ptr = non_null.as_ptr() as *mut T;
120        NonNull::new(typed_ptr).ok_or_else(|| {
121            TrustformersError::tensor_op_error("Type casting failed", "allocate_aligned")
122        })
123    }
124
125    /// Allocates and initializes aligned memory.
126    pub fn allocate_initialized<T: Clone>(&mut self, count: usize, value: T) -> Result<NonNull<T>> {
127        let ptr = self.allocate_aligned::<T>(count)?;
128
129        unsafe {
130            for i in 0..count {
131                ptr::write(ptr.as_ptr().add(i), value.clone());
132            }
133        }
134
135        Ok(ptr)
136    }
137
138    /// Gets memory usage statistics.
139    pub fn memory_usage(&self) -> usize {
140        self.allocated_blocks.iter().map(|(_, layout)| layout.size()).sum()
141    }
142}
143
144impl Drop for AlignedAllocator {
145    fn drop(&mut self) {
146        for (ptr, layout) in &self.allocated_blocks {
147            unsafe {
148                dealloc(ptr.as_ptr(), *layout);
149            }
150        }
151    }
152}
153
154// Safety: AlignedAllocator manages owned memory allocations properly
155// and the NonNull pointers are used as owned memory handles
156unsafe impl Send for AlignedAllocator {}
157unsafe impl Sync for AlignedAllocator {}
158
159/// Structure of Arrays (SoA) layout for optimizer state.
160///
161/// Momentum and variance for *every* registered parameter live in two contiguous
162/// arrays rather than in per-parameter allocations, so a sweep over one moment
163/// touches consecutive cache lines. Each parameter owns the half-open range
164/// `[momentum_offset, momentum_offset + size)` of `SoAOptimizerState::momentum`
165/// (and likewise for the variance array); ranges are padded up to a cache-line
166/// boundary so no two parameters share a cache line.
167///
168/// The moments are **persistent**: `update_parameter_soa` reads the previous values
169/// out of these arrays and writes the new ones back. An earlier revision recomputed
170/// both from the current gradient on every call, which silently removed all of
171/// Adam's adaptivity.
172#[derive(Debug)]
173pub struct SoAOptimizerState {
174    /// Contiguous first-moment (momentum) storage shared by all parameters.
175    momentum: Vec<f32>,
176    /// Contiguous second-moment (variance) storage shared by all parameters.
177    variance: Vec<f32>,
178    /// Parameter metadata
179    parameters: Vec<ParameterInfo>,
180    /// Fast lookup from parameter id to its index in `parameters`.
181    parameter_index: HashMap<String, usize>,
182    /// Global step counter
183    step: usize,
184    /// Alignment configuration
185    alignment: AlignmentConfig,
186}
187
188/// Information about a parameter in SoA layout.
189#[derive(Debug, Clone)]
190pub struct ParameterInfo {
191    /// Parameter ID
192    pub id: String,
193    /// Starting index in momentum array
194    pub momentum_offset: usize,
195    /// Starting index in variance array
196    pub variance_offset: usize,
197    /// Number of elements
198    pub size: usize,
199    /// Cache-friendly chunk size
200    pub chunk_size: usize,
201}
202
203impl SoAOptimizerState {
204    /// Creates a new SoA optimizer state.
205    pub fn new(alignment: AlignmentConfig) -> Self {
206        Self {
207            momentum: Vec::new(),
208            variance: Vec::new(),
209            parameters: Vec::new(),
210            parameter_index: HashMap::new(),
211            step: 0,
212            alignment,
213        }
214    }
215
216    /// Number of `f32` elements per cache line, used to pad parameter blocks.
217    fn cache_line_elements(&self) -> usize {
218        (self.alignment.cache_line_size / std::mem::size_of::<f32>()).max(1)
219    }
220
221    /// Adds a parameter to the SoA layout, reserving zeroed moment storage for it.
222    ///
223    /// Registering the same id twice is a no-op, so callers may register lazily.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when `size` is zero, which would produce an empty block.
228    pub fn add_parameter(&mut self, id: String, size: usize) -> Result<()> {
229        if self.parameter_index.contains_key(&id) {
230            return Ok(());
231        }
232        if size == 0 {
233            return Err(TrustformersError::tensor_op_error(
234                "cannot register a zero-sized parameter in the SoA layout",
235                "add_parameter",
236            ));
237        }
238
239        // Calculate optimal chunk size for vectorization
240        let chunk_size = self.calculate_optimal_chunk_size(size);
241
242        // Each parameter's block starts on a cache-line boundary within the shared
243        // array so that neighbouring parameters never share a line.
244        let line = self.cache_line_elements();
245        let momentum_offset = self.momentum.len().div_ceil(line) * line;
246        let variance_offset = self.variance.len().div_ceil(line) * line;
247
248        self.momentum.resize(momentum_offset + size, 0.0);
249        self.variance.resize(variance_offset + size, 0.0);
250
251        let param_info = ParameterInfo {
252            id: id.clone(),
253            momentum_offset,
254            variance_offset,
255            size,
256            chunk_size,
257        };
258
259        self.parameter_index.insert(id, self.parameters.len());
260        self.parameters.push(param_info);
261        Ok(())
262    }
263
264    /// Calculates optimal chunk size for vectorization.
265    fn calculate_optimal_chunk_size(&self, size: usize) -> usize {
266        let vector_elements = self.alignment.vector_size / std::mem::size_of::<f32>();
267        let cache_line_elements = self.alignment.cache_line_size / std::mem::size_of::<f32>();
268
269        // Choose chunk size that aligns with both vector and cache line boundaries
270        let min_chunk = vector_elements;
271        let preferred_chunk = cache_line_elements;
272
273        if size >= preferred_chunk {
274            preferred_chunk
275        } else if size >= min_chunk {
276            // Round down to nearest vector size
277            (size / min_chunk) * min_chunk
278        } else {
279            size
280        }
281    }
282
283    /// Gets parameter information by ID.
284    pub fn get_parameter_info(&self, id: &str) -> Option<&ParameterInfo> {
285        self.parameter_index.get(id).and_then(|&i| self.parameters.get(i))
286    }
287
288    /// Reads the persisted first moment of a parameter (primarily for tests).
289    pub fn momentum_of(&self, id: &str) -> Option<&[f32]> {
290        let info = self.get_parameter_info(id)?;
291        self.momentum.get(info.momentum_offset..info.momentum_offset + info.size)
292    }
293
294    /// Reads the persisted second moment of a parameter (primarily for tests).
295    pub fn variance_of(&self, id: &str) -> Option<&[f32]> {
296        let info = self.get_parameter_info(id)?;
297        self.variance.get(info.variance_offset..info.variance_offset + info.size)
298    }
299
300    /// Current global step counter.
301    pub fn step_count(&self) -> usize {
302        self.step
303    }
304
305    /// Updates momentum and variance for a parameter using optimized memory access.
306    pub fn update_parameter_soa(
307        &mut self,
308        param_id: &str,
309        param: &mut [f32],
310        grad: &[f32],
311        lr: f32,
312        betas: (f32, f32),
313        eps: f32,
314        weight_decay: f32,
315    ) -> Result<()> {
316        let param_info = self
317            .get_parameter_info(param_id)
318            .ok_or_else(|| {
319                TrustformersError::tensor_op_error("Parameter not found", "update_parameter_soa")
320            })?
321            .clone();
322
323        if param.len() != param_info.size || grad.len() != param_info.size {
324            return Err(TrustformersError::tensor_op_error(
325                "Size mismatch",
326                "update_parameter_soa",
327            ));
328        }
329
330        self.step += 1;
331        let (bias_correction1, bias_correction2) =
332            BiasCorrection::compute_adam_corrections(betas.0, betas.1, self.step);
333
334        // Process in cache-friendly chunks
335        let chunk_size = param_info.chunk_size;
336        let num_chunks = param_info.size.div_ceil(chunk_size);
337
338        for chunk_idx in 0..num_chunks {
339            let start = chunk_idx * chunk_size;
340            let end = (start + chunk_size).min(param_info.size);
341
342            self.process_chunk_soa(
343                &mut param[start..end],
344                &grad[start..end],
345                start,
346                &param_info,
347                lr,
348                betas,
349                bias_correction1,
350                bias_correction2,
351                eps,
352                weight_decay,
353            )?;
354        }
355
356        Ok(())
357    }
358
359    /// Processes a chunk using the Structure of Arrays layout.
360    ///
361    /// Reads the previous moments out of the shared arrays, applies the Adam EMA
362    /// update, writes the new moments back, and steps the parameter.
363    fn process_chunk_soa(
364        &mut self,
365        param_chunk: &mut [f32],
366        grad_chunk: &[f32],
367        offset: usize,
368        param_info: &ParameterInfo,
369        lr: f32,
370        betas: (f32, f32),
371        bias_correction1: f32,
372        bias_correction2: f32,
373        eps: f32,
374        weight_decay: f32,
375    ) -> Result<()> {
376        let momentum_start = param_info.momentum_offset + offset;
377        let variance_start = param_info.variance_offset + offset;
378        let len = param_chunk.len();
379
380        // `momentum` and `variance` are distinct fields, so both slices can be held
381        // mutably at once.
382        let momentum_slice =
383            self.momentum.get_mut(momentum_start..momentum_start + len).ok_or_else(|| {
384                TrustformersError::tensor_op_error(
385                    "momentum block out of range for SoA layout",
386                    "process_chunk_soa",
387                )
388            })?;
389        let variance_slice =
390            self.variance.get_mut(variance_start..variance_start + len).ok_or_else(|| {
391                TrustformersError::tensor_op_error(
392                    "variance block out of range for SoA layout",
393                    "process_chunk_soa",
394                )
395            })?;
396
397        for i in 0..len {
398            let grad_val = grad_chunk[i] + weight_decay * param_chunk[i];
399
400            // Real SoA access: load the persisted moments for this element.
401            let momentum = &mut momentum_slice[i];
402            let variance = &mut variance_slice[i];
403
404            // Update momentum and variance with exponential moving averages
405            ParameterUpdate::update_ema(momentum, grad_val, betas.0);
406            ParameterUpdate::update_ema(variance, grad_val * grad_val, betas.1);
407
408            // Compute bias-corrected estimates
409            let m_hat = *momentum / bias_correction1;
410            let v_hat = *variance / bias_correction2;
411
412            // Apply Adam update to parameter
413            ParameterUpdate::adam_update(&mut param_chunk[i], lr, m_hat, v_hat, eps);
414        }
415
416        Ok(())
417    }
418
419    /// Gets memory layout statistics.
420    pub fn layout_stats(&self) -> LayoutStats {
421        let momentum_memory = self.momentum.len() * std::mem::size_of::<f32>();
422        let variance_memory = self.variance.len() * std::mem::size_of::<f32>();
423        let total_elements: usize = self.parameters.iter().map(|p| p.size).sum();
424
425        LayoutStats {
426            total_parameters: self.parameters.len(),
427            total_elements,
428            momentum_memory_bytes: momentum_memory,
429            variance_memory_bytes: variance_memory,
430            total_memory_bytes: momentum_memory + variance_memory,
431            alignment_config: self.alignment,
432            cache_line_utilization: self.calculate_cache_line_utilization(),
433        }
434    }
435
436    /// Calculates cache line utilization efficiency.
437    fn calculate_cache_line_utilization(&self) -> f32 {
438        if self.parameters.is_empty() {
439            return 1.0;
440        }
441
442        let cache_line_elements = self.alignment.cache_line_size / std::mem::size_of::<f32>();
443        let mut total_utilization = 0.0;
444
445        for param in &self.parameters {
446            let lines_used = param.size.div_ceil(cache_line_elements);
447            let elements_in_lines = lines_used * cache_line_elements;
448            let utilization = param.size as f32 / elements_in_lines as f32;
449            total_utilization += utilization;
450        }
451
452        total_utilization / self.parameters.len() as f32
453    }
454}
455
456// Safety: SoAOptimizerState contains AlignedAllocator which manages memory properly
457unsafe impl Send for SoAOptimizerState {}
458unsafe impl Sync for SoAOptimizerState {}
459
460/// Memory layout optimization statistics.
461#[derive(Debug, Clone)]
462pub struct LayoutStats {
463    /// Number of parameters
464    pub total_parameters: usize,
465    /// Total number of elements
466    pub total_elements: usize,
467    /// Memory used by momentum arrays
468    pub momentum_memory_bytes: usize,
469    /// Memory used by variance arrays
470    pub variance_memory_bytes: usize,
471    /// Total memory usage
472    pub total_memory_bytes: usize,
473    /// Alignment configuration
474    pub alignment_config: AlignmentConfig,
475    /// Cache line utilization efficiency (0.0 to 1.0)
476    pub cache_line_utilization: f32,
477}
478
479impl LayoutStats {
480    /// Calculates memory overhead compared to naive layout.
481    pub fn memory_overhead(&self) -> f32 {
482        let naive_memory = self.total_elements * std::mem::size_of::<f32>() * 2; // momentum + variance
483        if naive_memory == 0 {
484            return 0.0;
485        }
486        (self.total_memory_bytes as f32 / naive_memory as f32) - 1.0
487    }
488
489    /// Suggests layout optimizations.
490    pub fn optimization_suggestions(&self) -> Vec<String> {
491        let mut suggestions = Vec::new();
492
493        if self.cache_line_utilization < 0.8 {
494            suggestions.push("Poor cache line utilization; consider parameter padding".to_string());
495        }
496
497        let overhead = self.memory_overhead();
498        if overhead > 0.2 {
499            suggestions.push(format!(
500                "High memory overhead ({:.1}%); review alignment requirements",
501                overhead * 100.0
502            ));
503        }
504
505        if self.alignment_config.vector_size > 32 && self.total_elements < 1000 {
506            suggestions.push("Vector size may be too large for small parameters".to_string());
507        }
508
509        if !self.alignment_config.use_huge_pages && self.total_memory_bytes > 1024 * 1024 {
510            suggestions.push("Consider enabling huge pages for large memory usage".to_string());
511        }
512
513        if suggestions.is_empty() {
514            suggestions.push("Memory layout appears well optimized".to_string());
515        }
516
517        suggestions
518    }
519}
520
521/// Memory-optimized Adam optimizer using SoA layout.
522#[derive(Debug)]
523pub struct LayoutOptimizedAdam {
524    /// Learning rate
525    lr: f32,
526    /// Beta coefficients
527    betas: (f32, f32),
528    /// Epsilon for numerical stability
529    eps: f32,
530    /// Weight decay coefficient
531    weight_decay: f32,
532    /// SoA optimizer state
533    state: SoAOptimizerState,
534    /// Stable parameter identity registry (see [`crate::param_id`]).
535    ///
536    /// Replaces heap-address keys, which change in every process and so made
537    /// checkpoint resume silently restore nothing.
538    params: crate::param_id::ParamRegistry,
539}
540
541impl LayoutOptimizedAdam {
542    /// Creates a new layout-optimized Adam optimizer.
543    pub fn new(lr: f32, betas: (f32, f32), eps: f32, weight_decay: f32) -> Self {
544        Self::with_alignment(lr, betas, eps, weight_decay, AlignmentConfig::default())
545    }
546
547    /// Creates an optimizer with custom alignment configuration.
548    pub fn with_alignment(
549        lr: f32,
550        betas: (f32, f32),
551        eps: f32,
552        weight_decay: f32,
553        alignment: AlignmentConfig,
554    ) -> Self {
555        Self {
556            lr,
557            betas,
558            eps,
559            weight_decay,
560            state: SoAOptimizerState::new(alignment),
561            params: crate::param_id::ParamRegistry::new(),
562        }
563    }
564
565    /// Creates an AVX-512 optimized variant.
566    pub fn avx512_optimized(lr: f32, betas: (f32, f32), eps: f32, weight_decay: f32) -> Self {
567        Self::with_alignment(lr, betas, eps, weight_decay, AlignmentConfig::avx512())
568    }
569
570    /// Gets layout optimization statistics.
571    pub fn layout_stats(&self) -> LayoutStats {
572        self.state.layout_stats()
573    }
574
575    /// Adds a parameter to the optimizer with optimal layout.
576    pub fn add_parameter(&mut self, id: String, size: usize) -> Result<()> {
577        self.state.add_parameter(id, size)
578    }
579}
580
581impl Optimizer for LayoutOptimizedAdam {
582    fn update(&mut self, parameter: &mut Tensor, grad: &Tensor) -> Result<()> {
583        match (parameter, grad) {
584            (Tensor::F32(param), Tensor::F32(grad_arr)) => {
585                let param_id = self.params.key_for_addr(param.as_ptr() as usize, param.len())?;
586
587                // Ensure parameter is registered
588                if self.state.get_parameter_info(&param_id).is_none() {
589                    self.state.add_parameter(param_id.clone(), param.len())?;
590                }
591
592                let param_slice = param.as_slice_mut().ok_or_else(|| {
593                    TrustformersError::tensor_op_error(
594                        "Failed to get mutable slice from param tensor",
595                        "update",
596                    )
597                })?;
598                let grad_slice = grad_arr.as_slice().ok_or_else(|| {
599                    TrustformersError::tensor_op_error(
600                        "Failed to get slice from gradient tensor",
601                        "update",
602                    )
603                })?;
604                self.state.update_parameter_soa(
605                    &param_id,
606                    param_slice,
607                    grad_slice,
608                    self.lr,
609                    self.betas,
610                    self.eps,
611                    self.weight_decay,
612                )
613            },
614            _ => Err(TrustformersError::tensor_op_error(
615                "Unsupported tensor types for LayoutOptimizedAdam",
616                "update",
617            )),
618        }
619    }
620
621    fn zero_grad(&mut self) {
622        // No explicit gradient storage
623    }
624
625    fn step(&mut self) {
626        // Step counter is handled in update_parameter_soa
627    }
628
629    fn get_lr(&self) -> f32 {
630        self.lr
631    }
632
633    fn set_lr(&mut self, lr: f32) {
634        self.lr = lr;
635    }
636}
637
638// Safety: LayoutOptimizedAdam contains SoAOptimizerState which is Send/Sync
639unsafe impl Send for LayoutOptimizedAdam {}
640unsafe impl Sync for LayoutOptimizedAdam {}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn test_alignment_config() {
648        let config = AlignmentConfig::default();
649        assert_eq!(config.cache_line_size, 64);
650        assert_eq!(config.vector_size, 32);
651        assert!(!config.use_huge_pages);
652
653        let avx512_config = AlignmentConfig::avx512();
654        assert_eq!(avx512_config.vector_size, 64);
655
656        let alignment = config.alignment_for_size(1000);
657        assert!(alignment > 0);
658        assert!(alignment <= config.cache_line_size);
659    }
660
661    #[test]
662    fn test_aligned_allocator() {
663        let config = AlignmentConfig::default();
664        let mut allocator = AlignedAllocator::new(config);
665
666        let _ptr = allocator.allocate_aligned::<f32>(1000).expect("Operation failed in test");
667        // Pointer is allocated successfully
668
669        let memory_usage = allocator.memory_usage();
670        assert!(memory_usage >= 1000 * std::mem::size_of::<f32>());
671    }
672
673    #[test]
674    fn test_soa_optimizer_state() {
675        let config = AlignmentConfig::default();
676        let mut state = SoAOptimizerState::new(config);
677
678        state
679            .add_parameter("param1".to_string(), 1000)
680            .expect("Operation failed in test");
681        assert!(state.get_parameter_info("param1").is_some());
682
683        let stats = state.layout_stats();
684        assert_eq!(stats.total_parameters, 1);
685        assert_eq!(stats.total_elements, 1000);
686    }
687
688    #[test]
689    fn test_layout_optimized_adam() {
690        let optimizer = LayoutOptimizedAdam::new(1e-3, (0.9, 0.999), 1e-8, 0.01);
691        assert_eq!(optimizer.get_lr(), 1e-3);
692        assert_eq!(optimizer.betas, (0.9, 0.999));
693
694        let stats = optimizer.layout_stats();
695        assert_eq!(stats.total_parameters, 0);
696    }
697
698    #[test]
699    fn test_layout_stats() {
700        let config = AlignmentConfig::default();
701        let mut state = SoAOptimizerState::new(config);
702
703        state
704            .add_parameter("param1".to_string(), 100)
705            .expect("Operation failed in test");
706        state
707            .add_parameter("param2".to_string(), 200)
708            .expect("Operation failed in test");
709
710        let stats = state.layout_stats();
711        assert_eq!(stats.total_parameters, 2);
712        assert_eq!(stats.total_elements, 300);
713        assert!(stats.cache_line_utilization > 0.0);
714        assert!(stats.cache_line_utilization <= 1.0);
715
716        let overhead = stats.memory_overhead();
717        assert!(overhead >= 0.0);
718
719        let suggestions = stats.optimization_suggestions();
720        assert!(!suggestions.is_empty());
721    }
722
723    #[test]
724    fn test_chunk_size_calculation() {
725        let config = AlignmentConfig::default();
726        let state = SoAOptimizerState::new(config);
727
728        let chunk_size_large = state.calculate_optimal_chunk_size(10000);
729        let chunk_size_small = state.calculate_optimal_chunk_size(5);
730
731        assert!(chunk_size_large > chunk_size_small);
732        assert!(
733            chunk_size_large.is_multiple_of(config.vector_size / std::mem::size_of::<f32>())
734                || chunk_size_large == 10000
735        );
736    }
737
738    #[test]
739    fn test_avx512_optimization() {
740        let optimizer = LayoutOptimizedAdam::avx512_optimized(1e-3, (0.9, 0.999), 1e-8, 0.01);
741        let stats = optimizer.layout_stats();
742        assert_eq!(stats.alignment_config.vector_size, 64);
743    }
744
745    /// Regression: `process_chunk_soa` used to derive momentum/variance from the
746    /// current gradient on every call and never write them back, so the "Adam"
747    /// update was a pure function of the latest gradient.
748    #[test]
749    fn test_soa_state_persists_across_steps() {
750        let mut state = SoAOptimizerState::new(AlignmentConfig::default());
751        state.add_parameter("w".to_string(), 4).expect("register");
752
753        let mut param = vec![0.0_f32; 4];
754        let grad = vec![1.0_f32; 4];
755
756        state
757            .update_parameter_soa("w", &mut param, &grad, 0.1, (0.9, 0.999), 1e-8, 0.0)
758            .expect("step 1");
759        let momentum_after_first = state.momentum_of("w").expect("momentum block").to_vec();
760        assert!(
761            momentum_after_first.iter().all(|m| (m - 0.1).abs() < 1e-6),
762            "first EMA must be (1-beta1)*g = 0.1, got {momentum_after_first:?}"
763        );
764
765        // A second step with a ZERO gradient can only move the parameter if the
766        // momentum from step 1 was actually stored.
767        let before_second = param.clone();
768        let zero_grad = vec![0.0_f32; 4];
769        state
770            .update_parameter_soa("w", &mut param, &zero_grad, 0.1, (0.9, 0.999), 1e-8, 0.0)
771            .expect("step 2");
772
773        let momentum_after_second = state.momentum_of("w").expect("momentum block").to_vec();
774        assert!(
775            momentum_after_second.iter().all(|m| (m - 0.09).abs() < 1e-6),
776            "second EMA must decay to beta1*0.1 = 0.09, got {momentum_after_second:?}"
777        );
778        for (before, after) in before_second.iter().zip(param.iter()) {
779            assert!(
780                (before - after).abs() > 1e-6,
781                "carried momentum must still move the parameter on a zero gradient"
782            );
783        }
784    }
785
786    /// Two parameters must own disjoint, non-overlapping blocks of the shared arrays.
787    #[test]
788    fn test_soa_parameters_get_disjoint_blocks() {
789        let mut state = SoAOptimizerState::new(AlignmentConfig::default());
790        state.add_parameter("a".to_string(), 4).expect("a");
791        state.add_parameter("b".to_string(), 4).expect("b");
792
793        let mut param_a = vec![0.0_f32; 4];
794        let mut param_b = vec![0.0_f32; 4];
795        let grad_a = vec![1.0_f32; 4];
796        let grad_b = vec![0.0_f32; 4];
797
798        state
799            .update_parameter_soa("a", &mut param_a, &grad_a, 0.1, (0.9, 0.999), 1e-8, 0.0)
800            .expect("update a");
801        state
802            .update_parameter_soa("b", &mut param_b, &grad_b, 0.1, (0.9, 0.999), 1e-8, 0.0)
803            .expect("update b");
804
805        let momentum_b = state.momentum_of("b").expect("b momentum");
806        assert!(
807            momentum_b.iter().all(|m| m.abs() < 1e-9),
808            "parameter b saw a zero gradient; its momentum must stay zero: {momentum_b:?}"
809        );
810        let momentum_a = state.momentum_of("a").expect("a momentum");
811        assert!(momentum_a.iter().all(|m| *m > 0.0), "a must have momentum");
812    }
813
814    /// Convergence smoke test on a quadratic bowl f(x) = sum(x^2), grad = 2x.
815    #[test]
816    fn test_layout_optimized_adam_converges_on_quadratic() {
817        let mut optimizer = LayoutOptimizedAdam::new(0.05, (0.9, 0.999), 1e-8, 0.0);
818        let mut param = Tensor::from_vec(vec![1.0_f32; 4], &[4]).expect("param");
819        let initial_loss: f32 = param.data().expect("data").iter().map(|v| v * v).sum();
820
821        for _ in 0..400 {
822            let grad_data: Vec<f32> = param.data().expect("data").iter().map(|v| 2.0 * v).collect();
823            let grad = Tensor::from_vec(grad_data, &[4]).expect("grad");
824            optimizer.update(&mut param, &grad).expect("update");
825        }
826
827        let final_loss: f32 = param.data().expect("data").iter().map(|v| v * v).sum();
828        assert!(
829            final_loss < initial_loss * 1e-2,
830            "loss must decrease: {initial_loss} -> {final_loss}"
831        );
832    }
833}