Skip to content

Commit eb80b67

Browse files
committed
Support post-norm and dropout in TransformerBlock
Add optional `attn_residual_norm` and `mlp_residual_norm` modules to `TransformerBlock`, and apply its dropout to the attention output before the residual addition. Build the Kumo-Anomaly `ResidualBlock` on a new `KumoAnomalyTransformerBlock` instead of `torch.nn.TransformerEncoderLayer`.
1 parent b4bb3c5 commit eb80b67

5 files changed

Lines changed: 224 additions & 37 deletions

File tree

‎sdm/models/kumo/timeseries/anomaly/block.py‎

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,64 @@
66

77
import torch
88
from torch import Tensor
9-
from torch.nn import Conv1d, LayerNorm, Linear, TransformerEncoderLayer
9+
from torch.nn import GELU, Conv1d, Dropout, LayerNorm, Linear, Sequential
10+
11+
from sdm.nn import TransformerBlock
12+
13+
14+
class KumoAnomalyTransformerBlock(TransformerBlock):
15+
"""Post-norm transformer block of Kumo-Anomaly.
16+
17+
Args:
18+
channels: Input and output width.
19+
num_heads: Attention heads.
20+
hidden_channels: Feedforward width.
21+
dropout: Attention and feedforward dropout probability.
22+
device: The device.
23+
dtype: The dtype.
24+
"""
25+
26+
def __init__(
27+
self,
28+
channels: int,
29+
num_heads: int,
30+
hidden_channels: int,
31+
dropout: float,
32+
device: torch.device | str | None = None,
33+
dtype: torch.dtype | None = None,
34+
) -> None:
35+
factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
36+
super().__init__(
37+
channels=channels,
38+
num_query_heads=num_heads,
39+
mlp=Sequential(
40+
Linear(channels, hidden_channels, **factory_kwargs),
41+
GELU(),
42+
Dropout(dropout),
43+
Linear(hidden_channels, channels, **factory_kwargs),
44+
Dropout(dropout),
45+
),
46+
attn_residual_norm=LayerNorm(channels, **factory_kwargs),
47+
mlp_residual_norm=LayerNorm(channels, **factory_kwargs),
48+
dropout=dropout,
49+
**factory_kwargs,
50+
)
51+
# Match the initialization of `torch.nn.MultiheadAttention`.
52+
torch.nn.init.xavier_uniform_(self.attn.qkv_lin.weight)
53+
torch.nn.init.zeros_(self.attn.qkv_lin.bias)
54+
self.attn.out_lin.reset_parameters()
55+
torch.nn.init.zeros_(self.attn.out_lin.bias)
56+
57+
def peak_bytes_per_example(
58+
self,
59+
element_size: int,
60+
query_length: int,
61+
key_value_length: int | None = None,
62+
) -> int:
63+
r""":meta private:""" # noqa: D415
64+
length = max(query_length, key_value_length or 0)
65+
factor = 10 if element_size <= 2 else 8
66+
return factor * length * element_size * self.attn.q_dim
1067

1168

1269
class ResidualBlock(torch.nn.Module):
@@ -84,25 +141,18 @@ def __init__(
84141
with torch.no_grad():
85142
projection.weight.mul_(0.5)
86143

87-
# Preserve post-residual normalization and attention dropout, which
88-
# SDM's TransformerBlock does not currently provide together.
89-
# TODO: Switch to SDM's TransformerBlock once it supports both.
90-
self.time_layer = TransformerEncoderLayer(
91-
d_model=channels,
92-
nhead=num_heads,
93-
dim_feedforward=hidden_channels,
144+
self.time_layer = KumoAnomalyTransformerBlock(
145+
channels=channels,
146+
num_heads=num_heads,
147+
hidden_channels=hidden_channels,
94148
dropout=dropout,
95-
activation="gelu",
96-
batch_first=True,
97149
**factory_kwargs,
98150
)
99-
self.feature_layer = TransformerEncoderLayer(
100-
d_model=channels,
101-
nhead=num_heads,
102-
dim_feedforward=hidden_channels,
151+
self.feature_layer = KumoAnomalyTransformerBlock(
152+
channels=channels,
153+
num_heads=num_heads,
154+
hidden_channels=hidden_channels,
103155
dropout=dropout,
104-
activation="gelu",
105-
batch_first=True,
106156
**factory_kwargs,
107157
)
108158
self.norm_after_time = LayerNorm(channels, **factory_kwargs)
@@ -159,3 +209,17 @@ def forward(
159209
.chunk(2, dim=1)
160210
)
161211
return (x + residual) / math.sqrt(2.0), skip
212+
213+
214+
if __name__ == "__main__":
215+
from sdm.testing.memory import benchmark_transformer_block_memory_peak
216+
217+
benchmark_transformer_block_memory_peak(
218+
block=lambda channels, num_heads: KumoAnomalyTransformerBlock(
219+
channels=channels,
220+
num_heads=num_heads,
221+
hidden_channels=64,
222+
dropout=0.0,
223+
),
224+
channels_and_heads=[(128, 8)],
225+
)

‎sdm/nn/attention.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,10 @@ class TransformerBlock(torch.nn.Module):
458458
attention.
459459
post_attn_norm: Normalization applied to the attention output before
460460
its residual addition.
461+
attn_residual_norm: Normalization applied after the attention residual
462+
addition.
463+
mlp_residual_norm: Normalization applied after the feedforward
464+
residual addition. Requires ``mlp``.
461465
query_transform: Transformation applied to projected query heads before
462466
scaled dot-product attention.
463467
key_transform: Transformation applied to projected key heads before
@@ -468,7 +472,8 @@ class TransformerBlock(torch.nn.Module):
468472
scale: Scaling factor passed to
469473
:func:`torch.nn.functional.scaled_dot_product_attention`.
470474
``None`` uses ``1 / sqrt(channels_per_head)``.
471-
dropout: Attention dropout probability.
475+
dropout: Dropout probability applied to attention weights and to the
476+
attention output before its residual addition.
472477
bias: If set to ``False``, the module will not learn an additive bias.
473478
device: The device.
474479
dtype: The dtype.
@@ -483,6 +488,8 @@ def __init__(
483488
query_norm: torch.nn.Module | None = None,
484489
key_value_norm: torch.nn.Module | None = None,
485490
post_attn_norm: torch.nn.Module | None = None,
491+
attn_residual_norm: torch.nn.Module | None = None,
492+
mlp_residual_norm: torch.nn.Module | None = None,
486493
query_transform: torch.nn.Module | None = None,
487494
key_transform: torch.nn.Module | None = None,
488495
query_scaling: QueryScaling | None = None,
@@ -494,12 +501,18 @@ def __init__(
494501
dtype: torch.dtype | None = None,
495502
) -> None:
496503
super().__init__()
504+
if mlp is None and mlp_residual_norm is not None:
505+
raise ValueError("'mlp_residual_norm' requires 'mlp'")
506+
497507
factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
498508

499509
self.mlp = mlp
500510
self.query_norm = query_norm
501511
self.key_value_norm = key_value_norm
502512
self.post_attn_norm = post_attn_norm
513+
self.attn_residual_norm = attn_residual_norm
514+
self.mlp_residual_norm = mlp_residual_norm
515+
self.dropout = dropout
503516

504517
self.attn = Attention(
505518
channels=channels,
@@ -811,20 +824,33 @@ def _forward(
811824

812825
if self.post_attn_norm is not None:
813826
attn_out = self.post_attn_norm(attn_out)
827+
if self.training and self.dropout > 0.0:
828+
attn_out = F.dropout(attn_out, p=self.dropout)
814829

815830
if (
816831
out is not None
817832
and torch.compiler.is_compiling()
818833
and not out.is_contiguous()
819834
):
820835
tmp = (attn_out + query).to(out.dtype)
836+
if self.attn_residual_norm is not None:
837+
tmp = self.attn_residual_norm(tmp)
821838
if self.mlp is not None:
822-
tmp += self.mlp(tmp)
839+
tmp = (tmp + self.mlp(tmp)).to(out.dtype)
840+
if self.mlp_residual_norm is not None:
841+
tmp = self.mlp_residual_norm(tmp)
823842
out.copy_(tmp)
824843
else:
825844
tmp = torch.add(attn_out, query, out=out)
845+
if self.attn_residual_norm is not None:
846+
tmp = self.attn_residual_norm(tmp)
826847
if self.mlp is not None:
827848
tmp = torch.add(tmp, self.mlp(tmp), out=out)
849+
if self.mlp_residual_norm is not None:
850+
tmp = self.mlp_residual_norm(tmp)
851+
# Normalization allocates, so copy its result into `out`.
852+
if out is not None and tmp is not out:
853+
tmp = out.copy_(tmp)
828854
out = tmp
829855

830856
return (out, kv) if return_key_value else out

‎test/models/kumo/timeseries/anomaly/test_block.py‎

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,48 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import math
5-
from collections.abc import Iterator
65

76
import pytest
87
import torch
9-
import torch.nn.functional as F
108
from torch import Tensor
119

12-
from sdm.models.kumo.timeseries.anomaly.block import ResidualBlock
10+
from sdm.models.kumo.timeseries.anomaly.block import (
11+
KumoAnomalyTransformerBlock,
12+
ResidualBlock,
13+
)
1314
from sdm.testing import withCUDA
1415

1516

16-
@pytest.fixture(autouse=True)
17-
def exact_gelu() -> Iterator[None]:
18-
# At inference time on GPU, PyTorch's encoder layer switches to a faster
19-
# kernel with an approximate GELU. Disable it to compare exact outputs.
20-
enabled = torch.backends.mha.get_fastpath_enabled()
21-
torch.backends.mha.set_fastpath_enabled(False)
22-
yield
23-
torch.backends.mha.set_fastpath_enabled(enabled)
24-
25-
2617
def reference_attention(
27-
layer: torch.nn.TransformerEncoderLayer, x: Tensor
18+
layer: KumoAnomalyTransformerBlock, x: Tensor
2819
) -> Tensor:
29-
"""Evaluate post-norm attention without the encoder's fused fast path."""
30-
attn, _ = layer.self_attn(x, x, x, need_weights=False)
31-
x = layer.norm1(x + layer.dropout1(attn))
32-
hidden = layer.linear2(layer.dropout(F.gelu(layer.linear1(x))))
33-
return layer.norm2(x + layer.dropout2(hidden))
20+
"""Evaluate a sequence-first PyTorch encoder layer with equal weights."""
21+
state = layer.state_dict()
22+
reference = torch.nn.TransformerEncoderLayer(
23+
d_model=x.size(-1),
24+
nhead=layer.attn.num_query_heads,
25+
dim_feedforward=state["mlp.0.weight"].size(0),
26+
dropout=0.0,
27+
activation="gelu",
28+
device=x.device,
29+
dtype=x.dtype,
30+
)
31+
names = {
32+
"self_attn.in_proj_": "attn.qkv_lin.",
33+
"self_attn.out_proj.": "attn.out_lin.",
34+
"linear1.": "mlp.0.",
35+
"linear2.": "mlp.3.",
36+
"norm1.": "attn_residual_norm.",
37+
"norm2.": "mlp_residual_norm.",
38+
}
39+
reference.load_state_dict(
40+
{
41+
target + key: state[source + key]
42+
for target, source in names.items()
43+
for key in ("weight", "bias")
44+
}
45+
)
46+
return reference.eval()(x.transpose(0, 1)).transpose(0, 1)
3447

3548

3649
def reference_block(

‎test/nn/test_attention.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,3 +751,61 @@ def test_transformer_block_kv_cache() -> None:
751751

752752
torch.testing.assert_close(cache_out, direct_out)
753753
torch.testing.assert_close(cached_out, direct_out)
754+
755+
756+
@withCUDA
757+
def test_transformer_block_residual_norms(device: torch.device) -> None:
758+
channels = 8
759+
mlp = torch.nn.Linear(channels, channels, device=device)
760+
attn_residual_norm = torch.nn.LayerNorm(channels, device=device)
761+
mlp_residual_norm = torch.nn.LayerNorm(channels, device=device)
762+
module = TransformerBlock(
763+
channels=channels,
764+
num_query_heads=2,
765+
mlp=mlp,
766+
attn_residual_norm=attn_residual_norm,
767+
mlp_residual_norm=mlp_residual_norm,
768+
device=device,
769+
)
770+
torch.nn.init.normal_(module.attn.out_lin.weight)
771+
query = torch.randn(2, 4, 3, channels, device=device).transpose(1, 2)
772+
773+
expected = attn_residual_norm(query + module.attn(query))
774+
expected = mlp_residual_norm(expected + mlp(expected))
775+
torch.testing.assert_close(module(query), expected)
776+
777+
with torch.no_grad():
778+
out = torch.empty(2, 3, 4, channels, device=device)
779+
assert module(query, batch_size_limit=1, out=out) is out
780+
torch.testing.assert_close(out, expected)
781+
782+
alias = query.clone()
783+
assert module(alias, out=alias) is alias
784+
torch.testing.assert_close(alias, expected)
785+
786+
with pytest.raises(ValueError, match="requires 'mlp'"):
787+
TransformerBlock(
788+
channels=channels,
789+
num_query_heads=2,
790+
mlp=None,
791+
mlp_residual_norm=mlp_residual_norm,
792+
)
793+
794+
795+
@withCUDA
796+
def test_transformer_block_dropout(device: torch.device) -> None:
797+
module = TransformerBlock(
798+
channels=4,
799+
num_query_heads=1,
800+
mlp=None,
801+
dropout=0.5,
802+
device=device,
803+
)
804+
torch.nn.init.eye_(module.attn.out_lin.weight)
805+
# A single key receives the full attention weight. Dropping weights and
806+
# outputs each scales the unit values by 0 or 2.
807+
query = torch.zeros(64, 1, 4, device=device)
808+
with torch.no_grad():
809+
module.attn.qkv_lin.bias[8:].fill_(1.0)
810+
assert set(module(query).unique().tolist()) == {0.0, 4.0}
811+
torch.testing.assert_close(module.eval()(query), query + 1)

‎test/nn/test_compile.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,32 @@ def test_transformer_block_compile(
280280
torch.testing.assert_close(out, expected)
281281

282282

283+
@withCUDA
284+
def test_transformer_block_residual_norms_compile(
285+
device: torch.device,
286+
) -> None:
287+
channels = 8
288+
module = TransformerBlock(
289+
channels=channels,
290+
num_query_heads=2,
291+
mlp=torch.nn.Linear(channels, channels, device=device),
292+
attn_residual_norm=torch.nn.LayerNorm(channels, device=device),
293+
mlp_residual_norm=torch.nn.LayerNorm(channels, device=device),
294+
device=device,
295+
)
296+
query = torch.randn(2, 3, channels, device=device)
297+
expected = torch.empty(2, 3, channels, device=device, dtype=torch.bfloat16)
298+
out = expected.new_empty(3, 2, channels).transpose(0, 1)
299+
300+
# CUDA autocast runs `LayerNorm` in float32, so residual sums must be
301+
# rounded to the dtype of `out` as in eager mode.
302+
with torch.no_grad(), torch.autocast(device.type, dtype=torch.bfloat16):
303+
module(query=query, out=expected)
304+
actual = fullgraph(module)(query=query, out=out)
305+
assert actual is out
306+
assert torch.equal(out, expected)
307+
308+
283309
@withCUDA
284310
@pytest.mark.parametrize("self_attn", [False, True])
285311
def test_induced_transformer_block_compile(

0 commit comments

Comments
 (0)