Skip to content

Refactor of pre/post weight update hook functions - #224

Open
mkhona-nvidia wants to merge 5 commits into
NVIDIA-NeMo:mainfrom
mkhona-nvidia:mkhona/pre_post_weight_update_refactors
Open

Refactor of pre/post weight update hook functions#224
mkhona-nvidia wants to merge 5 commits into
NVIDIA-NeMo:mainfrom
mkhona-nvidia:mkhona/pre_post_weight_update_refactors

Conversation

@mkhona-nvidia

Copy link
Copy Markdown
Contributor

Weight Update Hooks

This change adds a small weight_update_hooks library for reusable behavior around an optimizer's final in-place parameter update. A hook is a configured object passed into an optimizer, for example:

from emerging_optimizers.weight_update_hooks import RadialBrake

optimizer = Muon(
    params,
    weight_decay=0.0,
    weight_update_hook=RadialBrake(outward_scale_factor=0.5),
)

The optimizer owns only one hook object. Hook-specific arguments live on the hook constructor, not on the optimizer constructor. The base update flow is:

pre_update_state = weight_update_hook.pre_weight_update_inplace(p, update)
p.add_(update, alpha=-lr)
post_weight_update_fn_inplace(p)
weight_update_hook.post_weight_update_inplace(p, pre_update_state)

pre_update_state is a transient value returned by the pre hook and immediately consumed by the post hook for the same parameter. It is not stored in optimizer.state, so it does not pollute checkpoints.

The library currently provides three hook implementations, including the newly added RadialBrake based on https://nilin.github.io/radial-brake/:

  • NoOpWeightUpdateHook: default no-op behavior.
  • Hyperball: normalizes the update to a target radius before the update, then projects the updated weight back to that radius.
  • RadialBrake: applies the normal optimizer update, then rescales the updated weight so radial norm changes are damped. For pre-update weight w_prev and updated weight w, it sets:
$$\|w_{\text{brake}}\| = \|w_{\text{prev}}\| + s(\|w\| - \|w_{\text{prev}}\|)$$

where s = outward_scale_factor if the update increases the norm, otherwise s = inward_scale_factor.

MuonHyperball now uses this shared hook machinery by passing Hyperball(radius=hyperball_radius, eps=hyperball_eps) into Muon, instead of implementing custom pre/post update methods and storing temporary values in optimizer state.

Signed-off-by: mikail <mkhona@nvidia.com>
@mkhona-nvidia
mkhona-nvidia requested a review from skyw June 4, 2026 18:10
@copy-pr-bot

copy-pr-bot Bot commented Jun 4, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@mkhona-nvidia mkhona-nvidia changed the title refactor of pre/post weight update hook functions Refactor of pre/post weight update hook functions Jun 4, 2026
@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR introduces reusable pre/post weight-update hooks and migrates MuonHyperball onto the shared hook mechanism.

  • Adds Hyperball, RadialBrake, RelativeUpdate, and no-op hook implementations.
  • Integrates hook execution into the shared orthogonalized optimizer step.
  • Adds validation and focused tests for hook behavior.
  • The Hyperball zero-norm fix can still leave a parameter at zero rather than its configured radius.

Confidence Score: 4/5

The PR is not yet safe to merge because Hyperball can leave an updated parameter at zero instead of restoring its configured radius.

The shared Hyperball scaling helper selects a zero multiplier when the post-update parameter norm is below eps, so an exactly cancelling update violates the optimizer's fixed-radius invariant.

Files Needing Attention: emerging_optimizers/weight_update_hooks/hyperball.py

Important Files Changed

Filename Overview
emerging_optimizers/weight_update_hooks/hyperball.py Adds fixed-radius normalization, but its zero-norm post-update branch leaves parameters at zero and violates the radius constraint.
emerging_optimizers/orthogonalized_optimizers/orthogonalized_optimizer.py Integrates transient pre/post hook execution around the final in-place parameter update.
emerging_optimizers/orthogonalized_optimizers/muon_hyperball.py Migrates MuonHyperball to the shared Hyperball hook and retains construction-time radius validation.
emerging_optimizers/weight_update_hooks/radial_brake.py Adds bounded radial norm damping and now prevents amplifying scale factors.
emerging_optimizers/weight_update_hooks/relative_update.py Adds pre-update scaling that matches update norm to the current weight norm.
tests/test_weight_update_hooks.py Covers normal, boundary, and validation behavior for the new hooks.

Sequence Diagram

sequenceDiagram
    participant Step as OrthogonalizedOptimizer.step
    participant Hook as WeightUpdateHook
    participant Param as Parameter
    Step->>Hook: pre_weight_update_inplace(p, update)
    Hook-->>Step: transient hook state
    Step->>Param: "p.add_(update, alpha=-lr)"
    Step->>Step: optimizer-specific post hook
    Step->>Hook: post_weight_update_inplace(p, state)
    Hook->>Param: project/rescale updated parameter
Loading

Reviews (3): Last reviewed commit: "Merge branch 'NVIDIA-NeMo:main' into mkh..." | Re-trigger Greptile

Comment on lines +41 to +49
current_norm = torch.linalg.vector_norm(p.detach().to(torch.float32))
if current_norm.item() == 0:
raise ValueError("Hyperball requires all parameters to have non-zero norm.")

radius = (
torch.as_tensor(self.radius, device=p.device, dtype=torch.float32)
if self.radius is not None
else current_norm
)

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 The zero-norm guard fires even when self.radius is not None, but in that branch current_norm is never used as the radius — it is only assigned to radius when self.radius is None. A fixed-radius Hyperball applied to a parameter that starts at zero (e.g. a zero-initialized bias) will therefore raise a ValueError on the very first optimizer step, even though the fixed-radius path has no mathematical dependency on the pre-update parameter norm.

Suggested change
current_norm = torch.linalg.vector_norm(p.detach().to(torch.float32))
if current_norm.item() == 0:
raise ValueError("Hyperball requires all parameters to have non-zero norm.")
radius = (
torch.as_tensor(self.radius, device=p.device, dtype=torch.float32)
if self.radius is not None
else current_norm
)
current_norm = torch.linalg.vector_norm(p.detach().to(torch.float32))
if self.radius is not None:
radius = torch.as_tensor(self.radius, device=p.device, dtype=torch.float32)
else:
if current_norm.item() == 0:
raise ValueError("Hyperball requires all parameters to have non-zero norm when radius is not fixed.")
radius = current_norm

Comment thread emerging_optimizers/weight_update_hooks/radial_brake.py Outdated
Comment thread emerging_optimizers/orthogonalized_optimizers/muon_hyperball.py
Signed-off-by: mikail <mkhona@nvidia.com>

@skyw skyw left a comment

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.

Code logic LGTM. A lot of test names can be improved, not super critical.

Before merge, please get familiar with protocol, i.e. being able to explain what purpose it serves here and why not use subclass.

One critical thing is 0 handling, i.e. epsilon.

  • at least, it should be consistently applied, for vector_norm for example.
  • Comparing a floating point number directly against zero (a == 0 for example) is actually testing underflow, not numerical 0. Same eps apply, if numbers smaller than eps are considered 0, logical test to determine a numerical 0 should be abs(a) < eps
  • Magnitude aware handling maybe out of scope, but something to consider as we running into this more and more.


# perform weight update with pre and post weight update functions for subclass customization
self.pre_weight_update_fn_inplace(p, orth_grad)
weight_update_hook_pre_update_state = self.weight_update_hook.pre_weight_update_inplace(p, orth_grad)

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.

name is too long, two update in the name.

__all__ = ["NoOpWeightUpdateHook", "WeightUpdateHook"]


class WeightUpdateHook(Protocol):

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.

@mkhona-nvidia I don't oppose using Protocol. But I think need to get yourself comfortable with PEP544 before this can be merged.

p: torch.Tensor,
update: torch.Tensor,
) -> torch.Tensor:
current_norm = torch.linalg.vector_norm(p.detach().to(torch.float32))

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.

detach is not necessary as all of our optimizers are wrapped in no_grad.

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.

Actually, vector_norm has an dtype argument, explicit to fp32 is not necessary.

It technically not dtype but compute type though, don't know who added it to pytorch.

if self.radius is not None:
radius = torch.as_tensor(self.radius, device=p.device, dtype=torch.float32)
else:
if current_norm.item() == 0:

@skyw skyw Jun 22, 2026

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.

This triggers synced device to host copy. Can't have it in every call. We may have it already, but it needs to be fixed. Otherwise can't use in any pratical runs.

def __init__(
self,
radius: float | None = None,
eps: float = 1e-8,

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.

Maybe reduce this to 1e-15?

pre_norm = pre_update_state
post_norm = torch.linalg.vector_norm(p.detach().to(torch.float32))
norm_delta = post_norm - pre_norm
scale_factor = self.outward_scale_factor if norm_delta.item() > 0 else self.inward_scale_factor

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.

same as before, don't use .item().

Comment thread tests/test_orthogonalized_optimizer.py Outdated
"""MuonHyperball manages its own Hyperball hook internally."""
test_param = nn.Parameter(torch.randn((5, 7), dtype=torch.float32, device=self.device))

with self.assertRaisesRegex(TypeError, "does not accept a 'weight_update_hook' argument"):

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.

This is better to be a KeyError,

Comment thread tests/test_weight_update_hooks.py Outdated
pre_update_state = hook.pre_weight_update_inplace(param, update)
hook.post_weight_update_inplace(param, pre_update_state)

torch.testing.assert_close(param, param_before, atol=0.0, rtol=0.0)

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.

Good to test exact match.

We should probably have a partial function called assert_equal given it is so widely used.

Comment thread tests/test_weight_update_hooks.py Outdated
torch.testing.assert_close(param, param_before, atol=0.0, rtol=0.0)
torch.testing.assert_close(update, update_before, atol=0.0, rtol=0.0)

def test_radial_brake_dampens_outward_norm_change(self) -> None:

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.

Suggestion: always state expected behavior in test name. e.g. in this case, what close to what. "change" is too vague of a behavior.

weight_decay_method: opt_mixin.WeightDecayT,
fp32_matmul_prec: FP32MatmulPrecT,
scaled_orthogonalize_fn: Callable | None = None,
weight_update_hook: WeightUpdateHook | None = None,

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.

Let's make it private, _weight_update_hook, to suggest it is not supposed to be modified after initialization.

Hook easily invites abuse. making it private at least making people aware of the abusing. A setter can be provided if we want to support properly change the hook inflight.

Signed-off-by: mkhona <mkhona@nvidia.com>
Signed-off-by: mkhona <mkhona@nvidia.com>
Comment on lines +46 to +47
scale = torch.where(is_numerical_zero, torch.zeros_like(norm), radius / norm.clamp_min(self.eps))
tensor.mul_(scale.to(dtype=tensor.dtype))

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 Zero projection breaks radius constraint

When the normalized update exactly cancels the parameter, the post-update norm falls below eps and this branch multiplies the parameter by zero. The hook therefore leaves the parameter at zero instead of projecting it back to the configured nonzero radius.

Knowledge Base Used: Orthogonalized Optimizers

@skyw skyw left a comment

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.

Bottom line is it can be merged after trimming the tests.



__all__ = [
"Hyperball",

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.

Q: why some has Hook while others don't?
If there is no particular reason, make naming consistent.

class WeightUpdateHook(Protocol[HookStateT]):
"""Static structural contract for behavior around the final weight update.

PEP 544 structural typing lets third-party hooks satisfy this interface without

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.

too verbose and not always accurate, remove.

scale_mode: MuonScaleT = "spectral",
extra_scale_factor: float = 1.0,
use_syrk: bool = False,
weight_update_hook: WeightUpdateHook[Any] | None = None,

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.

Q: what's the reason to use Any while a HookStateT is defined?
can set bound to HookStateT to accept known types it needs to handle.

class RadialBrake:
"""Dampen radial norm changes after an optimizer update.

The optimizer first applies its usual update ``w = w_prev + dw``. This hook then rescales ``w`` so that

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.

Add argument section.

"""

def __init__(self, eps: float = 1e-15) -> None:
if not math.isfinite(eps) or eps <= 0.0:

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.

This is overkill, check magnitude on eps would be enough.
It would be strange to check if values are finite at one place while not everywhere else

param.add_(update)
hook.post_weight_update_inplace(param, pre_update_state)

torch.testing.assert_close(torch.linalg.vector_norm(param), torch.tensor(7.5, device=self.device))

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.

Comment why it should be 7.5

param.add_(update)
hook.post_weight_update_inplace(param, pre_update_state)

torch.testing.assert_close(torch.linalg.vector_norm(param), torch.tensor(9.0, device=self.device))

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.

Same as above. always comment hardcoded test target.

@@ -0,0 +1,234 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.

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.

Too many hardcoded simple tests don't do a lot different, please trim.

Also think about how to improve tests coverage, includes but not limits to:

  • Coverage, a lot of tests only test shape 2 input.
  • Could it be exact match? can manipulate to value to have exactly representable norm for example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants