Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 164 additions & 80 deletions python/paddle/optimizer/muon.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

if TYPE_CHECKING:
from collections.abc import Callable
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING

Expand All @@ -40,6 +41,9 @@
# Debug logging for Muon optimizer
_logger = logging.getLogger(__name__)
MUON_DEBUG = os.environ.get("MUON_DEBUG", "0") == "1"
g_shard_bypass_dygraph_optimizer = int(
os.environ.get("FLAGS_shard_bypass_dygraph_optimizer", 0)
)

__all__ = []

Expand Down Expand Up @@ -150,6 +154,15 @@ def _default_should_use_muon(name, shape, exclude_patterns):
return True


def _to_hashable(value):
"""Recursively convert lists/tuples/dicts into hashable tuples."""
if isinstance(value, (list, tuple)):
return tuple(_to_hashable(v) for v in value)
if isinstance(value, dict):
return tuple((k, _to_hashable(v)) for k, v in sorted(value.items()))
return value


class Muon(Optimizer):
r"""
Muon optimizer for MuonShardingOptimizer (Sharding Stage1 V3) usage.
Expand Down Expand Up @@ -519,52 +532,64 @@ def _adamw_update(
False, # amsgrad
)

def _muon_update(
def _muon_update_group(
self,
param,
grad,
group_params_grads,
lr,
momentum_buffer,
momentum_beta,
ns_steps,
nesterov,
epsilon,
weight_decay,
version,
):
"""In-place Muon update for a 2D parameter tensor.
"""Batched Muon update for a group of parameters with identical shape and split_concat_func."""
# Because shape is identical, use the first param's properties
param0, _ = group_params_grads[0]
param_shape = getattr(param0, "original_shape", param0.shape)
is_3d = len(param_shape) == 3

Applies Newton-Schulz orthogonalisation to the 2D weight matrix and
updates the parameter in-place. MuonShardingOptimizer assigns whole
2D tensors to ranks, so no sharding gather or TP communication is needed.
"""
param_shape = getattr(param, "original_shape", param.shape)
param_info = self._muon_param_info_map.get(param.name)
param_info = self._muon_param_info_map.get(param0.name)
split_concat_func = param_info.split_concat_func if param_info else None

matrix_2d_list = []
find_master_list = []

with paddle.no_grad():
grad_f32 = (
grad.astype(momentum_buffer.dtype)
if grad.dtype != momentum_buffer.dtype
else grad
)
# --- Pass 1: Sequential momentum update & preparation ---
for param, grad in group_params_grads:
momentum_buffer = self._get_accumulator(
self._moment_acc_str, param
)
grad_f32 = (
grad.astype(momentum_buffer.dtype)
if grad.dtype != momentum_buffer.dtype
else grad
)

# Step 1: Momentum update
new_momentum = paddle.lerp(
momentum_buffer, grad_f32, 1.0 - momentum_beta
)
paddle.assign(new_momentum, momentum_buffer)
update_buffer = (
paddle.lerp(grad_f32, momentum_buffer, momentum_beta)
if nesterov
else momentum_buffer
)
new_momentum = paddle.lerp(
momentum_buffer, grad_f32, 1.0 - momentum_beta
)
paddle.assign(new_momentum, momentum_buffer)
update_buffer = (
paddle.lerp(grad_f32, momentum_buffer, momentum_beta)
if nesterov
else momentum_buffer
)

matrix_2d_global = update_buffer.reshape(param_shape)
matrix_2d_list.append(matrix_2d_global)
find_master_list.append(param.name in self._master_weights)

# Step 2: Reshape update buffer to 2D matrix.
# MuonShardingOptimizer assigns whole 2D tensors to ranks, so params
# are already 2D/3D (no sharding gather needed).
matrix_2d_global = update_buffer.reshape(param_shape)
# --- Pass 2: Batched Newton-Schulz iteration ---
if len(matrix_2d_list) > 1:
if is_3d:
batched_matrix = paddle.concat(matrix_2d_list, axis=0)
else:
batched_matrix = paddle.stack(matrix_2d_list, axis=0)
else:
batched_matrix = matrix_2d_list[0]

# Shared NS + scaling closure (captures ns_steps, epsilon, version, ns_coeffs)
def ortho_fn(m):
ns_out = Muon._zeropower_via_newtonschulz5(
m,
Expand All @@ -578,56 +603,74 @@ def ortho_fn(m):
)
return scaled

# Step 3: Newton-Schulz orthogonalisation
# Use split_concat_func from param_info if provided, otherwise default to whole matrix
if (
param_info is not None
and param_info.split_concat_func is not None
):
# Use slice function defined in model configuration
orthogonal_update = param_info.split_concat_func(
matrix_2d_global, ortho_fn
if split_concat_func is not None:
batched_orthogonal_update = split_concat_func(

This comment was marked as outdated.

batched_matrix, ortho_fn
)
Comment thread
xxyux marked this conversation as resolved.
if MUON_DEBUG:
_global_rank = paddle.distributed.get_rank()
if _global_rank == 0:
_sf = param_info.split_concat_func
_sf = split_concat_func
# split_concat_func may be a plain function or a
# functools.partial; unwrap for readable logging.
_fn = getattr(_sf, "func", _sf)
_logger.info(
f"[Muon] Using split_concat_func: param={param.name}, "
f"split_concat_func={_sf.func.__name__}, "
f"args={_sf.args}, kwargs={_sf.keywords}"
f"[Muon] Using split_concat_func: "
f"params={[p.name for p, _ in group_params_grads]}, "
f"split_concat_func={_fn.__name__}, "
f"args={getattr(_sf, 'args', ())}, "
f"kwargs={getattr(_sf, 'keywords', None)}"
)
else:
# Default: whole matrix orthogonalisation
orthogonal_update = ortho_fn(matrix_2d_global)

find_master = param.name in self._master_weights
master_weight = (
self._master_weights[param.name] if find_master else None
)
batched_orthogonal_update = ortho_fn(batched_matrix)

# Unbatch results
if len(matrix_2d_list) > 1:
if is_3d:
orthogonal_updates = paddle.split(
batched_orthogonal_update,
num_or_sections=len(matrix_2d_list),
axis=0,
)
else:
orthogonal_updates = paddle.unbind(
batched_orthogonal_update, axis=0
)
else:
orthogonal_updates = [batched_orthogonal_update]

# --- Pass 3: Sequential apply weight decay & step ---
for i, (param, _) in enumerate(group_params_grads):
orthogonal_update = orthogonal_updates[i]
find_master = find_master_list[i]
master_weight = (
self._master_weights[param.name] if find_master else None
)

lr_ratio = 1.0 if self._lr_ratio is None else self._lr_ratio(param)
effective_lr = lr * lr_ratio
lr_ratio = (
1.0 if self._lr_ratio is None else self._lr_ratio(param)
)
effective_lr = lr * lr_ratio

with_decay = True
if (
self._apply_decay_param_fun is not None
and not self._apply_decay_param_fun(param.name)
):
with_decay = False
if with_decay and weight_decay > 0:
if find_master:
master_weight.scale_(1.0 - effective_lr * weight_decay)
else:
param.scale_(1.0 - effective_lr * weight_decay)

final_step = orthogonal_update * effective_lr

with_decay = True
if (
self._apply_decay_param_fun is not None
and not self._apply_decay_param_fun(param.name)
):
with_decay = False
if with_decay and weight_decay > 0:
if find_master:
master_weight.scale_(1.0 - effective_lr * weight_decay)
master_weight.subtract_(final_step)
paddle.assign(master_weight.astype(param.dtype), param)
else:
param.scale_(1.0 - effective_lr * weight_decay)

final_step = orthogonal_update * effective_lr

if find_master:
master_weight.subtract_(final_step)
paddle.assign(master_weight.astype(param.dtype), param)
else:
param.subtract_(final_step.astype(param.dtype))
param.subtract_(final_step.astype(param.dtype))

# ------------------------------------------------------------------
# Core optimization step
Expand All @@ -639,6 +682,10 @@ def _apply_optimize(self, loss, startup_program, params_grads):
"Muon optimizer only supports dygraph mode."
)

# Same bypass as the base Optimizer._apply_optimize: skip all parameter updates.
if g_shard_bypass_dygraph_optimizer:
return

if self._grad_clip is not None:
params_grads = self._grad_clip(params_grads)

Expand All @@ -658,26 +705,63 @@ def _apply_optimize(self, loss, startup_program, params_grads):
continue

param_info = self._muon_param_info_map.get(param.name)
assert param_info is not None, (
f"muon_param_info_map does not have {param.name}"
)
use_muon = param_info.use_muon
if param_info is not None:
use_muon = param_info.use_muon
else:
use_muon = _default_should_use_muon(
param.name,
getattr(param, "original_shape", param.shape),
self._muon_exclude_patterns,
)
_logger.warning(
f"muon_param_info_map does not have {param.name}, "
f"falling back to default rule: use_muon={use_muon}"
)

self._ensure_accumulators(param, use_muon, group)
if use_muon:
muon_params.append((param, grad))
else:
adamw_params.append((param, grad))

# --- Pass 1: Muon updates (large temporary tensors) ---
# --- Pass 2: Muon updates ---
lr_tensor = paddle.to_tensor(lr, dtype=paddle.float32)
lr_tensor_f64 = paddle.to_tensor(lr, dtype=paddle.float64)

# Group parameters by split_concat_func and shape, then update
# each group in one batched call.
muon_groups = defaultdict(list)
Comment on lines +731 to +733

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 优先级:P1
处理要求:请针对该评论修复并提交新的 commit。

这里把 Muon 参数无条件分组批处理了:上一版的 MUON_ENABLE_BATCH 开关和逐参数 fallback 已被删除,当前仓库里也没有任何 MUON_ENABLE_BATCH 读取,test_muon_sharding_batched_update_group 也随之移除。但 PR 描述仍说明 group NS iteration 由该环境变量控制,因此未设置环境变量的现有训练现在也会默认走新的 batch 路径,破坏原来的可回退行为,并会放大已有 split_concat_func 输入契约问题。

请恢复开关语义:默认继续逐参数更新,仅在 MUON_ENABLE_BATCH=1 时进入分组 batch 路径。实现形态可以类似:

if not MUON_ENABLE_BATCH:
    for param, grad in muon_params:
        self._muon_update_group([(param, grad)], ...)
else:
    # existing grouping logic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

目前只保留了_muon_update_group函数,删除了_muon_update函数。因为所有case都可以等价替换。不管有没有slice_func,shape是2D还是3D。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个判断我不同意。当前实现里,多个同 shape 的参数会先被合并成 batched_matrix,再交给 split_concat_func;而仓库里的 _qkv_sep / _ffn_split 仍然是按单个 2D 矩阵、axis=1 写的,不能直接把 grouped batch 当成等价输入。

所以删掉 _muon_update 并不自动成立“所有 case 都可等价替换”。如果要保留这个结论,至少需要把带 split_concat_func 的 group 改成 batch-aware 接口,或者对这类 group 保留逐参数路径,并补上多个同 shape、同 slice 函数的回归测试。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个判断我不同意。当前 head 里还有一个更直接的语义问题:lr_ratio/effective_lr 和最后的 subtract_ 已经被移到 for 外面,导致 group 里的前几个参数只做了前面的 decay,真正的 Muon step 只会落到最后一个参数上。这个状态下,哪怕不考虑 split_concat_func_muon_update_group 也不能和逐参数路径视为等价。

我已经在对应代码行补了 inline 评论,请先把这个循环结构修正,再讨论是否可以删除 _muon_updatesplit_concat_func 的 2D 契约问题也仍然需要单独处理。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug MUON_ENABLE_BATCH 没有接入,分组 NS 现在会无条件启用。

PR 描述说 group NS iteration 由 MUON_ENABLE_BATCH 控制,但当前文件全仓搜索不到这个环境变量;这里总是构建 muon_groups,随后所有 Muon 参数都会进入 _muon_update_group。这会把本应可选的优化变成默认行为,用户无法关闭新路径,也无法在 split 函数或数值回归时回退到逐参数更新。

建议修复方式:在模块加载时读取 MUON_ENABLE_BATCH,只有为 1 时才按 (split_concat_func, shape) 聚合;未开启时按 muon_params 顺序逐个调用 _muon_update_group([(param, grad)], ...),并补一组 env 未开启时不聚合的测试。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

目前只保留了_muon_update_group函数,删除了_muon_update函数。因为所有case都可以等价替换。不管有没有slice_func,shape是2D还是3D。

for param, grad in muon_params:
self._muon_update(
param,
grad,
assert len(param.shape) == 2 or len(param.shape) == 3, (
"Muon only supports 2D or 3D parameters."
)
param_shape = getattr(param, "original_shape", param.shape)
param_info = self._muon_param_info_map.get(param.name)

func_key = None
if param_info and param_info.split_concat_func is not None:
func = param_info.split_concat_func
if hasattr(func, "func"):
# functools.partial: group by the underlying function
# object plus all bound args/kwargs.
func_key = (
func.func,
tuple(_to_hashable(v) for v in func.args),
tuple(
(k, _to_hashable(v))
for k, v in sorted((func.keywords or {}).items())
),
)
else:
func_key = func

key = (func_key, tuple(param_shape))
muon_groups[key].append((param, grad))

for key, group_params in muon_groups.items():
self._muon_update_group(
group_params,
lr_tensor,
self._get_accumulator(self._moment_acc_str, param),
group.get("momentum", 0.95),
group.get("ns_steps", 5),
group.get("nesterov", True),
Expand All @@ -686,7 +770,7 @@ def _apply_optimize(self, loss, startup_program, params_grads):
version=group.get("muon_version", 3),
)

# --- Pass 2: AdamW updates ---
# --- Pass 3: AdamW updates ---
for param, grad in adamw_params:
self._adamw_update(
param,
Expand Down
Loading
Loading