stackformers 3.8.2__tar.gz → 3.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {stackformers-3.8.2 → stackformers-3.9.1}/PKG-INFO +1 -1
  2. {stackformers-3.8.2 → stackformers-3.9.1}/pyproject.toml +1 -1
  3. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/README.md +4 -2
  4. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/ops.py +8 -57
  5. stackformers-3.9.1/stackformers/attention/varlen_backend.py +88 -0
  6. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/presets/README.md +3 -1
  7. stackformers-3.9.1/stackformers/presets/pyramid_vision.py +173 -0
  8. stackformers-3.9.1/stackformers/spatial/README.md +49 -0
  9. stackformers-3.9.1/stackformers/spatial/__init__.py +45 -0
  10. stackformers-3.9.1/stackformers/spatial/config.py +138 -0
  11. stackformers-3.9.1/stackformers/spatial/factory.py +49 -0
  12. stackformers-3.9.1/stackformers/spatial/input.py +53 -0
  13. stackformers-3.9.1/stackformers/spatial/kv_reduction.py +69 -0
  14. stackformers-3.9.1/stackformers/spatial/layer.py +42 -0
  15. stackformers-3.9.1/stackformers/spatial/patch_merging.py +54 -0
  16. stackformers-3.9.1/stackformers/spatial/protocols.py +33 -0
  17. stackformers-3.9.1/stackformers/spatial/spatial_reduction.py +70 -0
  18. stackformers-3.9.1/stackformers/spatial/window.py +101 -0
  19. stackformers-3.9.1/tests/attention/test_varlen_backend.py +122 -0
  20. stackformers-3.9.1/tests/presets/test_pyramid_vision.py +71 -0
  21. stackformers-3.9.1/tests/spatial/__init__.py +0 -0
  22. stackformers-3.9.1/tests/spatial/test_input.py +41 -0
  23. stackformers-3.9.1/tests/spatial/test_kv_reduction.py +66 -0
  24. stackformers-3.9.1/tests/spatial/test_layer.py +45 -0
  25. stackformers-3.9.1/tests/spatial/test_patch_merging.py +37 -0
  26. stackformers-3.9.1/tests/spatial/test_spatial_reduction.py +81 -0
  27. stackformers-3.9.1/tests/spatial/test_window.py +75 -0
  28. {stackformers-3.8.2 → stackformers-3.9.1}/uv.lock +1 -1
  29. stackformers-3.8.2/.claude/settings.json +0 -7
  30. {stackformers-3.8.2 → stackformers-3.9.1}/.claudeignore +0 -0
  31. {stackformers-3.8.2 → stackformers-3.9.1}/.gitignore +0 -0
  32. {stackformers-3.8.2 → stackformers-3.9.1}/.python-version +0 -0
  33. {stackformers-3.8.2 → stackformers-3.9.1}/.vscode/settings.json +0 -0
  34. {stackformers-3.8.2 → stackformers-3.9.1}/CLAUDE.md +0 -0
  35. {stackformers-3.8.2 → stackformers-3.9.1}/Justfile +0 -0
  36. {stackformers-3.8.2 → stackformers-3.9.1}/LICENSE +0 -0
  37. {stackformers-3.8.2 → stackformers-3.9.1}/PLAN.md +0 -0
  38. {stackformers-3.8.2 → stackformers-3.9.1}/README.md +0 -0
  39. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/__init__.py +0 -0
  40. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/__init__.py +0 -0
  41. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/bias.py +0 -0
  42. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/config.py +0 -0
  43. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/cross_attn.py +0 -0
  44. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/protocols.py +0 -0
  45. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/attention/self_attn.py +0 -0
  46. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/config.py +0 -0
  47. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/cross_attender.py +0 -0
  48. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/decoder.py +0 -0
  49. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/encoder.py +0 -0
  50. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/README.md +0 -0
  51. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/__init__.py +0 -0
  52. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/config.py +0 -0
  53. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/factory.py +0 -0
  54. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/geglu.py +0 -0
  55. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/protocols.py +0 -0
  56. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/relu_squared.py +0 -0
  57. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/feedforward/swiglu.py +0 -0
  58. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/layers.py +0 -0
  59. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/norm/README.md +0 -0
  60. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/norm/__init__.py +0 -0
  61. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/norm/config.py +0 -0
  62. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/norm/factory.py +0 -0
  63. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/norm/protocols.py +0 -0
  64. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/README.md +0 -0
  65. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/__init__.py +0 -0
  66. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/config.py +0 -0
  67. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/factory.py +0 -0
  68. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/learned.py +0 -0
  69. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/none.py +0 -0
  70. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/protocols.py +0 -0
  71. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/rope1d.py +0 -0
  72. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/positional/rope2d.py +0 -0
  73. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/presets/__init__.py +0 -0
  74. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/presets/cross_attender.py +0 -0
  75. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/presets/decoder.py +0 -0
  76. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/presets/encoder.py +0 -0
  77. {stackformers-3.8.2 → stackformers-3.9.1}/stackformers/sequence.py +0 -0
  78. {stackformers-3.8.2 → stackformers-3.9.1}/tests/__init__.py +0 -0
  79. {stackformers-3.8.2 → stackformers-3.9.1}/tests/attention/__init__.py +0 -0
  80. {stackformers-3.8.2 → stackformers-3.9.1}/tests/attention/test_cross_attn.py +0 -0
  81. {stackformers-3.8.2 → stackformers-3.9.1}/tests/attention/test_kernels.py +0 -0
  82. {stackformers-3.8.2 → stackformers-3.9.1}/tests/attention/test_ops.py +0 -0
  83. {stackformers-3.8.2 → stackformers-3.9.1}/tests/attention/test_self_attn.py +0 -0
  84. {stackformers-3.8.2 → stackformers-3.9.1}/tests/conftest.py +0 -0
  85. {stackformers-3.8.2 → stackformers-3.9.1}/tests/feedforward/__init__.py +0 -0
  86. {stackformers-3.8.2 → stackformers-3.9.1}/tests/feedforward/test_geglu.py +0 -0
  87. {stackformers-3.8.2 → stackformers-3.9.1}/tests/feedforward/test_relu_squared.py +0 -0
  88. {stackformers-3.8.2 → stackformers-3.9.1}/tests/feedforward/test_swiglu.py +0 -0
  89. {stackformers-3.8.2 → stackformers-3.9.1}/tests/positional/__init__.py +0 -0
  90. {stackformers-3.8.2 → stackformers-3.9.1}/tests/positional/test_learned_pos.py +0 -0
  91. {stackformers-3.8.2 → stackformers-3.9.1}/tests/positional/test_rope.py +0 -0
  92. {stackformers-3.8.2 → stackformers-3.9.1}/tests/presets/__init__.py +0 -0
  93. {stackformers-3.8.2 → stackformers-3.9.1}/tests/presets/test_cross_attender.py +0 -0
  94. {stackformers-3.8.2 → stackformers-3.9.1}/tests/presets/test_decoder.py +0 -0
  95. {stackformers-3.8.2 → stackformers-3.9.1}/tests/presets/test_encoder.py +0 -0
  96. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_cross_attender.py +0 -0
  97. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_decoder.py +0 -0
  98. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_encoder.py +0 -0
  99. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_layers.py +0 -0
  100. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_norm.py +0 -0
  101. {stackformers-3.8.2 → stackformers-3.9.1}/tests/test_sequence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackformers
3
- Version: 3.8.2
3
+ Version: 3.9.1
4
4
  Summary: Typed, composable, SOLID transformer library for PyTorch
5
5
  Project-URL: Homepage, https://github.com/Red-Eyed/stackformers
6
6
  Project-URL: Repository, https://github.com/Red-Eyed/stackformers
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stackformers"
7
- version = "3.8.2"
7
+ version = "3.9.1"
8
8
  description = "Typed, composable, SOLID transformer library for PyTorch"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -8,9 +8,11 @@ Multi-head self-attention and cross-attention.
8
8
 
9
9
  **Static vs runtime dispatch.** The attention *type* (global vs sliding-window) is determined at construction time via `window_size: int | None` in `SelfAttentionConfig`. The *backend* (padded vs packed) is determined at runtime: `SelfAttention.forward` and `CrossAttention.forward` dispatch on `SequenceInput` via `match`. This means one model handles both training (packed) and inference (padded) without swapping classes.
10
10
 
11
- **Kernel ops in `ops.py`.** All low-level attention math lives in `attention/ops.py`: mask builders, `packed_attn` (thin `varlen_attn` wrapper), `padded_sdpa`, and `packed_attn_or_fallback`. The modules stay thin dispatchers.
11
+ **Kernel ops in `ops.py`.** All low-level attention math lives in `attention/ops.py`: mask builders, `padded_sdpa`, the packed↔padded scatter/gather helpers, and `packed_attn_or_fallback`. The modules stay thin dispatchers.
12
12
 
13
- **Packed attention with automatic fallback.** `PackedInput` normally routes to `varlen_attn`, which requires CUDA and `float16`/`bfloat16`. When those conditions are not met (CPU, float32, `torch.export`), `packed_attn_or_fallback` transparently scatters q/k/v to padded layout, runs `F.scaled_dot_product_attention`, and gathers the result back to packed same weights, same output shape `(nt, h, d)`. A `UserWarning` is emitted so callers know they are on the slow path. Dropout works on the fallback path; it is silently skipped only when `varlen_attn` is used (which does not accept a dropout argument).
13
+ **Experimental kernel behind one interface function.** `torch.nn.attention.varlen` is an experimental API the symbol is absent in some torch builds and its signature is not stable. Everything that touches it lives in `varlen_backend.py` behind a single function, `try_varlen_attn(...) -> Tensor | None`: the guarded import, the eligibility/bias checks, and the call itself. The rest of the package depends only on that function and never on the volatile import path, so an absent or renamed symbol degrades to the fallback instead of breaking the whole package at import time.
14
+
15
+ **Packed attention with automatic fallback.** `PackedInput` normally routes to `varlen_attn`, which requires CUDA and `float16`/`bfloat16`. `packed_attn_or_fallback` calls `try_varlen_attn`; a `None` result means take the padded path — scatter q/k/v to padded layout, run `F.scaled_dot_product_attention`, and gather the result back to packed (same weights, same output shape `(nt, h, d)`). The fallback runs **silently** for the expected CPU/float32/`torch.export` route, and **with a `UserWarning` explaining why** when the kernel was eligible but unusable: it failed to import, an attention bias is present (no bias slot), or the call raised a runtime/signature error (unsupported GPU or a changed experimental API). Dropout works on the fallback path; it is silently skipped only when `varlen_attn` is used (which does not accept a dropout argument).
14
16
 
15
17
  **`torch.export` and `torch.compile` compatibility.** All scatter/gather helpers in `ops.py` (`_cu_to_indices`, `_packed_heads_to_padded`, `_padded_heads_to_packed`) are fully export-compatible. The key design constraint is that per-document length iteration must never use Python-side loops over data-dependent values (i.e., no `.tolist()` followed by a list comprehension). Instead, `_cu_to_indices` uses `torch.repeat_interleave` to produce `batch_idx` and derives `pos_idx` via the identity `arange(nt) - cu[batch_idx]`. This keeps the computation graph purely symbolic even when `nt` (total tokens) is a data-dependent unbacked symbol.
16
18
 
@@ -1,19 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import warnings
4
-
5
3
  import torch
6
4
  import torch.nn.functional as F
7
5
  from torch import Tensor
8
- from torch.nn.attention.varlen import varlen_attn as _varlen_attn
9
6
 
7
+ from stackformers.attention.varlen_backend import try_varlen_attn
10
8
  from stackformers.sequence import PackedSequence
11
9
 
12
10
 
13
- def varlen_supported(q: Tensor) -> bool:
14
- return q.is_cuda and q.dtype in (torch.float16, torch.bfloat16)
15
-
16
-
17
11
  def padding_mask(mask: Tensor, dtype: torch.dtype) -> Tensor:
18
12
  bias = torch.zeros(mask.shape, dtype=dtype, device=mask.device)
19
13
  bias.masked_fill_(~mask, torch.finfo(dtype).min)
@@ -34,12 +28,6 @@ def window_mask(n: int, s: int, window_size: int, causal: bool, device: torch.de
34
28
  return mask
35
29
 
36
30
 
37
- def varlen_window(causal: bool, window_size: int | None) -> tuple[int, int]:
38
- if window_size is None:
39
- return (-1, 0) if causal else (-1, -1)
40
- return (window_size, 0) if causal else (window_size // 2, window_size // 2)
41
-
42
-
43
31
  def padded_sdpa(
44
32
  q: Tensor,
45
33
  k: Tensor,
@@ -101,30 +89,6 @@ def _padded_heads_to_packed(x: Tensor, mask: Tensor) -> Tensor:
101
89
  return x.permute(0, 2, 1, 3)[mask]
102
90
 
103
91
 
104
- def packed_attn(
105
- q: Tensor,
106
- k: Tensor,
107
- v: Tensor,
108
- q_seq: PackedSequence,
109
- k_seq: PackedSequence,
110
- causal: bool,
111
- window_size: int | None,
112
- ) -> Tensor:
113
- win = varlen_window(causal, window_size)
114
- result = _varlen_attn(
115
- query=q,
116
- key=k,
117
- value=v,
118
- cu_seq_q=q_seq.cu_seqlens.to(torch.int32),
119
- cu_seq_k=k_seq.cu_seqlens.to(torch.int32),
120
- max_q=q_seq.max_seqlen,
121
- max_k=k_seq.max_seqlen,
122
- window_size=win,
123
- )
124
- assert isinstance(result, Tensor)
125
- return result
126
-
127
-
128
92
  def packed_attn_or_fallback(
129
93
  q: Tensor,
130
94
  k: Tensor,
@@ -135,29 +99,16 @@ def packed_attn_or_fallback(
135
99
  window_size: int | None,
136
100
  bias: Tensor | None,
137
101
  ) -> Tensor:
138
- """Run varlen attention; fall back to padded SDPA when unavailable.
102
+ """Run varlen attention; fall back to padded SDPA when it is unavailable.
139
103
 
140
104
  Inputs and output are all packed: q/k/v are (nt, h, d), result is (nt_q, h, d).
141
- bias=None no attention bias; varlen_attn is tried first.
142
- bias=Tensor attention bias present; varlen_attn is skipped (it has no bias slot) and a
143
- warning is emitted. Fallback also activates on CPU, non-float16/bfloat16 dtypes, or GPUs
144
- that do not support varlen_attn (e.g. compute capability < 8.0).
105
+ ``try_varlen_attn`` owns the experimental kernel and its warnings; ``None`` from it means
106
+ take the padded SDPA path silently for the expected CPU/float32/export route, or after
107
+ a warning when the kernel was eligible but unavailable.
145
108
  """
146
- if bias is not None and varlen_supported(q):
147
- warnings.warn(
148
- "varlen_attn does not support attention bias; falling back to padded SDPA. "
149
- "Performance may be lower.",
150
- stacklevel=2,
151
- )
152
- elif varlen_supported(q):
153
- try:
154
- return packed_attn(q, k, v, q_seq, k_seq, causal, window_size)
155
- except RuntimeError as exc:
156
- warnings.warn(
157
- f"varlen_attn is not supported on this device ({exc}); "
158
- "falling back to padded SDPA. Performance may be lower.",
159
- stacklevel=2,
160
- )
109
+ out = try_varlen_attn(q, k, v, q_seq, k_seq, causal, window_size, bias)
110
+ if out is not None:
111
+ return out
161
112
 
162
113
  # Keep b as SymInt-friendly: int(SymInt) forces specialization under torch.export.
163
114
  # Downstream consumers (_packed_heads_to_padded) accept SymInt for shape args.
@@ -0,0 +1,88 @@
1
+ """The experimental ``varlen_attn`` kernel behind one interface function.
2
+
3
+ ``torch.nn.attention.varlen`` is an experimental API: the symbol is absent in some torch
4
+ builds, and its call signature is not yet stable. Everything that touches it lives here —
5
+ the guarded import, the eligibility/bias checks, and the call itself — so the rest of the
6
+ package depends only on :func:`try_varlen_attn` and never on the volatile import path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+
13
+ import torch
14
+ from torch import Tensor
15
+
16
+ from stackformers.sequence import PackedSequence
17
+
18
+ _FALLBACK_NOTE = " Falling back to padded SDPA; performance may be lower."
19
+
20
+
21
+ def _load():
22
+ try:
23
+ from torch.nn.attention.varlen import varlen_attn
24
+ except ImportError as exc:
25
+ return None, f"varlen_attn could not be imported ({exc})"
26
+ return varlen_attn, None
27
+
28
+
29
+ _varlen_attn, _import_error = _load()
30
+
31
+
32
+ def _eligible(q: Tensor) -> bool:
33
+ """Whether this query's device/dtype is one ``varlen_attn`` would handle at all."""
34
+ return q.is_cuda and q.dtype in (torch.float16, torch.bfloat16)
35
+
36
+
37
+ def _window(causal: bool, window_size: int | None) -> tuple[int, int]:
38
+ if window_size is None:
39
+ return (-1, 0) if causal else (-1, -1)
40
+ return (window_size, 0) if causal else (window_size // 2, window_size // 2)
41
+
42
+
43
+ def _skip(reason: str) -> None:
44
+ warnings.warn(reason + _FALLBACK_NOTE, stacklevel=3)
45
+ return None
46
+
47
+
48
+ def try_varlen_attn(
49
+ q: Tensor,
50
+ k: Tensor,
51
+ v: Tensor,
52
+ q_seq: PackedSequence,
53
+ k_seq: PackedSequence,
54
+ causal: bool,
55
+ window_size: int | None,
56
+ bias: Tensor | None,
57
+ ) -> Tensor | None:
58
+ """Attempt varlen attention for a packed batch; return ``None`` to use the SDPA fallback.
59
+
60
+ q/k/v are ``(nt, h, d)``; the result is the packed ``(nt_q, h, d)`` tensor. ``None`` means
61
+ the caller should run the padded SDPA path instead. When varlen *would* apply (CUDA +
62
+ float16/bfloat16) but cannot, a ``UserWarning`` explains why: an absent kernel, an
63
+ attention bias (varlen_attn has no bias slot), or a runtime/signature error from the call
64
+ (an unsupported GPU or a changed experimental API). The expected CPU/float32/export route
65
+ returns ``None`` silently — that is normal, not a failure.
66
+ """
67
+ if not _eligible(q):
68
+ return None
69
+ if _varlen_attn is None:
70
+ return _skip(f"varlen_attn is unavailable ({_import_error}).")
71
+ if bias is not None:
72
+ return _skip("varlen_attn does not support attention bias.")
73
+ try:
74
+ result = _varlen_attn(
75
+ query=q,
76
+ key=k,
77
+ value=v,
78
+ cu_seq_q=q_seq.cu_seqlens.to(torch.int32),
79
+ cu_seq_k=k_seq.cu_seqlens.to(torch.int32),
80
+ max_q=q_seq.max_seqlen,
81
+ max_k=k_seq.max_seqlen,
82
+ window_size=_window(causal, window_size),
83
+ )
84
+ except (RuntimeError, TypeError) as exc:
85
+ return _skip(f"varlen_attn call failed ({type(exc).__name__}: {exc}).")
86
+ if not isinstance(result, Tensor):
87
+ return _skip(f"varlen_attn returned {type(result).__name__}, expected Tensor.")
88
+ return result
@@ -19,7 +19,9 @@ Each preset is split into two levels:
19
19
 
20
20
  **`CrossAttender`** — cross-attn → ff per layer. `x` is a fixed set of queries with no self-attention. Useful for Perceiver-style or slot-attention architectures.
21
21
 
22
- All presets accept `PaddedInput` or `PackedInput` interchangeably.
22
+ **`PyramidVisionBackbone`** multi-scale vision backbone (PVTv2-style hybrid). Stages of `spatial/` attention joined by `PatchMerging` downsamples; consumes a `SpatialInput` from your own patch-embed stem and returns one `(b, d, H, W)` feature map per stage. `pyramid_vision_config` defaults to 2-D window attention in the high-resolution early stages and spatially-reduced global attention later, tuned for 1024² images with an 8×8 patch stem. Unlike the other presets it is grid-based, so it takes `SpatialInput` rather than `PaddedInput`/`PackedInput`.
23
+
24
+ The other presets accept `PaddedInput` or `PackedInput` interchangeably.
23
25
 
24
26
  ## Customising a collaborator (subclass the concrete preset)
25
27
 
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import torch.nn as nn
4
+ from einops import rearrange
5
+ from jaxtyping import Float
6
+ from pydantic import BaseModel, Field, model_validator
7
+ from torch import Tensor
8
+
9
+ from stackformers.feedforward.config import FeedForwardConfig, SwiGLUConfig
10
+ from stackformers.feedforward.factory import build_ff
11
+ from stackformers.norm.config import NormConfig, RMSNormConfig
12
+ from stackformers.norm.factory import build_norm
13
+ from stackformers.positional.config import PosEncodingConfig, RoPE2DConfig
14
+ from stackformers.positional.factory import build_pos_encoding
15
+ from stackformers.spatial.config import (
16
+ PatchMergingConfig,
17
+ SpatialAttnConfig,
18
+ SpatialReductionAttentionConfig,
19
+ WindowAttention2DConfig,
20
+ )
21
+ from stackformers.spatial.factory import build_patch_merging, build_spatial_attn
22
+ from stackformers.spatial.input import SpatialInput
23
+ from stackformers.spatial.layer import SpatialTransformerLayer
24
+
25
+
26
+ class PyramidStageConfig(BaseModel):
27
+ """One pyramid stage: `depth` spatial-attention layers at a fixed grid and width."""
28
+
29
+ attn: SpatialAttnConfig
30
+ ff: FeedForwardConfig
31
+ norm: NormConfig
32
+ pos_encoding: PosEncodingConfig
33
+ depth: int = Field(gt=0)
34
+
35
+ @model_validator(mode="after")
36
+ def _dims_agree(self) -> PyramidStageConfig:
37
+ if not self.attn.dim == self.ff.dim == self.norm.dim:
38
+ raise ValueError(
39
+ f"stage dims disagree: attn={self.attn.dim}, ff={self.ff.dim}, norm={self.norm.dim}"
40
+ )
41
+ return self
42
+
43
+
44
+ class PyramidVisionConfig(BaseModel):
45
+ """A multi-scale vision backbone: stages joined by patch-merging downsamples.
46
+
47
+ inter_stage[i] merges the output of stage i into the input of stage i+1, so there is
48
+ exactly one fewer merge than there are stages.
49
+ """
50
+
51
+ stages: list[PyramidStageConfig] = Field(min_length=1)
52
+ inter_stage: list[PatchMergingConfig]
53
+
54
+ @model_validator(mode="after")
55
+ def _stages_and_merges_chain(self) -> PyramidVisionConfig:
56
+ if len(self.inter_stage) != len(self.stages) - 1:
57
+ raise ValueError(
58
+ f"need {len(self.stages) - 1} inter-stage merges, got {len(self.inter_stage)}"
59
+ )
60
+ for i, merge in enumerate(self.inter_stage):
61
+ if merge.in_dim != self.stages[i].attn.dim:
62
+ raise ValueError(f"merge {i} in_dim {merge.in_dim} != stage {i} dim")
63
+ if merge.out_dim != self.stages[i + 1].attn.dim:
64
+ raise ValueError(f"merge {i} out_dim {merge.out_dim} != stage {i + 1} dim")
65
+ return self
66
+
67
+
68
+ def pyramid_vision_config(
69
+ *,
70
+ dims: tuple[int, ...] = (64, 128, 320, 512),
71
+ depths: tuple[int, ...] = (3, 4, 6, 3),
72
+ heads: tuple[int, ...] = (1, 2, 5, 8),
73
+ sr_ratios: tuple[int, ...] = (8, 4, 2, 1),
74
+ window: int = 8,
75
+ window_stages: int = 2,
76
+ ff_mult: float = 4.0,
77
+ dropout: float = 0.0,
78
+ ) -> PyramidVisionConfig:
79
+ """PVTv2-style hybrid backbone: 2-D window attention in the first `window_stages`
80
+ high-resolution stages, spatially-reduced global attention in the rest.
81
+
82
+ Defaults are tuned for 1024² images patch-embedded with an 8×8 stem (stage-1 grid
83
+ 128×128); `window=8` and the sr_ratios divide that grid cleanly. RoPE 2-D positions
84
+ every stage; norm defaults to RMSNorm.
85
+
86
+ Papers: PVT/PVTv2 (Wang et al., 2021/2022, https://arxiv.org/abs/2102.12122,
87
+ https://arxiv.org/abs/2106.13797) for the SRA pyramid; Swin (Liu et al., 2021,
88
+ https://arxiv.org/abs/2103.14030) for the window-attention stages.
89
+ """
90
+ n = len(dims)
91
+ stages: list[PyramidStageConfig] = []
92
+ for i in range(n):
93
+ dim, dim_head = dims[i], dims[i] // heads[i]
94
+ attn: SpatialAttnConfig = (
95
+ WindowAttention2DConfig(
96
+ dim=dim, heads=heads[i], dim_head=dim_head, window=window, dropout=dropout
97
+ )
98
+ if i < window_stages
99
+ else SpatialReductionAttentionConfig(
100
+ dim=dim, heads=heads[i], dim_head=dim_head, reduction=sr_ratios[i], dropout=dropout
101
+ )
102
+ )
103
+ stages.append(
104
+ PyramidStageConfig(
105
+ attn=attn,
106
+ ff=SwiGLUConfig(dim=dim, mult=ff_mult, dropout=dropout),
107
+ norm=RMSNormConfig(dim=dim),
108
+ pos_encoding=RoPE2DConfig(dim_head=dim_head),
109
+ depth=depths[i],
110
+ )
111
+ )
112
+ merges = [PatchMergingConfig(in_dim=dims[i], out_dim=dims[i + 1]) for i in range(n - 1)]
113
+ return PyramidVisionConfig(stages=stages, inter_stage=merges)
114
+
115
+
116
+ class _PyramidStage(nn.Module):
117
+ """A stack of SpatialTransformerLayers at a fixed grid; SpatialInput → SpatialInput."""
118
+
119
+ def __init__(self, config: PyramidStageConfig) -> None:
120
+ super().__init__()
121
+ pos = build_pos_encoding(config.pos_encoding)
122
+ self.layers = nn.ModuleList(
123
+ SpatialTransformerLayer(
124
+ attn=build_spatial_attn(config.attn, pos),
125
+ ff=build_ff(config.ff),
126
+ norm_attn=build_norm(config.norm),
127
+ norm_ff=build_norm(config.norm),
128
+ )
129
+ for _ in range(config.depth)
130
+ )
131
+
132
+ def forward(self, input: SpatialInput) -> SpatialInput:
133
+ for layer in self.layers:
134
+ input = layer(input)
135
+ return input
136
+
137
+
138
+ def _to_feature_map(input: SpatialInput) -> Float[Tensor, "b d h w"]:
139
+ h, w = input.grid
140
+ return rearrange(input.x, "b (h w) d -> b d h w", h=h, w=w)
141
+
142
+
143
+ class PyramidVisionBackbone(nn.Module):
144
+ """Multi-scale vision backbone (PVTv2-style hybrid).
145
+
146
+ Consumes a SpatialInput from your own patch-embed stem and returns one feature map per
147
+ stage as (b, d, H, W) — the multi-scale pyramid a detection/segmentation head expects.
148
+
149
+ Paper: "PVTv2: Improved Baselines with Pyramid Vision Transformer", Wang et al., 2022 —
150
+ https://arxiv.org/abs/2106.13797
151
+ """
152
+
153
+ def __init__(self, config: PyramidVisionConfig) -> None:
154
+ super().__init__()
155
+ self.stages = nn.ModuleList(_PyramidStage(stage) for stage in config.stages)
156
+ self.merges = nn.ModuleList(build_patch_merging(m) for m in config.inter_stage)
157
+
158
+ def forward(self, input: SpatialInput) -> list[Float[Tensor, "b d h w"]]:
159
+ """Shape flow (4-stage example, stem grid H₀×W₀, dims d₀..d₃):
160
+
161
+ stem in (b, H₀·W₀, d₀)
162
+ stage i (b, Hᵢ·Wᵢ, dᵢ) emit feature map (b, dᵢ, Hᵢ, Wᵢ)
163
+ merge i (b, Hᵢ₊₁·Wᵢ₊₁, dᵢ₊₁) grid halved, width widened
164
+ returns [(b, dᵢ, Hᵢ, Wᵢ) for each stage] — the feature pyramid
165
+ """
166
+ features: list[Tensor] = []
167
+ x = input
168
+ for i, stage in enumerate(self.stages):
169
+ x = stage(x)
170
+ features.append(_to_feature_map(x))
171
+ if i < len(self.merges):
172
+ x = self.merges[i](x)
173
+ return features
@@ -0,0 +1,49 @@
1
+ # spatial
2
+
3
+ Attention over a 2-D grid of image tokens, for efficient vision backbones. Self-contained:
4
+ nothing here touches the 1-D `SequenceInput` / `attention/` stack — it only reuses
5
+ `padded_sdpa`, position encodings, norms, and feed-forwards.
6
+
7
+ ## Design
8
+
9
+ **A separate input type, not a widened `SequenceInput`.** `SpatialInput` carries the grid
10
+ shape `(H, W)` so attention can reshape the flat token sequence back to `(b, d, H, W)`. It is
11
+ deliberately kept out of the `SequenceInput` union: that union stays a sealed 1-D abstraction,
12
+ and spatial attention gets its own `SpatialAttn` protocol. Padded-only — fixed-resolution
13
+ images do not vary in length, so there is no packed spatial variant.
14
+
15
+ **Two attention variants, one protocol.** Both satisfy `SpatialAttn` and stay dot-product
16
+ attention (so RoPE 2-D, GQA, and `qk_norm` all apply):
17
+ - `WindowAttention2D` — non-overlapping `w×w` tiles, O(n·w²). Local; cheapest at the
18
+ high-resolution early stages. Distinct from `SelfAttentionConfig.window_size`, which is a
19
+ *1-D sliding* window over a flat sequence; this partitions the *2-D grid* into square tiles.
20
+ - `SpatialReductionAttention` — full-grid queries attend to a spatially-reduced K/V context
21
+ (PVTv2 SRA), O(n·s) with s ≪ n. Global; affordable once the grid is small.
22
+
23
+ **Reduction is an injected `KVReduction`.** `SpatialReductionAttention` delegates K/V
24
+ downsampling to a collaborator (`NoKVReduction` for r=1 full attention, `ConvKVReduction` for a
25
+ strided conv). The reduction's norm is itself an injected `Norm` — pluggable like everywhere
26
+ else, defaulting to RMSNorm.
27
+
28
+ **Window inverse is a closure.** `partition_windows` returns `(windows, merge)`; the `merge`
29
+ inverse is bound to the exact `(b, H, W, window)` used to partition, so the un-partition step
30
+ can never be called with mismatched parameters.
31
+
32
+ **Export-friendly.** Grid shapes are Python ints throughout (positions, window counts, merged
33
+ grid sizes), so reshapes stay static under `torch.export` / `torch.compile`.
34
+
35
+ ## Extending
36
+
37
+ Add a new variant as a new `nn.Module` satisfying `SpatialAttn`, plus a config with a `kind`
38
+ discriminator and a `match` arm in `factory.py`. To add a new K/V reduction, implement
39
+ `KVReduction` and extend `build_kv_reduction`. Shifted windows would be a new variant here, not
40
+ a flag on `WindowAttention2D`.
41
+
42
+ The opinionated multi-scale assembly (stages + patch-merging → feature pyramid) lives in
43
+ `presets/pyramid_vision.py`, not here — this module only provides the reusable primitives.
44
+
45
+ ## References
46
+
47
+ - **Swin Transformer** — window partitioning. Liu et al., 2021. https://arxiv.org/abs/2103.14030
48
+ - **Pyramid Vision Transformer (PVT)** — spatial-reduction attention. Wang et al., 2021. https://arxiv.org/abs/2102.12122
49
+ - **PVTv2** — overlapping patch embedding / improved pyramid. Wang et al., 2022. https://arxiv.org/abs/2106.13797
@@ -0,0 +1,45 @@
1
+ from stackformers.spatial.config import (
2
+ ConvKVReductionConfig,
3
+ KVReductionConfig,
4
+ NoKVReductionConfig,
5
+ PatchMergingConfig,
6
+ SpatialAttnConfig,
7
+ SpatialReductionAttentionConfig,
8
+ WindowAttention2DConfig,
9
+ )
10
+ from stackformers.spatial.factory import (
11
+ build_kv_reduction,
12
+ build_patch_merging,
13
+ build_spatial_attn,
14
+ )
15
+ from stackformers.spatial.input import SpatialInput, grid_positions, make_spatial_input
16
+ from stackformers.spatial.kv_reduction import ConvKVReduction, NoKVReduction
17
+ from stackformers.spatial.layer import SpatialTransformerLayer
18
+ from stackformers.spatial.patch_merging import PatchMerging
19
+ from stackformers.spatial.protocols import KVReduction, SpatialAttn
20
+ from stackformers.spatial.spatial_reduction import SpatialReductionAttention
21
+ from stackformers.spatial.window import WindowAttention2D
22
+
23
+ __all__ = [
24
+ "SpatialInput",
25
+ "make_spatial_input",
26
+ "grid_positions",
27
+ "SpatialAttn",
28
+ "KVReduction",
29
+ "NoKVReduction",
30
+ "ConvKVReduction",
31
+ "WindowAttention2D",
32
+ "SpatialReductionAttention",
33
+ "SpatialTransformerLayer",
34
+ "PatchMerging",
35
+ "NoKVReductionConfig",
36
+ "ConvKVReductionConfig",
37
+ "KVReductionConfig",
38
+ "WindowAttention2DConfig",
39
+ "SpatialReductionAttentionConfig",
40
+ "SpatialAttnConfig",
41
+ "PatchMergingConfig",
42
+ "build_kv_reduction",
43
+ "build_spatial_attn",
44
+ "build_patch_merging",
45
+ ]
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import BaseModel, Field, model_validator
6
+
7
+ from stackformers.attention.config import _validate_attn_dims
8
+ from stackformers.norm.config import NormConfig, RMSNormConfig
9
+
10
+
11
+ class NoKVReductionConfig(BaseModel):
12
+ kind: Literal["none"] = "none"
13
+
14
+
15
+ class ConvKVReductionConfig(BaseModel):
16
+ """Strided-conv spatial reduction of the key/value context.
17
+
18
+ Paper: "Pyramid Vision Transformer" (SRA), Wang et al., 2021 —
19
+ https://arxiv.org/abs/2102.12122
20
+ """
21
+
22
+ kind: Literal["conv"] = "conv"
23
+ dim: int = Field(gt=0, description="Channel width of the tokens being reduced.")
24
+ reduction: int = Field(
25
+ gt=1,
26
+ description="Spatial stride r: an H×W grid is pooled to (H/r)×(W/r). Must divide H and W.",
27
+ )
28
+ norm: NormConfig = Field(
29
+ default_factory=lambda: RMSNormConfig(dim=1),
30
+ description="Norm applied to the reduced tokens. Defaults to RMSNorm, matching the "
31
+ "rest of the library; set to LayerNormConfig for the original PVTv2 behaviour.",
32
+ )
33
+
34
+ @model_validator(mode="after")
35
+ def _sync_norm_dim(self) -> ConvKVReductionConfig:
36
+ # The norm always acts on `dim` channels, so derive its width rather than making
37
+ # the caller repeat it (and risk a mismatch).
38
+ self.norm = self.norm.model_copy(update={"dim": self.dim})
39
+ return self
40
+
41
+
42
+ KVReductionConfig = Annotated[
43
+ NoKVReductionConfig | ConvKVReductionConfig,
44
+ Field(discriminator="kind"),
45
+ ]
46
+
47
+
48
+ class _SpatialAttnBase(BaseModel):
49
+ """Shared geometry for 2-D attention variants (mirrors SelfAttentionConfig)."""
50
+
51
+ dim: int = Field(gt=0, description="Model dimension — input/output width of the sublayer.")
52
+ heads: int = Field(default=8, gt=0, description="Number of query heads.")
53
+ dim_head: int = Field(default=64, gt=0, description="Dimension per head.")
54
+ kv_heads: int | None = Field(
55
+ default=None, description="Key/value heads. None = heads (MHA); a divisor of heads = GQA."
56
+ )
57
+ dropout: float = Field(default=0.0, ge=0.0, le=1.0, description="Output-projection dropout.")
58
+ qk_norm: bool = Field(default=False, description="RMSNorm queries and keys before the product.")
59
+
60
+ @model_validator(mode="after")
61
+ def _validate_dims(self) -> _SpatialAttnBase:
62
+ _validate_attn_dims(self.dim, self.heads, self.dim_head, self.kv_heads)
63
+ return self
64
+
65
+ @property
66
+ def effective_kv_heads(self) -> int:
67
+ return self.kv_heads if self.kv_heads is not None else self.heads
68
+
69
+ @property
70
+ def groups(self) -> int:
71
+ return self.heads // self.effective_kv_heads
72
+
73
+
74
+ class WindowAttention2DConfig(_SpatialAttnBase):
75
+ """Non-overlapping w×w window attention over the grid — O(n·w²), local.
76
+
77
+ Distinct from SelfAttentionConfig.window_size, which is a 1-D sliding window over a
78
+ flat sequence; this partitions the 2-D grid into square tiles.
79
+
80
+ Paper: "Swin Transformer" (window partitioning), Liu et al., 2021 —
81
+ https://arxiv.org/abs/2103.14030
82
+ """
83
+
84
+ kind: Literal["window2d"] = "window2d"
85
+ window: int = Field(gt=0, description="Window side length w; must divide grid H and W.")
86
+
87
+
88
+ class SpatialReductionAttentionConfig(_SpatialAttnBase):
89
+ """Global attention with spatially-reduced K/V — O(n·s).
90
+
91
+ Paper: "Pyramid Vision Transformer" (SRA), Wang et al., 2021 —
92
+ https://arxiv.org/abs/2102.12122
93
+ """
94
+
95
+ kind: Literal["spatial_reduction"] = "spatial_reduction"
96
+ reduction: int = Field(
97
+ default=1,
98
+ ge=1,
99
+ description="K/V spatial stride r. 1 = full global attention (no reduction).",
100
+ )
101
+ kv_norm: NormConfig = Field(
102
+ default_factory=lambda: RMSNormConfig(dim=1),
103
+ description="Norm applied to reduced K/V tokens (ignored when reduction == 1).",
104
+ )
105
+
106
+ @model_validator(mode="after")
107
+ def _sync_kv_norm_dim(self) -> SpatialReductionAttentionConfig:
108
+ self.kv_norm = self.kv_norm.model_copy(update={"dim": self.dim})
109
+ return self
110
+
111
+
112
+ SpatialAttnConfig = Annotated[
113
+ WindowAttention2DConfig | SpatialReductionAttentionConfig,
114
+ Field(discriminator="kind"),
115
+ ]
116
+
117
+
118
+ class PatchMergingConfig(BaseModel):
119
+ """Overlapping strided-conv downsample between pyramid stages (defaults k3 s2 p1).
120
+
121
+ Paper: "PVTv2: Improved Baselines with Pyramid Vision Transformer" (overlapping patch
122
+ embedding), Wang et al., 2022 — https://arxiv.org/abs/2106.13797
123
+ """
124
+
125
+ in_dim: int = Field(gt=0, description="Input channel width.")
126
+ out_dim: int = Field(gt=0, description="Output channel width after merging.")
127
+ kernel: int = Field(default=3, gt=0, description="Conv kernel size.")
128
+ stride: int = Field(default=2, gt=0, description="Conv stride — the downsample factor.")
129
+ padding: int = Field(default=1, ge=0, description="Conv padding.")
130
+ norm: NormConfig = Field(
131
+ default_factory=lambda: RMSNormConfig(dim=1),
132
+ description="Norm applied to merged tokens; dim is synced to out_dim.",
133
+ )
134
+
135
+ @model_validator(mode="after")
136
+ def _sync_norm_dim(self) -> PatchMergingConfig:
137
+ self.norm = self.norm.model_copy(update={"dim": self.out_dim})
138
+ return self