Skip to content

Commit b0fcdc1

Browse files
authored
Add option to normalize NS input in double precision (#238)
* Add safer normalization in NS Signed-off-by: Hao Wu <skyw@nvidia.com>
1 parent 9ad154b commit b0fcdc1

2 files changed

Lines changed: 36 additions & 31 deletions

File tree

emerging_optimizers/orthogonalized_optimizers/muon_utils.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,21 @@ def get_coefficient_iterator(
129129
return islice(base, steps)
130130

131131

132-
def distributed_normalize_p2(x: torch.Tensor, eps: float, group: torch.distributed.ProcessGroup) -> torch.Tensor:
133-
"""Normalize a tensor in a distributed way."""
134-
x_sq_sum = (x * x).sum()
132+
def distributed_normalize_p2(
133+
x: torch.Tensor, eps: float, group: torch.distributed.ProcessGroup, normalize_in_double: bool = False
134+
) -> torch.Tensor:
135+
"""Normalize a tensor by its distributed Frobenius norm.
136+
137+
When ``normalize_in_double`` is set, the squared sum is accumulated in float64 so that tiny
138+
entries do not underflow to zero when squared in float32.
139+
"""
140+
x_sq = x.double() if normalize_in_double else x
141+
x_sq_sum = (x_sq * x_sq).sum()
135142
torch.distributed.all_reduce(x_sq_sum, op=torch.distributed.ReduceOp.SUM, group=group)
136-
return x / torch.sqrt(x_sq_sum).clamp_min(eps)
143+
norm = torch.sqrt(x_sq_sum).to(x.dtype)
144+
if not normalize_in_double:
145+
norm.clamp_min_(eps)
146+
return x / norm
137147

138148

139149
def newton_schulz(
@@ -145,6 +155,7 @@ def newton_schulz(
145155
transpose: bool | None = None,
146156
tp_group: torch.distributed.ProcessGroup | None = None,
147157
use_syrk: bool = False,
158+
normalize_in_double: bool = False,
148159
) -> torch.Tensor:
149160
"""Use Newton-Schulz iteration to compute the zeroth power / orthogonalization of x.
150161
@@ -177,6 +188,10 @@ def newton_schulz(
177188
If None, will be determined based on the size of the tensor.
178189
tp_group: The process group for communication if input is distributed.
179190
use_syrk: Whether to use the Triton kernel for the Newton-Schulz iteration.
191+
normalize_in_double: Whether to reduce the Frobenius norm in float64. This keeps the squared
192+
sum out of float32 underflow for inputs with very small entries, at the cost of a float64
193+
reduction. Without customized kernels, manually handle scaling without triggering a device to host
194+
sync are usually more expensive than using double.
180195
181196
Returns:
182197
The orthogonalization of x.
@@ -192,13 +207,17 @@ def newton_schulz(
192207
if transpose:
193208
x = x.mT
194209

195-
# Ensure spectral norm is at most 1.
196-
# NOTE: ``eps`` is a divide-by-zero guard; it must stay well below any realistic ``||x||_F``
197-
# yet remain fp32-safe when squared. See issue #229.
210+
# Ensure spectral norm is at most 1 by normalizing with the Frobenius norm. Reducing in float64
211+
# (``normalize_in_double``) keeps the squared sum out of float32 underflow for tiny-norm inputs.
198212
if tp_group is not None:
199-
X = distributed_normalize_p2(x, eps, tp_group)
213+
X = distributed_normalize_p2(x, eps, tp_group, normalize_in_double)
200214
else:
201-
X = torch.nn.functional.normalize(x, p=2, dim=(-2, -1), eps=eps) # type: ignore[arg-type]
215+
if not normalize_in_double:
216+
X = torch.nn.functional.normalize(x, p=2, dim=(-2, -1), eps=eps) # type: ignore[arg-type]
217+
else:
218+
# eps is ignored when normalize in double.
219+
norm = torch.linalg.vector_norm(x, dim=(-2, -1), keepdim=True, dtype=torch.float64).to(x.dtype)
220+
X = x / norm
202221

203222
if coefficient_type in _COEFFICIENT_SETS:
204223
coefficient_sets = _COEFFICIENT_SETS[coefficient_type]

tests/test_muon_utils.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -121,28 +121,14 @@ def test_newtonschulz5_close_to_reference(self, dim1, dim2):
121121
rtol=1e-7,
122122
)
123123

124-
@parameterized.parameters(1e-2, 1e-6, 1e-9, 1e-12)
125-
def test_newtonschulz_small_eps(self, scale):
126-
"""Orthogonalization depends only on direction, so scaling the input must not change the output.
127-
128-
Regression test for issue #229: a too-large ``eps`` in the internal ``F.normalize`` divides
129-
small-norm inputs by ``eps`` instead of their norm, silently degenerating the output. The
130-
orthogonalized result for ``x`` and ``scale * x`` must match for any ``scale > 0``.
131-
"""
132-
x = torch.randn(256, 256, device=self.device, dtype=torch.float32)
133-
x = x / x.norm() # unit Frobenius norm direction
134-
ref = muon_utils.newton_schulz(x, steps=5, coefficient_type="quintic")
135-
out = muon_utils.newton_schulz(scale * x, steps=5, coefficient_type="quintic")
136-
torch.testing.assert_close(
137-
out,
138-
ref,
139-
atol=1e-4,
140-
rtol=1e-5,
141-
msg=lambda m: (
142-
f"newton_schulz not scale-invariant at input scale {scale}: "
143-
f"||out||_F={out.norm().item():.4f} vs ||ref||_F={ref.norm().item():.4f}\n{m}"
144-
),
145-
)
124+
def test_preserve_values_with_underflowed_norm_in_fp64(self):
125+
scale = 1e-30
126+
x = torch.randn(256, 256, device=self.device, dtype=torch.float32) * scale
127+
assert torch.linalg.vector_norm(x) == 0 # should underflow
128+
norm_ref = torch.linalg.vector_norm(x, dtype=torch.double)
129+
assert norm_ref != 0
130+
out = muon_utils.newton_schulz(x, steps=0, normalize_in_double=True)
131+
torch.testing.assert_close(x / norm_ref, out, atol=0, rtol=1e-6)
146132

147133
@parameterized.parameters(
148134
(2, 256, 256),

0 commit comments

Comments
 (0)