Skip to content

Commit 1cf7ee6

Browse files
committed
channels last for layer_norm
1 parent 925fe1c commit 1cf7ee6

5 files changed

Lines changed: 287 additions & 32 deletions

File tree

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ packages = ["src/spidr"]
6464
source = "vcs"
6565

6666
[tool.pytest]
67+
addopts = ["-m", "not compile"]
6768
filterwarnings = ["error"]
69+
markers = ["compile: torch.compile tests, deselected by default; run with -m compile"]
6870

6971
[tool.ruff]
7072
line-length = 119

‎src/spidr/models/components.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ def select_masked(x: Tensor, index: Tensor) -> Tensor:
2828

2929

3030
class LayerNorm(nn.LayerNorm):
31-
"""Layer norm with transpose."""
31+
"""Layer norm over the channel dimension of `(batch, channel, *)`."""
3232

3333
def forward(self, input: Tensor) -> Tensor:
34-
x = input.transpose(-2, -1)
35-
x = F.layer_norm(x, self.normalized_shape, self.weight, self.bias, self.eps)
36-
return x.transpose(-2, -1)
34+
x = F.layer_norm(input.movedim(1, -1), self.normalized_shape, self.weight, self.bias, self.eps)
35+
return x.movedim(-1, 1)
3736

3837

3938
class ConvLayerBlock(nn.Module):
@@ -60,7 +59,11 @@ def init_weights(self) -> None:
6059
nn.init.kaiming_normal_(self.conv.weight)
6160

6261
def forward(self, x: Tensor) -> Tensor:
63-
x = self.conv(x)
62+
if x.dim() == 4: # Channels-last (batch, channel, 1, frame): run as a 2D convolution with height 1.
63+
weight = self.conv.weight.unsqueeze(2).to(memory_format=torch.channels_last)
64+
x = F.conv2d(x, weight, self.conv.bias, stride=(1, self.stride))
65+
else:
66+
x = self.conv(x)
6467
if self.layer_norm is not None:
6568
x = self.layer_norm(x)
6669
return F.gelu(x)
@@ -69,11 +72,17 @@ def forward(self, x: Tensor) -> Tensor:
6972
class FeatureExtractor(nn.Module):
7073
"""Extract features from audio."""
7174

72-
def __init__(self, conv_layers: nn.ModuleList) -> None:
75+
def __init__(self, conv_layers: nn.ModuleList, *, channels_last: bool = False) -> None:
7376
super().__init__()
7477
self.conv_layers = conv_layers
78+
self.channels_last = channels_last
7579

7680
def forward(self, x: Tensor) -> Tensor:
81+
if self.channels_last:
82+
x = x[:, None, None, :].to(memory_format=torch.channels_last) # (batch, 1, 1, sample)
83+
for layer in self.conv_layers:
84+
x = layer(x) # (batch, feature, 1, frame)
85+
return x.squeeze(2).transpose(1, 2) # (batch, frame, feature)
7786
x = x.unsqueeze(1) # (batch, channel==1, frame)
7887
for layer in self.conv_layers:
7988
x = layer(x) # (batch, feature, frame)
@@ -331,9 +340,7 @@ def quantize(self, targets: list[Tensor]) -> list[Tensor]:
331340
return onehot_targets
332341

333342

334-
def get_components(
335-
cfg: DinoSRConfig,
336-
) -> tuple[FeatureExtractor, FeatureProjection, Transformer, nn.ModuleList, Codebooks]:
343+
def get_feature_extractor(cfg: DinoSRConfig) -> FeatureExtractor:
337344
if cfg.extractor_mode not in {"group_norm", "layer_norm"}:
338345
raise ValueError(cfg.extractor_mode)
339346
blocks = nn.ModuleList()
@@ -348,8 +355,13 @@ def get_components(
348355
ConvLayerBlock(in_channels, out_channels, kernel_size, stride, layer_norm, bias=cfg.extractor_conv_bias)
349356
)
350357
in_channels = out_channels
351-
feature_extractor = FeatureExtractor(blocks)
358+
return FeatureExtractor(blocks, channels_last=cfg.extractor_mode == "layer_norm")
352359

360+
361+
def get_components(
362+
cfg: DinoSRConfig,
363+
) -> tuple[FeatureExtractor, FeatureProjection, Transformer, nn.ModuleList, Codebooks]:
364+
feature_extractor = get_feature_extractor(cfg)
353365
feature_projection = FeatureProjection(cfg.extractor_conv_layer_config[-1][0], cfg.encoder_embed_dim)
354366
pos_conv = ConvPositionalEmbedding(
355367
cfg.encoder_embed_dim, cfg.encoder_pos_conv_kernel, cfg.encoder_pos_conv_groups, cfg.encoder_pos_conv_depth

‎tests/test_feature_extractor.py‎

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Copyright (c) 2025 Meta Platforms, Inc. and affiliates.
2+
"""The channels-last feature extractor must match the reference Conv1d implementation."""
3+
4+
import dataclasses
5+
from collections.abc import Callable, Iterator
6+
from pathlib import Path
7+
8+
import pytest
9+
import torch
10+
from torch import Tensor, nn
11+
from torch.nn import functional as F
12+
13+
from spidr.config import DinoSRConfig
14+
from spidr.models.components import ConvLayerBlock, FeatureExtractor, LayerNorm, get_feature_extractor
15+
16+
# Short input: half-precision convolutions use slow fallback kernels on CPU.
17+
NUM_SAMPLES = 4000
18+
NUM_FRAMES = 12
19+
NUM_FEATURES = 512
20+
21+
22+
def reference_forward(extractor: FeatureExtractor, x: Tensor) -> Tensor:
23+
"""Original implementation: Conv1d in (batch, channel, frame) layout."""
24+
x = x.unsqueeze(1)
25+
for layer in extractor.conv_layers:
26+
assert isinstance(layer, ConvLayerBlock)
27+
x = layer.conv(x)
28+
if isinstance(layer.layer_norm, LayerNorm):
29+
norm = layer.layer_norm
30+
x = F.layer_norm(x.transpose(-2, -1), norm.normalized_shape, norm.weight, norm.bias, norm.eps)
31+
x = x.transpose(-2, -1)
32+
elif layer.layer_norm is not None:
33+
x = layer.layer_norm(x)
34+
x = F.gelu(x)
35+
return x.transpose(1, 2)
36+
37+
38+
def make_extractor(mode: str, *, bias: bool, dtype: torch.dtype) -> FeatureExtractor:
39+
cfg = dataclasses.replace(DinoSRConfig(), extractor_mode=mode, extractor_conv_bias=bias)
40+
extractor = get_feature_extractor(cfg)
41+
for module in extractor.modules(): # Non-trivial affine parameters.
42+
if isinstance(module, (LayerNorm, nn.GroupNorm)):
43+
nn.init.normal_(module.weight, 1.0, 0.1)
44+
nn.init.normal_(module.bias, 0.0, 0.1)
45+
elif isinstance(module, nn.Conv1d) and module.bias is not None:
46+
nn.init.normal_(module.bias, 0.0, 0.1)
47+
return extractor.to(dtype)
48+
49+
50+
def output_and_grads(
51+
forward: Callable[[FeatureExtractor, Tensor], Tensor],
52+
extractor: FeatureExtractor,
53+
x: Tensor,
54+
grad_output: Tensor,
55+
*,
56+
autocast_dtype: torch.dtype | None = None,
57+
) -> dict[str, Tensor]:
58+
"""Output, then the gradients of the input and of every parameter for a fixed upstream gradient."""
59+
x = x.detach().requires_grad_()
60+
with torch.autocast(x.device.type, dtype=autocast_dtype, enabled=autocast_dtype is not None):
61+
out = forward(extractor, x)
62+
assert out.shape == grad_output.shape
63+
expected_dtype = autocast_dtype or x.dtype
64+
# CUDA autocast runs normalization in FP32. In layer_norm mode the final
65+
# block ends in normalization + GELU; group_norm mode ends in a convolution.
66+
if (
67+
autocast_dtype is not None
68+
and x.device.type == "cuda"
69+
and isinstance(extractor.conv_layers[-1].layer_norm, (LayerNorm, nn.GroupNorm))
70+
):
71+
expected_dtype = torch.float32
72+
assert out.dtype == expected_dtype
73+
names, params = zip(*extractor.named_parameters(), strict=True)
74+
grads = torch.autograd.grad(out, [x, *params], grad_output)
75+
return dict(zip(["output", "input", *names], [out, *grads], strict=True))
76+
77+
78+
def run_both(mode: str, *, bias: bool, dtype: torch.dtype) -> tuple[dict[str, Tensor], dict[str, Tensor]]:
79+
torch.manual_seed(0)
80+
extractor = make_extractor(mode, bias=bias, dtype=dtype)
81+
x = torch.randn(3, NUM_SAMPLES, dtype=dtype)
82+
grad_output = torch.randn(3, NUM_FRAMES, NUM_FEATURES, dtype=dtype)
83+
actual = output_and_grads(FeatureExtractor.forward, extractor, x, grad_output)
84+
expected = output_and_grads(reference_forward, extractor, x, grad_output)
85+
return actual, expected
86+
87+
88+
@pytest.mark.parametrize("mode", ["layer_norm", "group_norm"])
89+
@pytest.mark.parametrize("bias", [False, True])
90+
def test_feature_extractor_matches_reference_float64(mode: str, bias: bool) -> None:
91+
"""In float64 rounding is negligible, so both implementations must agree almost exactly."""
92+
actual, expected = run_both(mode, bias=bias, dtype=torch.float64)
93+
assert actual.keys() == expected.keys()
94+
for name in expected:
95+
torch.testing.assert_close(actual[name], expected[name], rtol=1e-10, atol=1e-10, msg=name)
96+
97+
98+
@pytest.mark.parametrize("mode", ["layer_norm", "group_norm"])
99+
@pytest.mark.parametrize("bias", [False, True])
100+
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16])
101+
def test_feature_extractor_matches_reference_low_precision(mode: str, bias: bool, dtype: torch.dtype) -> None:
102+
"""Check both worst-entry and aggregate error without dividing by near-zero entries."""
103+
actual, expected = run_both(mode, bias=bias, dtype=dtype)
104+
assert_numerically_close(actual, expected, mode=mode, dtype=dtype)
105+
106+
107+
def assert_numerically_close(
108+
actual: dict[str, Tensor], expected: dict[str, Tensor], *, mode: str, dtype: torch.dtype
109+
) -> None:
110+
# Explicit acceptance budgets, not measured guarantees. RMS also catches widespread errors
111+
# that a single large reference entry could hide in the maximum-error comparison.
112+
max_tol, rms_tol = {
113+
torch.float32: (1e-4, 2e-5),
114+
torch.float16: (2e-2, 5e-3),
115+
torch.bfloat16: (1e-1, 4e-2),
116+
}[dtype]
117+
assert actual.keys() == expected.keys()
118+
for name, value in expected.items():
119+
a, e = actual[name].double(), value.double()
120+
assert a.shape == e.shape, name
121+
assert actual[name].dtype == value.dtype, name
122+
assert torch.isfinite(a).all(), name
123+
assert torch.isfinite(e).all(), name
124+
if mode == "group_norm" and name == "conv_layers.0.conv.bias":
125+
# GroupNorm with one channel per group removes any per-channel bias: this gradient is exactly zero in
126+
# theory, so both sides are pure rounding noise. Check it is negligible next to the weight gradient.
127+
scale = expected["conv_layers.0.conv.weight"].double().abs().max()
128+
assert max(a.abs().max(), e.abs().max()) <= max_tol * scale, name
129+
continue
130+
delta = a - e
131+
# An exactly zero reference must agree exactly; no division by zero or arbitrary floor.
132+
max_error, max_scale = delta.abs().max().item(), e.abs().max().item()
133+
rms_error, rms_scale = delta.square().mean().sqrt().item(), e.square().mean().sqrt().item()
134+
assert max_error <= max_tol * max_scale, (
135+
f"{name}: max error {max_error:.6g}, reference max {max_scale:.6g}, tolerance {max_tol}"
136+
)
137+
assert rms_error <= rms_tol * rms_scale, (
138+
f"{name}: RMS error {rms_error:.6g}, reference RMS {rms_scale:.6g}, tolerance {rms_tol}"
139+
)
140+
141+
142+
@pytest.fixture
143+
def isolated_compile_cache(compiled: bool) -> Iterator[None]:
144+
"""Each configuration gets its own cache; input sizes within a case share it."""
145+
if compiled:
146+
torch.compiler.reset()
147+
try:
148+
yield
149+
finally:
150+
if compiled:
151+
torch.compiler.reset()
152+
153+
154+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for pretraining precision coverage")
155+
# Triton constructs ast.AnnAssign without `simple`, deprecated in Python 3.14.
156+
# Keep this upstream compiler warning from failing otherwise valid CUDA graphs.
157+
@pytest.mark.filterwarnings(
158+
r"ignore:AnnAssign\.__init__ missing 1 required positional argument. 'simple'\."
159+
r":DeprecationWarning:triton\.compiler\.code_generator"
160+
)
161+
@pytest.mark.parametrize("mode", ["layer_norm", "group_norm"])
162+
@pytest.mark.parametrize("bias", [False, True])
163+
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
164+
@pytest.mark.parametrize("compiled", [False, pytest.param(True, marks=pytest.mark.compile)], ids=["eager", "compiled"])
165+
@pytest.mark.usefixtures("isolated_compile_cache")
166+
def test_feature_extractor_cuda_pretraining(
167+
mode: str, bias: bool, dtype: torch.dtype, compiled: bool, monkeypatch: pytest.MonkeyPatch
168+
) -> None:
169+
"""FP32 parameters under CUDA autocast, with backward outside autocast as in train.py."""
170+
if dtype == torch.bfloat16 and torch.cuda.get_device_capability()[0] < 8:
171+
pytest.skip("Pretraining requires Ampere or newer for BF16")
172+
# Match the public backend settings in setup_pytorch without its global compiler patches.
173+
monkeypatch.setattr(torch.backends.cuda.matmul, "allow_tf32", False)
174+
monkeypatch.setattr(torch.backends.cuda.matmul, "allow_bf16_reduced_precision_reduction", True)
175+
monkeypatch.setattr(torch.backends.cudnn, "benchmark", False)
176+
# Strict FP32 equivalence needs IEEE convolution arithmetic too. Matmul's flag
177+
# does not disable cuDNN TF32. This deliberately differs from training's default
178+
# cuDNN policy; mixed-precision cases still exercise FP16/BF16 convolutions.
179+
monkeypatch.setattr(torch.backends.cudnn, "allow_tf32", False)
180+
torch.manual_seed(0)
181+
extractor = make_extractor(mode, bias=bias, dtype=torch.float32).cuda().train()
182+
actual_forward = FeatureExtractor.forward
183+
expected_forward = reference_forward
184+
if compiled:
185+
# Full graphs prevent silent eager fallback from satisfying the compiled test.
186+
actual_forward = torch.compile(actual_forward, dynamic=True, fullgraph=True)
187+
expected_forward = torch.compile(expected_forward, dynamic=True, fullgraph=True)
188+
autocast_dtype = None if dtype == torch.float32 else dtype
189+
# Reuse each compiled callable across changing batch and frame dimensions.
190+
for batch, samples in [(2, 8000), (3, 16000), (2, 12080)]:
191+
frames = samples
192+
for layer in extractor.conv_layers:
193+
assert isinstance(layer, ConvLayerBlock)
194+
frames = (frames - layer.kernel_size) // layer.stride + 1
195+
x = torch.randn(batch, samples, device="cuda", dtype=torch.float32)
196+
upstream = torch.randn(batch, frames, NUM_FEATURES, device="cuda", dtype=dtype)
197+
actual = output_and_grads(actual_forward, extractor, x, upstream, autocast_dtype=autocast_dtype)
198+
expected = output_and_grads(expected_forward, extractor, x, upstream, autocast_dtype=autocast_dtype)
199+
assert all(p.dtype == torch.float32 for p in extractor.parameters())
200+
assert_numerically_close(actual, expected, mode=mode, dtype=dtype)
201+
if compiled:
202+
# Also anchor both compiled implementations to the original eager implementation.
203+
eager = output_and_grads(reference_forward, extractor, x, upstream, autocast_dtype=autocast_dtype)
204+
assert_numerically_close(actual, eager, mode=mode, dtype=dtype)
205+
assert_numerically_close(expected, eager, mode=mode, dtype=dtype)
206+
207+
208+
def test_conv1d_checkpoint_resumes_channels_last(tiny_dinosr_config: DinoSRConfig, tmp_path: Path) -> None:
209+
"""Old Conv1d weights and populated AdamW moments resume through the new forward path."""
210+
torch.manual_seed(0)
211+
original = get_feature_extractor(tiny_dinosr_config).double()
212+
original.channels_last = False
213+
optimizer = torch.optim.AdamW(original.parameters(), lr=1e-3)
214+
x = torch.randn(2, 1600, dtype=torch.float64)
215+
216+
def step(extractor: FeatureExtractor, optim: torch.optim.AdamW) -> None:
217+
optim.zero_grad(set_to_none=True)
218+
extractor(x).square().mean().backward()
219+
optim.step()
220+
221+
step(original, optimizer)
222+
path = tmp_path / "conv1d.pt"
223+
torch.save({"model": original.state_dict(), "optimizer": optimizer.state_dict()}, path)
224+
resumed = get_feature_extractor(tiny_dinosr_config).double()
225+
assert resumed.channels_last
226+
resumed_optimizer = torch.optim.AdamW(resumed.parameters(), lr=1e-3)
227+
checkpoint = torch.load(path, weights_only=True)
228+
resumed.load_state_dict(checkpoint["model"], strict=True)
229+
resumed_optimizer.load_state_dict(checkpoint["optimizer"])
230+
for old_param, new_param in zip(original.parameters(), resumed.parameters(), strict=True):
231+
torch.testing.assert_close(old_param, new_param, rtol=0, atol=0)
232+
for key, value in optimizer.state[old_param].items():
233+
torch.testing.assert_close(value, resumed_optimizer.state[new_param][key], rtol=0, atol=0)
234+
step(original, optimizer)
235+
step(resumed, resumed_optimizer)
236+
for old_param, new_param in zip(original.parameters(), resumed.parameters(), strict=True):
237+
torch.testing.assert_close(old_param, new_param, rtol=1e-9, atol=1e-10)
238+
for key, value in optimizer.state[old_param].items():
239+
torch.testing.assert_close(value, resumed_optimizer.state[new_param][key], rtol=1e-9, atol=1e-10)

‎tests/test_models.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,16 @@ def test_update_ema_still_targets_the_teacher_after_a_buffer_is_added(tiny_dinos
281281
for encoder in (model.student, model.teacher):
282282
encoder.register_buffer("probe", torch.zeros(4))
283283
model._ema_targets = _split_ema_targets(model.teacher, model.student, model.teacher_exclude_layers)
284-
assert any(t is model.teacher.probe for t in model._ema_targets["copy_teacher"])
284+
assert any(t is model.teacher.get_buffer("probe") for t in model._ema_targets["copy_teacher"])
285285

286286
model.double() # Any `_apply`: a real device move rebinds buffers the same way.
287287
with torch.no_grad():
288-
model.student.probe.fill_(1.0)
288+
model.student.get_buffer("probe").fill_(1.0)
289289
model.update_ema(step=1)
290290

291-
assert torch.equal(model.teacher.probe, model.student.probe), "teacher buffer was not synced"
291+
assert torch.equal(model.teacher.get_buffer("probe"), model.student.get_buffer("probe")), (
292+
"teacher buffer was not synced"
293+
)
292294

293295

294296
def test_ema_targets_stay_out_of_the_state_dict(tiny_dinosr_config: DinoSRConfig) -> None:

0 commit comments

Comments
 (0)