1use burn_core as burn;
2
3use crate::RecordState;
4
5use burn::config::Config;
6use burn::tensor::Device;
7use burn::tensor::Tensor;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 Optimizer,
12 decay::WeightDecayConfig,
13 module_optimizer::ModuleOptimizer,
14 momentum::{Momentum, MomentumConfig, MomentumState},
15};
16use crate::LearningRate;
17
18#[cfg(not(feature = "std"))]
19#[allow(unused_imports)]
20use num_traits::Float as _;
21
22#[derive(Clone, Default, Debug, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum AdjustLrFn {
33 #[default]
43 Original,
44
45 MatchRmsAdamW,
54}
55
56impl AdjustLrFn {
57 fn adjustment_ratio(&self, shape: &[usize]) -> f64 {
67 if shape.len() < 2 {
68 return 1.0;
69 }
70
71 let a = shape[0] as f64;
72 let b = shape[1] as f64;
73
74 match self {
75 Self::Original => {
76 let ratio = a / b;
78 ratio.max(1.0).sqrt()
79 }
80 Self::MatchRmsAdamW => {
81 0.2 * a.max(b).sqrt()
83 }
84 }
85 }
86}
87
88#[derive(Config, Debug)]
126pub struct MuonConfig {
127 weight_decay: Option<WeightDecayConfig>,
129
130 #[config(default = "MomentumConfig { momentum: 0.95, dampening: 0.0, nesterov: true }")]
137 momentum: MomentumConfig,
138
139 #[config(default = "(3.4445, -4.775, 2.0315)")]
144 ns_coefficients: (f32, f32, f32),
145
146 #[config(default = 1e-7)]
148 epsilon: f32,
149
150 #[config(default = 5)]
152 ns_steps: usize,
153
154 #[config(default = "AdjustLrFn::Original")]
159 adjust_lr_fn: AdjustLrFn,
160}
161
162impl MuonConfig {
163 pub fn build(&self) -> Muon {
170 let momentum = Momentum::new(&self.momentum);
171 let weight_decay_penalty = self.weight_decay.as_ref().map(|wd| wd.penalty);
172
173 Muon {
174 momentum,
175 ns_params: NewtonSchulzParams::new(self.ns_coefficients, self.ns_steps),
176 weight_decay_penalty,
177 epsilon: self.epsilon,
178 adjust_lr_fn: self.adjust_lr_fn,
179 }
180 }
181
182 pub fn init(&self) -> ModuleOptimizer {
215 ModuleOptimizer::from(self.build())
216 }
217}
218
219#[derive(Clone, Copy)]
221struct NewtonSchulzParams {
222 a: f32,
223 b: f32,
224 c: f32,
225 steps: usize,
226}
227
228impl NewtonSchulzParams {
229 fn new(coefficients: (f32, f32, f32), steps: usize) -> Self {
230 Self {
231 a: coefficients.0,
232 b: coefficients.1,
233 c: coefficients.2,
234 steps,
235 }
236 }
237}
238
239#[derive(Clone)]
257pub struct Muon {
258 momentum: Momentum,
259 ns_params: NewtonSchulzParams,
260 weight_decay_penalty: Option<f32>,
261 epsilon: f32,
262 adjust_lr_fn: AdjustLrFn,
263}
264
265impl Muon {
266 fn adjust_lr(&self, lr: LearningRate, shape: &[usize]) -> LearningRate {
283 lr * self.adjust_lr_fn.adjustment_ratio(shape)
284 }
285
286 fn zeropower_via_newtonschulz<const D: usize>(&self, g: Tensor<D>) -> Tensor<D> {
306 let shape = g.shape();
307 let dim_m2 = shape[D - 2];
308 let dim_m1 = shape[D - 1];
309
310 let (mut x, needs_transpose) = if dim_m2 > dim_m1 {
312 (g.swap_dims(D - 2, D - 1), true)
313 } else {
314 (g, false)
315 };
316
317 let norm = x
320 .clone()
321 .powf_scalar(2.0)
322 .sum()
323 .sqrt()
324 .clamp_min(self.epsilon)
325 .unsqueeze();
326
327 x = x.div(norm);
328
329 let NewtonSchulzParams { a, b, c, steps } = self.ns_params;
332
333 for _ in 0..steps {
334 let x_t = x.clone().swap_dims(D - 2, D - 1);
336 let a_matrix = x.clone().matmul(x_t);
337
338 let a_squared = a_matrix.clone().matmul(a_matrix.clone());
340 let b_matrix = a_matrix.mul_scalar(b).add(a_squared.mul_scalar(c));
341
342 x = x.clone().mul_scalar(a).add(b_matrix.matmul(x.clone()));
344 }
345
346 if needs_transpose {
348 x = x.swap_dims(D - 2, D - 1);
349 }
350
351 x
352 }
353}
354
355#[derive(RecordState, Clone, new)]
357pub struct MuonState<const D: usize> {
358 pub momentum: MomentumState<D>,
360}
361
362impl Optimizer for Muon {
363 type State<const D: usize> = MuonState<D>;
364
365 fn step<const D: usize>(
385 &self,
386 lr: LearningRate,
387 tensor: Tensor<D>,
388 grad: Tensor<D>,
389 state: Option<Self::State<D>>,
390 ) -> (Tensor<D>, Option<Self::State<D>>) {
391 assert!(
392 D == 2,
393 "Newton-Schulz iteration requires 2D tensors, got {}D",
394 D
395 );
396
397 let state_momentum = state.map(|s| s.momentum);
399 let (grad, new_momentum_state) = self.momentum.transform(grad, state_momentum);
400
401 let update = self.zeropower_via_newtonschulz(grad);
403
404 let adjusted_lr = self.adjust_lr(lr, &tensor.shape());
406
407 let tensor = if let Some(penalty) = self.weight_decay_penalty {
410 let decay_factor = 1.0 - lr * penalty as f64;
411 tensor.mul_scalar(decay_factor)
412 } else {
413 tensor
414 };
415
416 let delta = update.mul_scalar(adjusted_lr);
418 let new_state = MuonState::new(new_momentum_state);
419
420 (tensor - delta, Some(new_state))
421 }
422
423 fn to_device<const D: usize>(mut state: Self::State<D>, device: &Device) -> Self::State<D> {
424 state.momentum = state.momentum.to_device(device);
425 state
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::{GradientsParams, Optimizer};
433 use burn::module::Param;
434 use burn::tensor::{Distribution, Tensor, TensorData};
435 use burn_nn::{Linear, LinearConfig};
436
437 const TOLERANCE: f64 = 1e-8;
438
439 fn given_linear_layer_no_bias(weight: TensorData) -> Linear {
440 let device = Device::default().autodiff();
441 Linear {
442 weight: Param::from_data(weight, &device),
443 bias: None, }
445 }
446
447 #[test]
448 fn test_adjust_lr_fn_original() {
449 let method = AdjustLrFn::Original;
450
451 let ratio = method.adjustment_ratio(&[512, 512]);
453 assert!((ratio - 1.0).abs() < TOLERANCE);
454
455 let ratio = method.adjustment_ratio(&[1024, 512]);
457 let expected = (2.0f64).sqrt();
458 assert!((ratio - expected).abs() < TOLERANCE);
459
460 let ratio = method.adjustment_ratio(&[512, 1024]);
462 assert!((ratio - 1.0).abs() < TOLERANCE);
463 }
464
465 #[test]
466 fn test_adjust_lr_fn_match_rms_adamw() {
467 let method = AdjustLrFn::MatchRmsAdamW;
468
469 let ratio = method.adjustment_ratio(&[1024, 512]);
471 let expected = 0.2 * 1024.0f64.sqrt();
472 assert!((ratio - expected).abs() < TOLERANCE);
473
474 let ratio = method.adjustment_ratio(&[512, 512]);
476 let expected = 0.2 * 512.0f64.sqrt();
477 assert!((ratio - expected).abs() < TOLERANCE);
478 }
479
480 #[test]
481 #[should_panic(expected = "Newton-Schulz iteration requires 2D tensors, got 1D")]
482 fn test_1d_tensor_panics() {
483 let device = Default::default();
484 let config = MuonConfig::new();
485 let optim = Muon {
486 momentum: Momentum::new(&config.momentum),
487 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
488 weight_decay_penalty: None,
489 epsilon: config.epsilon,
490 adjust_lr_fn: config.adjust_lr_fn,
491 };
492
493 let tensor_1d = Tensor::<1>::zeros([512], &device);
494 let grad_1d = Tensor::<1>::ones([512], &device);
495
496 let _ = optim.step(0.01, tensor_1d, grad_1d, None);
497 }
498
499 #[test]
500 fn test_muon_optimizer_save_load_state() {
501 let device = Device::default().autodiff();
502 let linear = LinearConfig::new(6, 6)
504 .with_bias(false) .init(&device);
506
507 let x = Tensor::<2>::random([2, 6], Distribution::Default, &device);
508
509 let mut optimizer = MuonConfig::new().init();
510 let grads = linear.forward(x).backward();
511 let grads = GradientsParams::from_grads(grads, &linear);
512 let _linear = optimizer.step(0.01, linear, grads);
513
514 let state_before = optimizer.to_record();
515 let bytes = optimizer.into_bytes().unwrap();
516
517 let optimizer_loaded = MuonConfig::new().init().from_bytes(bytes).unwrap();
518 let state_after = optimizer_loaded.to_record();
519
520 assert_eq!(state_before.len(), state_after.len());
521 }
522
523 #[test]
524 fn test_muon_with_weight_decay() {
525 let device = Device::default().autodiff();
526 let linear = given_linear_layer_no_bias(TensorData::from([
528 [1.0, 1.0, 1.0, 1.0],
529 [1.0, 1.0, 1.0, 1.0],
530 [1.0, 1.0, 1.0, 1.0],
531 [1.0, 1.0, 1.0, 1.0],
532 ]));
533
534 let x = Tensor::<2>::from_floats([[0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]], &device)
535 .require_grad();
536
537 let mut optimizer = MuonConfig::new()
538 .with_weight_decay(Some(WeightDecayConfig::new(0.01)))
539 .init();
540
541 let grads = linear.forward(x).backward();
542 let grads = GradientsParams::from_grads(grads, &linear);
543 let linear = optimizer.step(0.01, linear, grads);
544
545 let state = linear;
546 let weight = state.weight.to_data();
547
548 for val in weight.as_slice::<f32>().unwrap() {
549 assert!(
550 *val < 1.0,
551 "Weight should be reduced by weight decay, got {}",
552 val
553 );
554 }
555 }
556
557 #[test]
558 fn test_newton_schulz_orthogonalization() {
559 let device = Default::default();
560 let matrix = Tensor::<2>::from_floats([[1.0, 0.5], [0.5, 1.0]], &device);
561
562 let config = MuonConfig::new();
563 let muon = Muon {
564 momentum: Momentum::new(&config.momentum),
565 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
566 weight_decay_penalty: None,
567 epsilon: config.epsilon,
568 adjust_lr_fn: config.adjust_lr_fn,
569 };
570
571 let orthogonalized = muon.zeropower_via_newtonschulz(matrix);
572 let o_t = orthogonalized.clone().transpose();
573 let product = orthogonalized.matmul(o_t);
574
575 let data = product.into_data();
576 let values = data.as_slice::<f32>().unwrap();
577
578 assert!(
579 (values[0] - 1.0).abs() < 0.1,
580 "Product[0,0] should be ~1.0, got {}",
581 values[0]
582 );
583 assert!(
584 (values[3] - 1.0).abs() < 0.1,
585 "Product[1,1] should be ~1.0, got {}",
586 values[3]
587 );
588 }
589
590 #[test]
591 fn test_tall_matrix_transpose() {
592 let device = Default::default();
595
596 let tall_matrix = Tensor::<2>::from_floats(
598 [
599 [1.0, 0.5, 0.3, 0.2],
600 [0.5, 1.0, 0.4, 0.1],
601 [0.3, 0.4, 1.0, 0.5],
602 [0.2, 0.1, 0.5, 1.0],
603 [0.1, 0.2, 0.3, 0.4],
604 [0.4, 0.3, 0.2, 0.1],
605 [0.2, 0.4, 0.1, 0.3],
606 [0.3, 0.1, 0.4, 0.2],
607 ],
608 &device,
609 );
610
611 let config = MuonConfig::new();
612 let muon = Muon {
613 momentum: Momentum::new(&config.momentum),
614 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
615 weight_decay_penalty: None,
616 epsilon: config.epsilon,
617 adjust_lr_fn: config.adjust_lr_fn,
618 };
619
620 let orthogonalized = muon.zeropower_via_newtonschulz(tall_matrix.clone());
622
623 let original_shape = tall_matrix.shape();
625 let result_shape = orthogonalized.shape();
626 assert_eq!(
627 original_shape.dims::<2>(),
628 result_shape.dims::<2>(),
629 "Shape should be preserved: [8, 4]"
630 );
631
632 let original_data = tall_matrix.into_data();
634 let result_data = orthogonalized.into_data();
635 assert_ne!(
636 original_data.as_slice::<f32>().unwrap(),
637 result_data.as_slice::<f32>().unwrap(),
638 "Orthogonalized matrix should differ from input"
639 );
640
641 let wide_matrix = Tensor::<2>::from_floats(
643 [
644 [1.0, 0.5, 0.3, 0.2, 0.1, 0.4, 0.2, 0.3],
645 [0.5, 1.0, 0.4, 0.1, 0.2, 0.3, 0.4, 0.1],
646 [0.3, 0.4, 1.0, 0.5, 0.3, 0.2, 0.1, 0.4],
647 [0.2, 0.1, 0.5, 1.0, 0.4, 0.1, 0.3, 0.2],
648 ],
649 &device,
650 );
651
652 let orthogonalized_wide = muon.zeropower_via_newtonschulz(wide_matrix.clone());
653
654 let wide_original_shape = wide_matrix.shape();
656 let wide_result_shape = orthogonalized_wide.shape();
657 assert_eq!(
658 wide_original_shape.dims::<2>(),
659 wide_result_shape.dims::<2>(),
660 "Wide matrix shape should be preserved: [4, 8]"
661 );
662 }
663
664 #[test]
665 fn test_zero_gradient() {
666 let device = Default::default();
668
669 let tensor = Tensor::<2>::from_floats(
670 [
671 [1.0, 0.5, 0.3, 0.2],
672 [0.5, 1.0, 0.4, 0.1],
673 [0.3, 0.4, 1.0, 0.5],
674 [0.2, 0.1, 0.5, 1.0],
675 ],
676 &device,
677 );
678
679 let zero_grad = Tensor::<2>::zeros([4, 4], &device);
681
682 let config = MuonConfig::new();
683 let muon = Muon {
684 momentum: Momentum::new(&config.momentum),
685 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
686 weight_decay_penalty: None,
687 epsilon: config.epsilon,
688 adjust_lr_fn: config.adjust_lr_fn,
689 };
690
691 let (updated_tensor, state) = muon.step(0.01, tensor.clone(), zero_grad, None);
693
694 assert!(state.is_some());
696
697 let original_data = tensor.into_data();
699 let updated_data = updated_tensor.clone().into_data();
700
701 let original_vals = original_data.as_slice::<f32>().unwrap();
702 let updated_vals = updated_data.as_slice::<f32>().unwrap();
703
704 for (orig, upd) in original_vals.iter().zip(updated_vals.iter()) {
705 assert!(
706 (orig - upd).abs() < 1e-6,
707 "With zero gradient, tensor should remain unchanged (or very close)"
708 );
709 }
710
711 for val in updated_vals {
713 assert!(
714 !val.is_nan(),
715 "Result should not contain NaN values with zero gradient"
716 );
717 }
718
719 let muon_with_decay = Muon {
721 momentum: Momentum::new(&config.momentum),
722 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
723 weight_decay_penalty: Some(0.01),
724 epsilon: config.epsilon,
725 adjust_lr_fn: config.adjust_lr_fn,
726 };
727
728 let tensor2 = Tensor::<2>::from_floats(
729 [
730 [1.0, 0.5, 0.3, 0.2],
731 [0.5, 1.0, 0.4, 0.1],
732 [0.3, 0.4, 1.0, 0.5],
733 [0.2, 0.1, 0.5, 1.0],
734 ],
735 &device,
736 );
737 let zero_grad2 = Tensor::<2>::zeros([4, 4], &device);
738
739 let (updated_tensor_decay, _) =
740 muon_with_decay.step(0.01, tensor2.clone(), zero_grad2, None);
741
742 let updated_decay_data = updated_tensor_decay.into_data();
744 let updated_decay_vals = updated_decay_data.as_slice::<f32>().unwrap();
745
746 for val in updated_decay_vals {
747 assert!(
748 !val.is_nan(),
749 "Result should not contain NaN with zero gradient and weight decay"
750 );
751 }
752
753 let original_vals2 = tensor2.into_data().as_slice::<f32>().unwrap().to_vec();
755 for (orig, upd) in original_vals2.iter().zip(updated_decay_vals.iter()) {
756 if orig.abs() > 1e-6 {
757 assert!(
759 upd.abs() < orig.abs(),
760 "Weight decay should reduce magnitude: original={}, updated={}",
761 orig,
762 upd
763 );
764 }
765 }
766 }
767}