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(crate) fn build(&self) -> Muon {
165 let momentum = Momentum::new(&self.momentum);
166 let weight_decay_penalty = self.weight_decay.as_ref().map(|wd| wd.penalty);
167
168 Muon {
169 momentum,
170 ns_params: NewtonSchulzParams::new(self.ns_coefficients, self.ns_steps),
171 weight_decay_penalty,
172 epsilon: self.epsilon,
173 adjust_lr_fn: self.adjust_lr_fn,
174 }
175 }
176
177 pub fn init(&self) -> ModuleOptimizer {
210 ModuleOptimizer::from(self.build())
211 }
212}
213
214#[derive(Clone, Copy)]
216struct NewtonSchulzParams {
217 a: f32,
218 b: f32,
219 c: f32,
220 steps: usize,
221}
222
223impl NewtonSchulzParams {
224 fn new(coefficients: (f32, f32, f32), steps: usize) -> Self {
225 Self {
226 a: coefficients.0,
227 b: coefficients.1,
228 c: coefficients.2,
229 steps,
230 }
231 }
232}
233
234#[derive(Clone)]
252pub struct Muon {
253 momentum: Momentum,
254 ns_params: NewtonSchulzParams,
255 weight_decay_penalty: Option<f32>,
256 epsilon: f32,
257 adjust_lr_fn: AdjustLrFn,
258}
259
260impl Muon {
261 fn adjust_lr(&self, lr: LearningRate, shape: &[usize]) -> LearningRate {
278 lr * self.adjust_lr_fn.adjustment_ratio(shape)
279 }
280
281 fn zeropower_via_newtonschulz<const D: usize>(&self, g: Tensor<D>) -> Tensor<D> {
301 let shape = g.shape();
302 let dim_m2 = shape[D - 2];
303 let dim_m1 = shape[D - 1];
304
305 let (mut x, needs_transpose) = if dim_m2 > dim_m1 {
307 (g.swap_dims(D - 2, D - 1), true)
308 } else {
309 (g, false)
310 };
311
312 let norm = x
315 .clone()
316 .powf_scalar(2.0)
317 .sum()
318 .sqrt()
319 .clamp_min(self.epsilon)
320 .unsqueeze();
321
322 x = x.div(norm);
323
324 let NewtonSchulzParams { a, b, c, steps } = self.ns_params;
327
328 for _ in 0..steps {
329 let x_t = x.clone().swap_dims(D - 2, D - 1);
331 let a_matrix = x.clone().matmul(x_t);
332
333 let a_squared = a_matrix.clone().matmul(a_matrix.clone());
335 let b_matrix = a_matrix.mul_scalar(b).add(a_squared.mul_scalar(c));
336
337 x = x.clone().mul_scalar(a).add(b_matrix.matmul(x.clone()));
339 }
340
341 if needs_transpose {
343 x = x.swap_dims(D - 2, D - 1);
344 }
345
346 x
347 }
348}
349
350#[derive(RecordState, Clone, new)]
352pub struct MuonState<const D: usize> {
353 pub momentum: MomentumState<D>,
355}
356
357impl Optimizer for Muon {
358 type State<const D: usize> = MuonState<D>;
359
360 fn step<const D: usize>(
380 &self,
381 lr: LearningRate,
382 tensor: Tensor<D>,
383 grad: Tensor<D>,
384 state: Option<Self::State<D>>,
385 ) -> (Tensor<D>, Option<Self::State<D>>) {
386 assert!(
387 D == 2,
388 "Newton-Schulz iteration requires 2D tensors, got {}D",
389 D
390 );
391
392 let state_momentum = state.map(|s| s.momentum);
394 let (grad, new_momentum_state) = self.momentum.transform(grad, state_momentum);
395
396 let update = self.zeropower_via_newtonschulz(grad);
398
399 let adjusted_lr = self.adjust_lr(lr, &tensor.shape());
401
402 let tensor = if let Some(penalty) = self.weight_decay_penalty {
405 let decay_factor = 1.0 - lr * penalty as f64;
406 tensor.mul_scalar(decay_factor)
407 } else {
408 tensor
409 };
410
411 let delta = update.mul_scalar(adjusted_lr);
413 let new_state = MuonState::new(new_momentum_state);
414
415 (tensor - delta, Some(new_state))
416 }
417
418 fn to_device<const D: usize>(mut state: Self::State<D>, device: &Device) -> Self::State<D> {
419 state.momentum = state.momentum.to_device(device);
420 state
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::{GradientsParams, Optimizer};
428 use burn::module::Param;
429 use burn::tensor::{Distribution, Tensor, TensorData};
430 use burn_nn::{Linear, LinearConfig};
431
432 const TOLERANCE: f64 = 1e-8;
433
434 fn given_linear_layer_no_bias(weight: TensorData) -> Linear {
435 let device = Device::default().autodiff();
436 Linear {
437 weight: Param::from_data(weight, &device),
438 bias: None, }
440 }
441
442 #[test]
443 fn test_adjust_lr_fn_original() {
444 let method = AdjustLrFn::Original;
445
446 let ratio = method.adjustment_ratio(&[512, 512]);
448 assert!((ratio - 1.0).abs() < TOLERANCE);
449
450 let ratio = method.adjustment_ratio(&[1024, 512]);
452 let expected = (2.0f64).sqrt();
453 assert!((ratio - expected).abs() < TOLERANCE);
454
455 let ratio = method.adjustment_ratio(&[512, 1024]);
457 assert!((ratio - 1.0).abs() < TOLERANCE);
458 }
459
460 #[test]
461 fn test_adjust_lr_fn_match_rms_adamw() {
462 let method = AdjustLrFn::MatchRmsAdamW;
463
464 let ratio = method.adjustment_ratio(&[1024, 512]);
466 let expected = 0.2 * 1024.0f64.sqrt();
467 assert!((ratio - expected).abs() < TOLERANCE);
468
469 let ratio = method.adjustment_ratio(&[512, 512]);
471 let expected = 0.2 * 512.0f64.sqrt();
472 assert!((ratio - expected).abs() < TOLERANCE);
473 }
474
475 #[test]
476 #[should_panic(expected = "Newton-Schulz iteration requires 2D tensors, got 1D")]
477 fn test_1d_tensor_panics() {
478 let device = Default::default();
479 let config = MuonConfig::new();
480 let optim = Muon {
481 momentum: Momentum::new(&config.momentum),
482 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
483 weight_decay_penalty: None,
484 epsilon: config.epsilon,
485 adjust_lr_fn: config.adjust_lr_fn,
486 };
487
488 let tensor_1d = Tensor::<1>::zeros([512], &device);
489 let grad_1d = Tensor::<1>::ones([512], &device);
490
491 let _ = optim.step(0.01, tensor_1d, grad_1d, None);
492 }
493
494 #[test]
495 fn test_muon_optimizer_save_load_state() {
496 let device = Device::default().autodiff();
497 let linear = LinearConfig::new(6, 6)
499 .with_bias(false) .init(&device);
501
502 let x = Tensor::<2>::random([2, 6], Distribution::Default, &device);
503
504 let mut optimizer = MuonConfig::new().init();
505 let grads = linear.forward(x).backward();
506 let grads = GradientsParams::from_grads(grads, &linear);
507 let _linear = optimizer.step(0.01.into(), linear, grads);
508
509 let state_before = optimizer.to_record();
510 let bytes = optimizer.into_bytes().unwrap();
511
512 let optimizer_loaded = MuonConfig::new().init().from_bytes(bytes).unwrap();
513 let state_after = optimizer_loaded.to_record();
514
515 assert_eq!(state_before.len(), state_after.len());
516 }
517
518 #[test]
519 fn test_muon_with_weight_decay() {
520 let device = Device::default().autodiff();
521 let linear = given_linear_layer_no_bias(TensorData::from([
523 [1.0, 1.0, 1.0, 1.0],
524 [1.0, 1.0, 1.0, 1.0],
525 [1.0, 1.0, 1.0, 1.0],
526 [1.0, 1.0, 1.0, 1.0],
527 ]));
528
529 let x = Tensor::<2>::from_floats([[0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]], &device)
530 .require_grad();
531
532 let mut optimizer = MuonConfig::new()
533 .with_weight_decay(Some(WeightDecayConfig::new(0.01)))
534 .init();
535
536 let grads = linear.forward(x).backward();
537 let grads = GradientsParams::from_grads(grads, &linear);
538 let linear = optimizer.step(0.01.into(), linear, grads);
539
540 let state = linear;
541 let weight = state.weight.to_data();
542
543 for val in weight.as_slice::<f32>().unwrap() {
544 assert!(
545 *val < 1.0,
546 "Weight should be reduced by weight decay, got {}",
547 val
548 );
549 }
550 }
551
552 #[test]
553 fn test_newton_schulz_orthogonalization() {
554 let device = Default::default();
555 let matrix = Tensor::<2>::from_floats([[1.0, 0.5], [0.5, 1.0]], &device);
556
557 let config = MuonConfig::new();
558 let muon = Muon {
559 momentum: Momentum::new(&config.momentum),
560 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
561 weight_decay_penalty: None,
562 epsilon: config.epsilon,
563 adjust_lr_fn: config.adjust_lr_fn,
564 };
565
566 let orthogonalized = muon.zeropower_via_newtonschulz(matrix);
567 let o_t = orthogonalized.clone().transpose();
568 let product = orthogonalized.matmul(o_t);
569
570 let data = product.into_data();
571 let values = data.as_slice::<f32>().unwrap();
572
573 assert!(
574 (values[0] - 1.0).abs() < 0.1,
575 "Product[0,0] should be ~1.0, got {}",
576 values[0]
577 );
578 assert!(
579 (values[3] - 1.0).abs() < 0.1,
580 "Product[1,1] should be ~1.0, got {}",
581 values[3]
582 );
583 }
584
585 #[test]
586 fn test_tall_matrix_transpose() {
587 let device = Default::default();
590
591 let tall_matrix = Tensor::<2>::from_floats(
593 [
594 [1.0, 0.5, 0.3, 0.2],
595 [0.5, 1.0, 0.4, 0.1],
596 [0.3, 0.4, 1.0, 0.5],
597 [0.2, 0.1, 0.5, 1.0],
598 [0.1, 0.2, 0.3, 0.4],
599 [0.4, 0.3, 0.2, 0.1],
600 [0.2, 0.4, 0.1, 0.3],
601 [0.3, 0.1, 0.4, 0.2],
602 ],
603 &device,
604 );
605
606 let config = MuonConfig::new();
607 let muon = Muon {
608 momentum: Momentum::new(&config.momentum),
609 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
610 weight_decay_penalty: None,
611 epsilon: config.epsilon,
612 adjust_lr_fn: config.adjust_lr_fn,
613 };
614
615 let orthogonalized = muon.zeropower_via_newtonschulz(tall_matrix.clone());
617
618 let original_shape = tall_matrix.shape();
620 let result_shape = orthogonalized.shape();
621 assert_eq!(
622 original_shape.dims::<2>(),
623 result_shape.dims::<2>(),
624 "Shape should be preserved: [8, 4]"
625 );
626
627 let original_data = tall_matrix.into_data();
629 let result_data = orthogonalized.into_data();
630 assert_ne!(
631 original_data.as_slice::<f32>().unwrap(),
632 result_data.as_slice::<f32>().unwrap(),
633 "Orthogonalized matrix should differ from input"
634 );
635
636 let wide_matrix = Tensor::<2>::from_floats(
638 [
639 [1.0, 0.5, 0.3, 0.2, 0.1, 0.4, 0.2, 0.3],
640 [0.5, 1.0, 0.4, 0.1, 0.2, 0.3, 0.4, 0.1],
641 [0.3, 0.4, 1.0, 0.5, 0.3, 0.2, 0.1, 0.4],
642 [0.2, 0.1, 0.5, 1.0, 0.4, 0.1, 0.3, 0.2],
643 ],
644 &device,
645 );
646
647 let orthogonalized_wide = muon.zeropower_via_newtonschulz(wide_matrix.clone());
648
649 let wide_original_shape = wide_matrix.shape();
651 let wide_result_shape = orthogonalized_wide.shape();
652 assert_eq!(
653 wide_original_shape.dims::<2>(),
654 wide_result_shape.dims::<2>(),
655 "Wide matrix shape should be preserved: [4, 8]"
656 );
657 }
658
659 #[test]
660 fn test_zero_gradient() {
661 let device = Default::default();
663
664 let tensor = Tensor::<2>::from_floats(
665 [
666 [1.0, 0.5, 0.3, 0.2],
667 [0.5, 1.0, 0.4, 0.1],
668 [0.3, 0.4, 1.0, 0.5],
669 [0.2, 0.1, 0.5, 1.0],
670 ],
671 &device,
672 );
673
674 let zero_grad = Tensor::<2>::zeros([4, 4], &device);
676
677 let config = MuonConfig::new();
678 let muon = Muon {
679 momentum: Momentum::new(&config.momentum),
680 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
681 weight_decay_penalty: None,
682 epsilon: config.epsilon,
683 adjust_lr_fn: config.adjust_lr_fn,
684 };
685
686 let (updated_tensor, state) = muon.step(0.01, tensor.clone(), zero_grad, None);
688
689 assert!(state.is_some());
691
692 let original_data = tensor.into_data();
694 let updated_data = updated_tensor.clone().into_data();
695
696 let original_vals = original_data.as_slice::<f32>().unwrap();
697 let updated_vals = updated_data.as_slice::<f32>().unwrap();
698
699 for (orig, upd) in original_vals.iter().zip(updated_vals.iter()) {
700 assert!(
701 (orig - upd).abs() < 1e-6,
702 "With zero gradient, tensor should remain unchanged (or very close)"
703 );
704 }
705
706 for val in updated_vals {
708 assert!(
709 !val.is_nan(),
710 "Result should not contain NaN values with zero gradient"
711 );
712 }
713
714 let muon_with_decay = Muon {
716 momentum: Momentum::new(&config.momentum),
717 ns_params: NewtonSchulzParams::new(config.ns_coefficients, config.ns_steps),
718 weight_decay_penalty: Some(0.01),
719 epsilon: config.epsilon,
720 adjust_lr_fn: config.adjust_lr_fn,
721 };
722
723 let tensor2 = Tensor::<2>::from_floats(
724 [
725 [1.0, 0.5, 0.3, 0.2],
726 [0.5, 1.0, 0.4, 0.1],
727 [0.3, 0.4, 1.0, 0.5],
728 [0.2, 0.1, 0.5, 1.0],
729 ],
730 &device,
731 );
732 let zero_grad2 = Tensor::<2>::zeros([4, 4], &device);
733
734 let (updated_tensor_decay, _) =
735 muon_with_decay.step(0.01, tensor2.clone(), zero_grad2, None);
736
737 let updated_decay_data = updated_tensor_decay.into_data();
739 let updated_decay_vals = updated_decay_data.as_slice::<f32>().unwrap();
740
741 for val in updated_decay_vals {
742 assert!(
743 !val.is_nan(),
744 "Result should not contain NaN with zero gradient and weight decay"
745 );
746 }
747
748 let original_vals2 = tensor2.into_data().as_slice::<f32>().unwrap().to_vec();
750 for (orig, upd) in original_vals2.iter().zip(updated_decay_vals.iter()) {
751 if orig.abs() > 1e-6 {
752 assert!(
754 upd.abs() < orig.abs(),
755 "Weight decay should reduce magnitude: original={}, updated={}",
756 orig,
757 upd
758 );
759 }
760 }
761 }
762}