Refactor of pre/post weight update hook functions - #224
Conversation
Signed-off-by: mikail <mkhona@nvidia.com>
Greptile SummaryThe PR introduces reusable pre/post weight-update hooks and migrates MuonHyperball onto the shared hook mechanism.
Confidence Score: 4/5The 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
Sequence DiagramsequenceDiagram
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
Reviews (3): Last reviewed commit: "Merge branch 'NVIDIA-NeMo:main' into mkh..." | Re-trigger Greptile |
| 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 | ||
| ) |
There was a problem hiding this comment.
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.
| 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 |
Signed-off-by: mikail <mkhona@nvidia.com>
skyw
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
name is too long, two update in the name.
| __all__ = ["NoOpWeightUpdateHook", "WeightUpdateHook"] | ||
|
|
||
|
|
||
| class WeightUpdateHook(Protocol): |
There was a problem hiding this comment.
@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)) |
There was a problem hiding this comment.
detach is not necessary as all of our optimizers are wrapped in no_grad.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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, |
| 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 |
There was a problem hiding this comment.
same as before, don't use .item().
| """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"): |
There was a problem hiding this comment.
This is better to be a KeyError,
| 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) |
There was a problem hiding this comment.
Good to test exact match.
We should probably have a partial function called assert_equal given it is so widely used.
| 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: |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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>
| scale = torch.where(is_numerical_zero, torch.zeros_like(norm), radius / norm.clamp_min(self.eps)) | ||
| tensor.mul_(scale.to(dtype=tensor.dtype)) |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Bottom line is it can be merged after trimming the tests.
|
|
||
|
|
||
| __all__ = [ | ||
| "Hyperball", |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 |
| """ | ||
|
|
||
| def __init__(self, eps: float = 1e-15) -> None: | ||
| if not math.isfinite(eps) or eps <= 0.0: |
There was a problem hiding this comment.
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)) |
| 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)) |
There was a problem hiding this comment.
Same as above. always comment hardcoded test target.
| @@ -0,0 +1,234 @@ | |||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |||
There was a problem hiding this comment.
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.
Weight Update Hooks
This change adds a small
weight_update_hookslibrary for reusable behavior around an optimizer's final in-place parameter update. A hook is a configured object passed into an optimizer, for example: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_stateis a transient value returned by the pre hook and immediately consumed by the post hook for the same parameter. It is not stored inoptimizer.state, so it does not pollute checkpoints.The library currently provides three hook implementations, including the newly added
RadialBrakebased 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 weightw_prevand updated weightw, it sets:where
s = outward_scale_factorif the update increases the norm, otherwises = inward_scale_factor.MuonHyperballnow uses this shared hook machinery by passingHyperball(radius=hyperball_radius, eps=hyperball_eps)intoMuon, instead of implementing custom pre/post update methods and storing temporary values in optimizer state.