Skip to main content

trustformers_optim/
muon.rs

1//! # Muon Optimizer
2//!
3//! Implementation of the Muon optimizer, a second-order optimization algorithm designed for
4//! neural network training, particularly with hidden layers having 2D weight matrices.
5//!
6//! Muon is used in the current training speed records for both NanoGPT and CIFAR-10 speedrunning.
7//!
8//! ## Key Features
9//!
10//! - **Second-Order Optimization**: Uses Newton-Schulz iteration for efficient orthogonalization
11//! - **Low FLOP Overhead**: Below 1% FLOP overhead for typical LM training scenarios
12//! - **2D Parameter Focus**: Designed specifically for 2D weight matrices (linear layers)
13//! - **Speed Records**: Achieves state-of-the-art training speed on multiple benchmarks
14//!
15//! ## Design Philosophy
16//!
17//! Muon only applies to 2D parameters (weight matrices), while scalar and vector parameters
18//! must be optimized using a standard method (e.g., AdamW). This hybrid approach provides
19//! the best of both worlds: second-order benefits for main parameters and proven stability
20//! for auxiliary parameters.
21
22use crate::common::{OptimizerState, StateMemoryStats};
23use crate::traits::StatefulOptimizer;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use trustformers_core::errors::{Result, TrustformersError};
27use trustformers_core::tensor::Tensor;
28use trustformers_core::traits::Optimizer;
29
30/// Configuration for Muon optimizer
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct MuonConfig {
33    /// Learning rate (default: 0.02)
34    pub learning_rate: f32,
35    /// Momentum coefficient (default: 0.95)
36    pub momentum: f32,
37    /// Newton-Schulz iteration steps (default: 5)
38    pub ns_steps: usize,
39    /// Minimum dimension for 2D optimization (default: 64)
40    pub min_dim_2d: usize,
41    /// Fallback optimizer learning rate for 1D parameters (default: 1e-3)
42    pub fallback_lr: f32,
43    /// Fallback momentum for 1D parameters (default: 0.9)
44    pub fallback_momentum: f32,
45    /// Use Nesterov look-ahead before orthogonalization (default: true).
46    ///
47    /// Jordan et al.'s reference implementation orthogonalizes `g + μ·m`; setting this
48    /// to `false` orthogonalizes the plain heavy-ball buffer `m`.
49    pub nesterov: bool,
50    /// Weight decay coefficient (default: 0.0)
51    pub weight_decay: f32,
52    /// Whether to use orthogonalization (default: true)
53    pub use_orthogonal: bool,
54}
55
56impl Default for MuonConfig {
57    fn default() -> Self {
58        Self {
59            learning_rate: 0.02,
60            momentum: 0.95,
61            ns_steps: 5,
62            min_dim_2d: 64,
63            fallback_lr: 1e-3,
64            fallback_momentum: 0.9,
65            nesterov: true,
66            weight_decay: 0.0,
67            use_orthogonal: true,
68        }
69    }
70}
71
72/// Muon optimizer implementation
73///
74/// Muon uses Newton-Schulz iteration for orthogonalization of 2D weight matrices,
75/// providing efficient second-order optimization. For 1D parameters, it falls back
76/// to a standard momentum-based update.
77#[derive(Debug)]
78pub struct Muon {
79    config: MuonConfig,
80    state: OptimizerState,
81    /// Momentum buffers for 2D parameters
82    momentum_2d: HashMap<String, Vec<Vec<f32>>>,
83    /// Momentum buffers for 1D parameters (AdamW-style fallback)
84    momentum_1d: HashMap<String, Vec<f32>>,
85    /// Parameter shapes for tracking 2D vs 1D
86    param_shapes: HashMap<String, (usize, usize)>,
87}
88
89impl Muon {
90    /// Create a new Muon optimizer with default configuration
91    pub fn new() -> Self {
92        Self::with_config(MuonConfig::default())
93    }
94
95    /// Create Muon with custom learning rate
96    pub fn new_with_lr(learning_rate: f32) -> Self {
97        let config = MuonConfig {
98            learning_rate,
99            ..Default::default()
100        };
101        Self::with_config(config)
102    }
103
104    /// Create Muon optimized for NanoGPT training
105    pub fn for_nanogpt() -> Self {
106        let config = MuonConfig {
107            learning_rate: 0.01,
108            momentum: 0.95,
109            ns_steps: 5,
110            min_dim_2d: 32, // Lower threshold for smaller models
111            fallback_lr: 5e-4,
112            fallback_momentum: 0.9,
113            nesterov: true,
114            weight_decay: 0.0,
115            use_orthogonal: true,
116        };
117        Self::with_config(config)
118    }
119
120    /// Create Muon optimized for CIFAR-10 training
121    pub fn for_cifar10() -> Self {
122        let config = MuonConfig {
123            learning_rate: 0.03,
124            momentum: 0.9,
125            ns_steps: 4, // Fewer steps for vision tasks
126            min_dim_2d: 64,
127            fallback_lr: 1e-3,
128            fallback_momentum: 0.9,
129            nesterov: true,
130            weight_decay: 1e-4,
131            use_orthogonal: true,
132        };
133        Self::with_config(config)
134    }
135
136    /// Create Muon optimized for large language models
137    pub fn for_large_lm() -> Self {
138        let config = MuonConfig {
139            learning_rate: 0.015,
140            momentum: 0.98,  // Higher momentum for large models
141            ns_steps: 6,     // More steps for better approximation
142            min_dim_2d: 128, // Higher threshold for large models
143            fallback_lr: 3e-4,
144            fallback_momentum: 0.95,
145            weight_decay: 0.01,
146            use_orthogonal: true,
147            nesterov: true,
148        };
149        Self::with_config(config)
150    }
151
152    /// Create Muon with custom configuration
153    pub fn with_config(config: MuonConfig) -> Self {
154        Self {
155            config,
156            state: OptimizerState::new(),
157            momentum_2d: HashMap::new(),
158            momentum_1d: HashMap::new(),
159            param_shapes: HashMap::new(),
160        }
161    }
162
163    /// Check if parameter should use 2D optimization
164    fn should_use_2d_optimization(&self, rows: usize, cols: usize) -> bool {
165        rows >= self.config.min_dim_2d && cols >= self.config.min_dim_2d
166    }
167
168    /// Newton-Schulz orthogonalization of a matrix, in place.
169    ///
170    /// The iteration `X ← (3X − X Xᵀ X)/2` converges to the orthogonal polar factor
171    /// **only** while `‖X‖₂ < √3`; above that it is cubically expanding and diverges
172    /// to `inf`/`NaN` within a few steps. Jordan et al.'s reference implementation
173    /// therefore normalises first:
174    ///
175    /// ```text
176    /// X₀ = G / (‖G‖_F + ε)         // guarantees ‖X₀‖₂ ≤ 1
177    /// X   ← (3X − X Xᵀ X) / 2       // ns_steps times
178    /// out = X · ‖G‖_F               // restore the original scale
179    /// ```
180    ///
181    /// The Frobenius norm bounds the spectral norm from above, so this makes the
182    /// iteration unconditionally stable for any finite input.
183    fn newton_schulz_orthogonalize(&self, matrix: &mut [Vec<f32>]) {
184        if !self.config.use_orthogonal {
185            return;
186        }
187
188        let rows = matrix.len();
189        if rows == 0 {
190            return;
191        }
192        let cols = matrix[0].len();
193        if cols == 0 {
194            return;
195        }
196
197        // Normalise into the convergence basin of the iteration.
198        let frobenius: f32 =
199            matrix.iter().flat_map(|row| row.iter()).map(|v| v * v).sum::<f32>().sqrt();
200        if !frobenius.is_finite() || frobenius <= f32::MIN_POSITIVE {
201            return;
202        }
203        let inv_norm = 1.0 / (frobenius + 1e-7);
204        for row in matrix.iter_mut() {
205            for value in row.iter_mut() {
206                *value *= inv_norm;
207            }
208        }
209
210        // Newton-Schulz iteration: X_{k+1} = X_k * (3I - X_k^T * X_k) / 2
211        for _ in 0..self.config.ns_steps {
212            // Compute X^T * X
213            let mut xtx = vec![vec![0.0; cols]; cols];
214            for i in 0..cols {
215                for j in 0..cols {
216                    let mut sum = 0.0;
217                    for k in 0..rows {
218                        sum += matrix[k][i] * matrix[k][j];
219                    }
220                    xtx[i][j] = sum;
221                }
222            }
223
224            // Compute 3I - X^T * X
225            for (i, row) in xtx.iter_mut().enumerate() {
226                for (j, value) in row.iter_mut().enumerate() {
227                    *value = if i == j { 3.0 - *value } else { -*value };
228                }
229            }
230
231            // Compute X * (3I - X^T * X) / 2
232            let mut new_matrix = vec![vec![0.0; cols]; rows];
233            for i in 0..rows {
234                for j in 0..cols {
235                    let mut sum = 0.0;
236                    for k in 0..cols {
237                        sum += matrix[i][k] * xtx[k][j];
238                    }
239                    new_matrix[i][j] = sum * 0.5;
240                }
241            }
242
243            // Update matrix
244            for i in 0..rows {
245                for j in 0..cols {
246                    matrix[i][j] = new_matrix[i][j];
247                }
248            }
249        }
250
251        // Restore the original magnitude, and apply Jordan et al.'s aspect-ratio
252        // scaling so the update size does not depend on the matrix shape.
253        let aspect = (rows as f32 / cols as f32).max(1.0).sqrt();
254        for row in matrix.iter_mut() {
255            for value in row.iter_mut() {
256                *value *= frobenius * aspect;
257            }
258        }
259    }
260
261    /// Update 2D parameter using Muon algorithm
262    fn update_2d_parameter(
263        &mut self,
264        param_data: &mut [f32],
265        grad_data: &[f32],
266        param_id: &str,
267        rows: usize,
268        cols: usize,
269    ) -> Result<()> {
270        // Initialize momentum if needed
271        if !self.momentum_2d.contains_key(param_id) {
272            let momentum = vec![vec![0.0; cols]; rows];
273            self.momentum_2d.insert(param_id.to_string(), momentum);
274        }
275
276        let momentum = self.momentum_2d.get_mut(param_id).ok_or_else(|| {
277            TrustformersError::invalid_state(
278                "momentum_2d should contain param_id after insert".to_string(),
279            )
280        })?;
281
282        // Reshape flat arrays to 2D views
283        let mut param_matrix = vec![vec![0.0; cols]; rows];
284        let mut grad_matrix = vec![vec![0.0; cols]; rows];
285
286        // Convert flat to 2D
287        for i in 0..rows {
288            for j in 0..cols {
289                let idx = i * cols + j;
290                param_matrix[i][j] = param_data[idx];
291                grad_matrix[i][j] = grad_data[idx];
292            }
293        }
294
295        // Apply weight decay
296        if self.config.weight_decay > 0.0 {
297            for i in 0..rows {
298                for j in 0..cols {
299                    grad_matrix[i][j] += self.config.weight_decay * param_matrix[i][j];
300                }
301            }
302        }
303
304        // Update momentum: m = momentum * m + grad
305        for i in 0..rows {
306            for j in 0..cols {
307                momentum[i][j] = self.config.momentum * momentum[i][j] + grad_matrix[i][j];
308            }
309        }
310
311        // Nesterov look-ahead: the reference Muon orthogonalizes
312        // `g + momentum · m` rather than the plain momentum buffer.
313        let mut update_matrix = momentum.clone();
314        if self.config.nesterov {
315            for i in 0..rows {
316                for j in 0..cols {
317                    update_matrix[i][j] = grad_matrix[i][j] + self.config.momentum * momentum[i][j];
318                }
319            }
320        }
321
322        // Apply Newton-Schulz orthogonalization
323        self.newton_schulz_orthogonalize(&mut update_matrix);
324
325        // Apply update: param = param - lr * orthogonalized_momentum
326        for i in 0..rows {
327            for j in 0..cols {
328                param_matrix[i][j] -= self.config.learning_rate * update_matrix[i][j];
329
330                // Convert back to flat array
331                let idx = i * cols + j;
332                param_data[idx] = param_matrix[i][j];
333            }
334        }
335
336        Ok(())
337    }
338
339    /// Update 1D parameter using fallback method (momentum SGD)
340    fn update_1d_parameter(
341        &mut self,
342        param_data: &mut [f32],
343        grad_data: &[f32],
344        param_id: &str,
345    ) -> Result<()> {
346        let param_size = param_data.len();
347
348        // Initialize momentum if needed
349        if !self.momentum_1d.contains_key(param_id) {
350            self.momentum_1d.insert(param_id.to_string(), vec![0.0; param_size]);
351        }
352
353        let momentum = self.momentum_1d.get_mut(param_id).ok_or_else(|| {
354            TrustformersError::invalid_state(
355                "momentum_1d should contain param_id after insert".to_string(),
356            )
357        })?;
358
359        // Apply momentum SGD update
360        for i in 0..param_size {
361            let mut grad = grad_data[i];
362
363            // Apply weight decay
364            if self.config.weight_decay > 0.0 {
365                grad += self.config.weight_decay * param_data[i];
366            }
367
368            // Update momentum
369            momentum[i] = self.config.fallback_momentum * momentum[i] + grad;
370
371            // Update parameter
372            param_data[i] -= self.config.fallback_lr * momentum[i];
373        }
374
375        Ok(())
376    }
377
378    /// Get memory statistics for Muon state (deprecated - use memory_usage instead)
379    pub fn memory_stats(&self) -> StateMemoryStats {
380        self.memory_usage()
381    }
382
383    /// Get optimization statistics
384    pub fn optimization_stats(&self) -> (usize, usize, f32) {
385        let params_2d = self.momentum_2d.len();
386        let params_1d = self.momentum_1d.len();
387        let total_params = params_2d + params_1d;
388        let ratio_2d = if total_params > 0 { params_2d as f32 / total_params as f32 } else { 0.0 };
389
390        (params_2d, params_1d, ratio_2d)
391    }
392}
393
394impl Default for Muon {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400impl Optimizer for Muon {
401    fn update(&mut self, parameter: &mut Tensor, grad: &Tensor) -> Result<()> {
402        // Stable parameter identity (see `crate::param_id`), resolved before the
403        // mutable data borrow.
404        let param_id = self.state.param_key_for_tensor(parameter)?;
405        let param_data = parameter.data_mut()?;
406        let grad_data = grad.data()?;
407        let param_size = param_data.len();
408
409        // Determine parameter shape
410        let (rows, cols) = if let Some(&shape) = self.param_shapes.get(&param_id) {
411            shape
412        } else {
413            // Try common factorizations for typical NN layers
414            let factors = self.find_good_factorization(param_size);
415            self.param_shapes.insert(param_id.clone(), factors);
416            factors
417        };
418
419        // Choose optimization method based on parameter shape
420        if self.should_use_2d_optimization(rows, cols) && rows * cols == param_size {
421            self.update_2d_parameter(param_data, &grad_data, &param_id, rows, cols)?;
422        } else {
423            self.update_1d_parameter(param_data, &grad_data, &param_id)?;
424        }
425
426        Ok(())
427    }
428
429    fn step(&mut self) {
430        self.state.step += 1;
431    }
432
433    fn zero_grad(&mut self) {
434        // This is typically handled by the training framework
435        // No action needed here as gradients are managed externally
436    }
437
438    fn get_lr(&self) -> f32 {
439        self.config.learning_rate
440    }
441
442    fn set_lr(&mut self, lr: f32) {
443        self.config.learning_rate = lr;
444    }
445}
446
447impl Muon {
448    /// Find a good factorization for a given parameter size
449    fn find_good_factorization(&self, size: usize) -> (usize, usize) {
450        if size < self.config.min_dim_2d {
451            return (1, size);
452        }
453
454        // Common neural network layer sizes
455        let sqrt_size = (size as f32).sqrt() as usize;
456
457        // Try factors close to square root
458        for offset in 0..=sqrt_size / 4 {
459            let candidate1 = sqrt_size + offset;
460            let candidate2 = sqrt_size - offset;
461
462            if candidate1 > 0 && size.is_multiple_of(candidate1) {
463                let other = size / candidate1;
464                if candidate1 >= self.config.min_dim_2d && other >= self.config.min_dim_2d {
465                    return (candidate1, other);
466                }
467            }
468
469            if candidate2 > 0 && size.is_multiple_of(candidate2) {
470                let other = size / candidate2;
471                if candidate2 >= self.config.min_dim_2d && other >= self.config.min_dim_2d {
472                    return (candidate2, other);
473                }
474            }
475        }
476
477        // If no good factorization found, treat as 1D
478        (1, size)
479    }
480}
481
482impl StatefulOptimizer for Muon {
483    type Config = MuonConfig;
484    type State = OptimizerState;
485
486    fn config(&self) -> &Self::Config {
487        &self.config
488    }
489
490    fn state(&self) -> &Self::State {
491        &self.state
492    }
493
494    fn state_mut(&mut self) -> &mut Self::State {
495        &mut self.state
496    }
497
498    fn state_dict(&self) -> Result<HashMap<String, Tensor>> {
499        let mut state_dict = HashMap::new();
500
501        // Save step count
502        state_dict.insert(
503            "step".to_string(),
504            Tensor::new(vec![self.state.step as f32])?,
505        );
506
507        // Save 2D momentum buffers (flattened)
508        for (param_id, momentum) in &self.momentum_2d {
509            let mut flattened = Vec::new();
510            for row in momentum {
511                flattened.extend_from_slice(row);
512            }
513            state_dict.insert(format!("momentum_2d_{}", param_id), Tensor::new(flattened)?);
514        }
515
516        // Save 1D momentum buffers
517        for (param_id, momentum) in &self.momentum_1d {
518            state_dict.insert(
519                format!("momentum_1d_{}", param_id),
520                Tensor::new(momentum.clone())?,
521            );
522        }
523
524        // Save parameter shapes
525        for (param_id, &(rows, cols)) in &self.param_shapes {
526            state_dict.insert(
527                format!("shape_{}", param_id),
528                Tensor::new(vec![rows as f32, cols as f32])?,
529            );
530        }
531
532        Ok(state_dict)
533    }
534
535    fn load_state_dict(&mut self, state_dict: HashMap<String, Tensor>) -> Result<()> {
536        // Load step count
537        if let Some(step_tensor) = state_dict.get("step") {
538            let step_data = step_tensor.data()?;
539            if !step_data.is_empty() {
540                self.state.step = step_data[0] as usize;
541            }
542        }
543
544        // Load parameter shapes first
545        for (key, tensor) in &state_dict {
546            if let Some(param_id) = key.strip_prefix("shape_") {
547                let shape_data = tensor.data()?;
548                if shape_data.len() >= 2 {
549                    let rows = shape_data[0] as usize;
550                    let cols = shape_data[1] as usize;
551                    self.param_shapes.insert(param_id.to_string(), (rows, cols));
552                }
553            }
554        }
555
556        // Load momentum buffers
557        for (key, tensor) in &state_dict {
558            let data = tensor.data()?;
559            if let Some(param_id) = key.strip_prefix("momentum_2d_") {
560                if let Some(&(rows, cols)) = self.param_shapes.get(param_id) {
561                    let mut momentum = vec![vec![0.0; cols]; rows];
562                    for i in 0..rows {
563                        for j in 0..cols {
564                            let idx = i * cols + j;
565                            if idx < data.len() {
566                                momentum[i][j] = data[idx];
567                            }
568                        }
569                    }
570                    self.momentum_2d.insert(param_id.to_string(), momentum);
571                }
572            } else if let Some(param_id) = key.strip_prefix("momentum_1d_") {
573                self.momentum_1d.insert(param_id.to_string(), data);
574            }
575        }
576
577        Ok(())
578    }
579
580    fn memory_usage(&self) -> StateMemoryStats {
581        let mut momentum_elements = 0;
582        let mut total_elements = 0;
583
584        // Count 2D momentum elements
585        for momentum in self.momentum_2d.values() {
586            let param_count = momentum.len() * momentum[0].len();
587            momentum_elements += param_count;
588            total_elements += param_count;
589        }
590
591        // Count 1D momentum elements
592        for momentum in self.momentum_1d.values() {
593            momentum_elements += momentum.len();
594            total_elements += momentum.len();
595        }
596
597        let total_bytes = total_elements * std::mem::size_of::<f32>();
598
599        StateMemoryStats {
600            momentum_elements,
601            variance_elements: 0,
602            third_moment_elements: 0,
603            total_bytes,
604            num_parameters: momentum_elements,
605        }
606    }
607
608    fn reset_state(&mut self) {
609        self.state = OptimizerState::new();
610        self.momentum_2d.clear();
611        self.momentum_1d.clear();
612        self.param_shapes.clear();
613    }
614
615    fn num_parameters(&self) -> usize {
616        let mut total = 0;
617        for momentum in self.momentum_2d.values() {
618            total += momentum.len() * momentum[0].len();
619        }
620        for momentum in self.momentum_1d.values() {
621            total += momentum.len();
622        }
623        total
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use approx::assert_relative_eq;
631
632    #[test]
633    fn test_muon_creation() {
634        let optimizer = Muon::new();
635        assert_eq!(optimizer.config.learning_rate, 0.02);
636        assert_eq!(optimizer.config.momentum, 0.95);
637        assert_eq!(optimizer.config.ns_steps, 5);
638        assert_eq!(optimizer.config.min_dim_2d, 64);
639        assert_eq!(optimizer.state.step, 0);
640    }
641
642    #[test]
643    fn test_muon_with_lr() {
644        let optimizer = Muon::new_with_lr(0.01);
645        assert_eq!(optimizer.config.learning_rate, 0.01);
646    }
647
648    #[test]
649    fn test_muon_nanogpt_preset() {
650        let optimizer = Muon::for_nanogpt();
651        assert_eq!(optimizer.config.learning_rate, 0.01);
652        assert_eq!(optimizer.config.min_dim_2d, 32);
653        assert_eq!(optimizer.config.fallback_lr, 5e-4);
654    }
655
656    #[test]
657    fn test_muon_cifar10_preset() {
658        let optimizer = Muon::for_cifar10();
659        assert_eq!(optimizer.config.learning_rate, 0.03);
660        assert_eq!(optimizer.config.ns_steps, 4);
661        assert_eq!(optimizer.config.weight_decay, 1e-4);
662    }
663
664    #[test]
665    fn test_muon_large_lm_preset() {
666        let optimizer = Muon::for_large_lm();
667        assert_eq!(optimizer.config.learning_rate, 0.015);
668        assert_eq!(optimizer.config.momentum, 0.98);
669        assert_eq!(optimizer.config.min_dim_2d, 128);
670    }
671
672    #[test]
673    fn test_should_use_2d_optimization() {
674        let optimizer = Muon::new();
675
676        // Should use 2D for large matrices
677        assert!(optimizer.should_use_2d_optimization(128, 128));
678        assert!(optimizer.should_use_2d_optimization(64, 256));
679
680        // Should not use 2D for small matrices
681        assert!(!optimizer.should_use_2d_optimization(32, 32));
682        assert!(!optimizer.should_use_2d_optimization(64, 32));
683        assert!(!optimizer.should_use_2d_optimization(1, 1000));
684    }
685
686    #[test]
687    fn test_find_good_factorization() {
688        let optimizer = Muon::new();
689
690        // Perfect square
691        let (rows, cols) = optimizer.find_good_factorization(64 * 64);
692        assert_eq!(rows * cols, 64 * 64);
693        assert!(rows >= optimizer.config.min_dim_2d);
694        assert!(cols >= optimizer.config.min_dim_2d);
695
696        // Small size should be treated as 1D
697        let (rows, cols) = optimizer.find_good_factorization(10);
698        assert_eq!((rows, cols), (1, 10));
699
700        // Common NN layer size
701        let (rows, cols) = optimizer.find_good_factorization(128 * 256);
702        assert_eq!(rows * cols, 128 * 256);
703    }
704
705    #[test]
706    fn test_optimization_stats() {
707        let mut optimizer = Muon::new();
708
709        // Initially no parameters
710        let (params_2d, params_1d, ratio) = optimizer.optimization_stats();
711        assert_eq!(params_2d, 0);
712        assert_eq!(params_1d, 0);
713        assert_eq!(ratio, 0.0);
714
715        // Add some 2D and 1D parameters
716        optimizer.momentum_2d.insert("param_0".to_string(), vec![vec![0.0; 128]; 128]);
717        optimizer.momentum_1d.insert("param_1".to_string(), vec![0.0; 10]);
718        optimizer.momentum_1d.insert("param_2".to_string(), vec![0.0; 20]);
719
720        let (params_2d, params_1d, ratio) = optimizer.optimization_stats();
721        assert_eq!(params_2d, 1);
722        assert_eq!(params_1d, 2);
723        assert_relative_eq!(ratio, 1.0 / 3.0, epsilon = 1e-6);
724    }
725
726    #[test]
727    fn test_memory_stats() {
728        let mut optimizer = Muon::new();
729
730        // Add momentum buffers
731        optimizer.momentum_2d.insert("param_0".to_string(), vec![vec![0.0; 100]; 50]); // 5000 params
732        optimizer.momentum_1d.insert("param_1".to_string(), vec![0.0; 1000]); // 1000 params
733
734        let stats = optimizer.memory_stats();
735        assert_eq!(stats.num_parameters, 6000);
736        assert_eq!(stats.momentum_elements, 6000);
737        assert_eq!(stats.variance_elements, 0);
738        assert_eq!(stats.total_bytes, 6000 * 4); // 4 bytes per f32
739    }
740
741    #[test]
742    fn test_state_dict_operations() {
743        let mut optimizer = Muon::new();
744        optimizer.state.step = 5;
745
746        // Add parameter shapes and momentum
747        optimizer.param_shapes.insert("param_0".to_string(), (2, 3));
748        optimizer.momentum_2d.insert(
749            "param_0".to_string(),
750            vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]],
751        );
752        optimizer.momentum_1d.insert("param_1".to_string(), vec![0.7, 0.8]);
753
754        // Save state
755        let state_dict = optimizer.state_dict().expect("Failed to get state dict");
756        assert!(state_dict.contains_key("step"));
757        assert!(state_dict.contains_key("momentum_2d_param_0"));
758        assert!(state_dict.contains_key("momentum_1d_param_1"));
759        assert!(state_dict.contains_key("shape_param_0"));
760
761        // Create new optimizer and load state
762        let mut new_optimizer = Muon::new();
763        new_optimizer.load_state_dict(state_dict).expect("Failed to load state dict");
764
765        assert_eq!(new_optimizer.state.step, 5);
766        assert_eq!(new_optimizer.param_shapes["param_0"], (2, 3));
767        assert_eq!(new_optimizer.momentum_1d["param_1"], vec![0.7, 0.8]);
768    }
769
770    #[test]
771    fn test_lr_setter_getter() {
772        let mut optimizer = Muon::new();
773        assert_eq!(optimizer.get_lr(), 0.02);
774
775        optimizer.set_lr(0.01);
776        assert_eq!(optimizer.get_lr(), 0.01);
777        assert_eq!(optimizer.config.learning_rate, 0.01);
778    }
779
780    #[test]
781    fn test_reset() {
782        let mut optimizer = Muon::new();
783        optimizer.state.step = 10;
784        optimizer.momentum_2d.insert("param_0".to_string(), vec![vec![1.0]]);
785        optimizer.momentum_1d.insert("param_1".to_string(), vec![1.0]);
786        optimizer.param_shapes.insert("param_0".to_string(), (1, 1));
787
788        optimizer.reset_state();
789
790        assert_eq!(optimizer.state.step, 0);
791        assert!(optimizer.momentum_2d.is_empty());
792        assert!(optimizer.momentum_1d.is_empty());
793        assert!(optimizer.param_shapes.is_empty());
794    }
795
796    #[test]
797    fn test_config_serialization() {
798        let config = MuonConfig {
799            learning_rate: 0.01,
800            momentum: 0.9,
801            ns_steps: 3,
802            min_dim_2d: 32,
803            fallback_lr: 1e-4,
804            fallback_momentum: 0.8,
805            weight_decay: 1e-5,
806            use_orthogonal: false,
807            nesterov: true,
808        };
809
810        let serialized = serde_json::to_string(&config).expect("Serialization failed");
811        let deserialized: MuonConfig =
812            serde_json::from_str(&serialized).expect("Deserialization failed");
813
814        assert_relative_eq!(deserialized.learning_rate, config.learning_rate);
815        assert_eq!(deserialized.ns_steps, config.ns_steps);
816        assert_eq!(deserialized.use_orthogonal, config.use_orthogonal);
817    }
818}
819
820#[cfg(test)]
821mod newton_schulz_tests {
822    use super::*;
823
824    /// Regression: without the Frobenius normalization the iteration is cubically
825    /// expanding for any input with spectral norm above ~√3, so a gradient of ordinary
826    /// magnitude produced `inf`/`NaN` within a few steps and wrote it into the
827    /// parameters.
828    #[test]
829    fn orthogonalization_stays_finite_for_a_large_matrix() {
830        let optimizer = Muon::new();
831        // Frobenius norm 100 — far outside the raw iteration's convergence basin.
832        let mut matrix = vec![vec![25.0_f32; 4]; 4];
833
834        optimizer.newton_schulz_orthogonalize(&mut matrix);
835
836        for row in &matrix {
837            for value in row {
838                assert!(value.is_finite(), "orthogonalization diverged: {value}");
839            }
840        }
841    }
842
843    /// The output must be near-orthogonal up to the restored scale: for a rank-1
844    /// input the normalized iterate has singular values in {1, 0}, so `XᵀX/‖G‖²` has
845    /// unit trace.
846    #[test]
847    fn orthogonalization_normalizes_the_spectrum() {
848        let optimizer = Muon::new();
849        // A well-conditioned diagonal matrix with wildly different singular values.
850        let mut matrix = vec![
851            vec![100.0_f32, 0.0, 0.0],
852            vec![0.0, 1.0, 0.0],
853            vec![0.0, 0.0, 0.01],
854        ];
855        let frobenius: f32 =
856            matrix.iter().flat_map(|r| r.iter()).map(|v| v * v).sum::<f32>().sqrt();
857
858        optimizer.newton_schulz_orthogonalize(&mut matrix);
859
860        // After orthogonalization the largest singular value must be pulled towards
861        // the others: the ratio of the largest to the smallest diagonal entry must
862        // shrink dramatically from its initial 10 000.
863        let ratio = (matrix[0][0] / matrix[1][1]).abs();
864        assert!(ratio.is_finite(), "diverged");
865        assert!(ratio < 100.0, "spectrum was not equalised, ratio {ratio}");
866        // The scale must be restored, not left at the normalized magnitude.
867        let out_frobenius: f32 =
868            matrix.iter().flat_map(|r| r.iter()).map(|v| v * v).sum::<f32>().sqrt();
869        assert!(
870            out_frobenius > frobenius * 0.5,
871            "the original scale must be restored"
872        );
873    }
874
875    /// An empty matrix must not panic on `matrix[0].len()`.
876    #[test]
877    fn orthogonalization_handles_degenerate_shapes() {
878        let optimizer = Muon::new();
879        let mut empty: Vec<Vec<f32>> = Vec::new();
880        optimizer.newton_schulz_orthogonalize(&mut empty);
881
882        let mut no_columns: Vec<Vec<f32>> = vec![Vec::new(), Vec::new()];
883        optimizer.newton_schulz_orthogonalize(&mut no_columns);
884
885        let mut zeros = vec![vec![0.0_f32; 2]; 2];
886        optimizer.newton_schulz_orthogonalize(&mut zeros);
887        assert!(zeros.iter().flatten().all(|v| v.is_finite()));
888    }
889
890    /// A realistic 2-D parameter update must stay finite end to end.
891    #[test]
892    fn muon_update_stays_finite_for_a_large_gradient() {
893        let mut optimizer = Muon::new();
894        let mut param = Tensor::from_vec(vec![0.1_f32; 64], &[8, 8]).expect("tensor");
895        let grad = Tensor::from_vec(vec![10.0_f32; 64], &[8, 8]).expect("grad");
896
897        for _ in 0..5 {
898            optimizer.update(&mut param, &grad).expect("update");
899        }
900
901        for value in param.data_f32().expect("data") {
902            assert!(
903                value.is_finite(),
904                "Muon wrote a non-finite parameter: {value}"
905            );
906        }
907    }
908}