-
Notifications
You must be signed in to change notification settings - Fork 641
Fix silent hook_normalized/hook_scale no-op on NormalizationBridge native-autograd path #1527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jlarson4
merged 3 commits into
TransformerLensOrg:main
from
Hetens:fix-native-ln-hook-dispatch
Jul 22, 2026
+306
−35
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
167 changes: 167 additions & 0 deletions
167
tests/unit/model_bridge/generalized_components/test_normalization_hook_semantics.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| """Hook semantics on NormalizationBridge's native-autograd path. | ||
| Regression coverage for #1526: with ``use_native_layernorm_autograd=True`` the | ||
| bridge fired ``hook_scale`` / ``hook_normalized`` on detached observation values | ||
| and discarded their return values — forward edits silently no-oped and backward | ||
| hooks never fired, while the default python-norm path honored both. | ||
| """ | ||
|
|
||
| import warnings | ||
|
|
||
| import pytest | ||
| import torch | ||
| import torch.nn as nn | ||
|
|
||
| # Warnings are matched by message substring, not imported constants, so this file | ||
| # collects (and fails red) on unfixed code where the constants don't exist yet. | ||
| from transformer_lens.model_bridge.generalized_components.normalization import ( | ||
| NormalizationBridge, | ||
| ) | ||
|
|
||
|
|
||
| class _Cfg: | ||
| def __init__(self, uses_rms_norm: bool = False, eps: float = 1e-5): | ||
| self.uses_rms_norm = uses_rms_norm | ||
| self.eps = eps | ||
|
|
||
|
|
||
| class _TinyRMSNorm(nn.Module): | ||
| """Minimal RMSNorm mirroring LlamaRMSNorm's forward.""" | ||
|
|
||
| def __init__(self, d: int, eps: float = 1e-5): | ||
| super().__init__() | ||
| self.weight = nn.Parameter(torch.ones(d)) | ||
| self.variance_epsilon = eps | ||
|
|
||
| def forward(self, x: torch.Tensor) -> torch.Tensor: | ||
| variance = x.pow(2).mean(-1, keepdim=True) | ||
| return self.weight * x * torch.rsqrt(variance + self.variance_epsilon) | ||
|
|
||
|
|
||
| def _layernorm(d: int) -> nn.LayerNorm: | ||
| layer = nn.LayerNorm(d, eps=1e-5) | ||
| nn.init.normal_(layer.weight, std=0.1) | ||
| nn.init.normal_(layer.bias, std=0.1) | ||
| layer.eval() | ||
| return layer | ||
|
|
||
|
|
||
| def _make_bridge(native: bool, rms: bool = False, d: int = 16) -> NormalizationBridge: | ||
| layer: nn.Module = _TinyRMSNorm(d) if rms else _layernorm(d) | ||
| bridge = NormalizationBridge( | ||
| name="ln", | ||
| config=_Cfg(uses_rms_norm=rms), | ||
| use_native_layernorm_autograd=native, | ||
| ) | ||
| bridge.set_original_component(layer) | ||
| return bridge | ||
|
|
||
|
|
||
| def _zero_hook(tensor, hook=None): | ||
| return torch.zeros_like(tensor) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("rms", [False, True], ids=["layernorm", "rmsnorm"]) | ||
| def test_native_path_normalized_edit_propagates(rms): | ||
| """Editing hook_normalized must change the output on the native-autograd path.""" | ||
| bridge = _make_bridge(native=True, rms=rms) | ||
| x = torch.randn(2, 5, 16) | ||
| baseline = bridge(x) | ||
| bridge.hook_normalized.add_hook(_zero_hook) | ||
| with pytest.warns(UserWarning, match="reconstructed from the hooked values"): | ||
| patched = bridge(x) | ||
| bridge.hook_normalized.remove_hooks() | ||
| assert not torch.allclose(baseline, patched) | ||
|
|
||
|
|
||
| def test_native_path_scale_edit_propagates(): | ||
| """Editing hook_scale must change the output on the native-autograd path.""" | ||
| bridge = _make_bridge(native=True) | ||
| x = torch.randn(2, 5, 16) | ||
| baseline = bridge(x) | ||
| bridge.hook_scale.add_hook(lambda t, hook=None: t * 2.0) | ||
| with pytest.warns(UserWarning, match="reconstructed from the hooked values"): | ||
| patched = bridge(x) | ||
| bridge.hook_scale.remove_hooks() | ||
| assert not torch.allclose(baseline, patched) | ||
|
|
||
|
|
||
| def test_native_path_bwd_hook_fires(): | ||
| """Backward hooks on hook_normalized must fire on the native-autograd path.""" | ||
| bridge = _make_bridge(native=True) | ||
| x = torch.randn(2, 5, 16, requires_grad=True) | ||
| fired = {} | ||
| bridge.hook_normalized.add_hook( | ||
| lambda grad, hook=None: fired.setdefault("bwd", True) and grad, dir="bwd" | ||
| ) | ||
| with pytest.warns(UserWarning, match="Backward hooks"): | ||
| out = bridge(x) | ||
| out.sum().backward() | ||
| bridge.hook_normalized.remove_hooks() | ||
| assert fired.get("bwd", False) | ||
|
|
||
|
|
||
| def test_native_path_edited_output_is_grad_connected(): | ||
| """An edited forward must stay differentiable back to the input.""" | ||
| bridge = _make_bridge(native=True) | ||
| x = torch.randn(2, 5, 16, requires_grad=True) | ||
| bridge.hook_scale.add_hook(lambda t, hook=None: t * 2.0) | ||
| with pytest.warns(UserWarning): | ||
| out = bridge(x) | ||
| bridge.hook_scale.remove_hooks() | ||
| out.sum().backward() | ||
| assert x.grad is not None | ||
| assert torch.any(x.grad != 0) | ||
|
|
||
|
|
||
| def test_native_path_observation_hooks_keep_native_numerics(): | ||
| """run_with_cache-style hooks (return None) must not change output numerics. | ||
| Guards the fallback dispatch: routing on "any hook attached" would silently | ||
| switch caching runs from HF's native forward to the python-norm path. | ||
| """ | ||
| bridge = _make_bridge(native=True) | ||
| x = torch.randn(2, 5, 16) | ||
| baseline = bridge(x) | ||
| cache = {} | ||
|
|
||
| def observe(tensor, hook=None): | ||
| cache["normalized"] = tensor.detach() | ||
| return None | ||
|
|
||
| bridge.hook_normalized.add_hook(observe) | ||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("error") # any fallback warning fails the test | ||
| observed = bridge(x) | ||
| bridge.hook_normalized.remove_hooks() | ||
| assert torch.equal(baseline, observed) | ||
| assert "normalized" in cache | ||
|
|
||
|
|
||
| def test_native_path_no_hooks_matches_original_component(): | ||
| """Without hooks, the native path is exactly the wrapped module's output.""" | ||
| bridge = _make_bridge(native=True) | ||
| x = torch.randn(2, 5, 16) | ||
| assert torch.equal(bridge(x), bridge.original_component(x)) | ||
|
|
||
|
|
||
| def test_default_path_edit_still_propagates(): | ||
| """Control: the python-norm path's editing semantics are unchanged.""" | ||
| bridge = _make_bridge(native=False) | ||
| x = torch.randn(2, 5, 16) | ||
| baseline = bridge(x) | ||
| bridge.hook_normalized.add_hook(_zero_hook) | ||
| patched = bridge(x) | ||
| bridge.hook_normalized.remove_hooks() | ||
| assert not torch.allclose(baseline, patched) | ||
|
|
||
|
|
||
| def test_default_path_emits_no_fallback_warnings(): | ||
| """Control: python-norm path edits never warn — warnings are native-path only.""" | ||
| bridge = _make_bridge(native=False) | ||
| x = torch.randn(2, 5, 16) | ||
| bridge.hook_normalized.add_hook(_zero_hook) | ||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("error") | ||
| bridge(x) | ||
| bridge.hook_normalized.remove_hooks() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.