Skip to content

Commit e101f77

Browse files
committed
release: 0.4.2 — block-sparse attention primitive (closes #21)
BSRMatrix + bsr_spmm as the block-sparse attention primitive. - examples/block_sparse_attention.py: three mask patterns (local window, dilated, global tokens), numerical parity vs dense reference, timing comparison for the bsr_spmm step - docs/sparse_attention.md: BSR-128 ↔ Trainium Tensor Engine alignment, pattern construction, block density arithmetic, fused-tile follow-up - tests/test_attention.py: 8 CPU tests — mask shapes + parity at atol=1e-4 for all three patterns and the full-attention edge case - mkdocs.yml: add Iterative Solvers (was missing) and Sparse Attention to nav
1 parent 3e989f4 commit e101f77

7 files changed

Lines changed: 567 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.2] — 2026-04-15
9+
10+
### Added
11+
12+
- **`examples/block_sparse_attention.py`** — block-sparse attention
13+
reference using `BSRMatrix` + `bsr_spmm`. Three mask patterns
14+
(local window, dilated, global tokens); verifies against a dense
15+
reference; reports block density and timing for the `bsr_spmm` step.
16+
Closes [#21](https://github.com/trnsci/trnsparse/issues/21).
17+
- **`docs/sparse_attention.md`** — writeup: how BSR-128 maps to
18+
Longformer/BigBird-style attention masks, block density arithmetic,
19+
pattern construction helpers, and the fused-tile follow-up.
20+
- **`docs/iterative_solvers.md`** now linked in `mkdocs.yml` nav
21+
(was present but missing from navigation).
22+
- **`tests/test_attention.py`** — 8 CPU tests: mask shape/symmetry
23+
checks + parity against dense reference at `atol=1e-4` for all three
24+
patterns and the full-attention edge case.
25+
26+
### Notes
27+
28+
No API changes, no kernel changes. The claim in #21 is: `bsr_spmm` is
29+
the block-sparse attention primitive; `BSRMatrix` captures the mask.
30+
The example and docs make that explicit.
31+
832
## [0.4.1] — 2026-04-14
933

1034
### Added

docs/sparse_attention.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Block-sparse attention
2+
3+
## The connection
4+
5+
Transformer attention computes `O = softmax(Q @ K.T / √d) @ V`. The expensive
6+
part at long sequence lengths is the `(seq_len, seq_len)` score matrix and the
7+
final `attn_weights @ V` matmul. Sparse attention restricts which positions can
8+
attend to each other, zeroing out large regions of the score matrix. What
9+
remains is a sparse × dense matmul: exactly `bsr_spmm`.
10+
11+
The block size that matters is 128. Trainium's Tensor Engine is a 128-partition
12+
systolic array; `nc_matmul` consumes a 128×K×N tile in one call. A local-window
13+
attention mask with window granularity of 128 tokens has exactly `2w+1` nonzero
14+
128×128 blocks per block-row — each nonzero block maps one-to-one to one
15+
`nc_matmul` call, with no gather overhead. The tile size of the mask and the
16+
tile size of the hardware coincide.
17+
18+
cuSPARSE's BSR is a specialization added on top of CSR. Here it's the other
19+
way: BSR is what the attention mask asks for, and the hardware is built around
20+
that shape.
21+
22+
## Building patterns
23+
24+
### Local window (Longformer-style)
25+
26+
Each token block attends to its `window` nearest neighbors in block space:
27+
28+
```python
29+
import trnsparse
30+
31+
def local_window_mask(seq_len: int, block_size: int, window: int) -> trnsparse.BSRMatrix:
32+
"""BSRMatrix for sliding-window attention."""
33+
import torch
34+
n_blocks = seq_len // block_size
35+
block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
36+
for i in range(n_blocks):
37+
lo, hi = max(0, i - window), min(n_blocks, i + window + 1)
38+
block_mask[i, lo:hi] = True
39+
mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
40+
return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
41+
```
42+
43+
At `seq_len=4096` and `window=2`: `5` nonzero blocks per row, `5/32 ≈ 15.6%`
44+
block density vs 100% for dense attention. Memory for the stored blocks:
45+
`32 rows × 5 blocks × 128 × 128 × 4 bytes ≈ 10 MB` vs 64 MB for the full
46+
float32 attention matrix.
47+
48+
### Dilated sparse
49+
50+
Every `stride`-th block column is attended to. Block `(i, j)` is nonzero
51+
iff `(i - j) % stride == 0`:
52+
53+
```python
54+
def dilated_mask(seq_len: int, block_size: int, stride: int) -> trnsparse.BSRMatrix:
55+
import torch
56+
n_blocks = seq_len // block_size
57+
block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
58+
for i in range(n_blocks):
59+
for j in range(n_blocks):
60+
if (i - j) % stride == 0:
61+
block_mask[i, j] = True
62+
mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
63+
return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
64+
```
65+
66+
### Global tokens (BigBird-style)
67+
68+
The first `n_global` token-blocks attend to and are attended to by every
69+
block. Remaining block-rows follow a local window:
70+
71+
```python
72+
def global_token_mask(
73+
seq_len: int, block_size: int, window: int, n_global: int
74+
) -> trnsparse.BSRMatrix:
75+
import torch
76+
n_blocks = seq_len // block_size
77+
block_mask = torch.zeros(n_blocks, n_blocks, dtype=torch.bool)
78+
block_mask[:n_global, :] = True # global rows attend everywhere
79+
block_mask[:, :n_global] = True # global columns attended by all
80+
for i in range(n_global, n_blocks):
81+
lo, hi = max(0, i - window), min(n_blocks, i + window + 1)
82+
block_mask[i, lo:hi] = True
83+
mask_dense = block_mask.repeat_interleave(block_size, 0).repeat_interleave(block_size, 1)
84+
return trnsparse.BSRMatrix.from_dense(mask_dense.float(), block_size=block_size)
85+
```
86+
87+
## The computation
88+
89+
Given a `mask_bsr` constructed above:
90+
91+
```python
92+
import torch
93+
94+
def block_sparse_attention(Q, K, V, mask_bsr):
95+
"""Q, K, V: (seq_len, head_dim) → (seq_len, head_dim)."""
96+
scale = Q.shape[-1] ** -0.5
97+
scores = (Q @ K.T) * scale
98+
99+
# Mask out unattended positions before softmax.
100+
mask_dense = mask_bsr.to_dense().bool()
101+
masked_scores = scores.masked_fill(~mask_dense, float("-inf"))
102+
attn_weights = torch.softmax(masked_scores, dim=-1)
103+
104+
# Positions outside the mask go to zero after softmax; from_dense
105+
# with threshold=0 will still store them unless they're exactly zero.
106+
# Use threshold slightly above zero to keep only the attended blocks.
107+
attn_bsr = trnsparse.BSRMatrix.from_dense(
108+
attn_weights, block_size=mask_bsr.block_size, threshold=1e-9
109+
)
110+
return trnsparse.bsr_spmm(attn_bsr, V)
111+
```
112+
113+
**What this materializes**: the full `(seq_len, seq_len)` score matrix. At
114+
`seq_len=4096` that's 64 MB of float32 even before the matmul. See
115+
[What's next](#whats-next) for the fused-tile path that avoids this.
116+
117+
**Gradients**: `bsr_spmm` is differentiable (see `architecture.md`). The
118+
backward through `block_sparse_attention` works out-of-the-box with
119+
`torch.autograd`; the block-selection step (`from_dense`) is non-differentiable
120+
by construction and is treated as a constant by the autograd graph.
121+
122+
## Block density arithmetic
123+
124+
For `seq_len = S`, `block_size = b`, `window = w` local-window mask:
125+
126+
```
127+
blocks per row = 2w + 1 (clamped at edges)
128+
block density = (2w+1) / (S/b)
129+
```
130+
131+
| seq_len | window | n_blocks_per_row | block density |
132+
|--------:|-------:|-----------------:|:--------------|
133+
| 1024 | 2 | 5 | 39.1% |
134+
| 2048 | 2 | 5 | 19.5% |
135+
| 4096 | 2 | 5 | 9.8% |
136+
| 4096 | 4 | 9 | 17.6% |
137+
| 8192 | 2 | 5 | 4.9% |
138+
139+
The win is at long sequences. At `seq_len=1024`, a window-2 mask still stores
140+
40% of blocks — dispatch overhead dominates on Trainium at that size. At
141+
`seq_len=8192`, 5% density means 95% of the `nc_matmul` calls are skipped and
142+
the per-block compute becomes the bottleneck, which is where the Tensor Engine
143+
thrives.
144+
145+
## What's next
146+
147+
The current path materializes the full `(seq_len, seq_len)` score matrix to
148+
compute attention weights. A production path would:
149+
150+
1. Iterate over nonzero blocks in `mask_bsr`.
151+
2. For each block `(i, j)`, load `Q[i*b:(i+1)*b]` and `K[j*b:(j+1)*b]` into
152+
SBUF, compute the score tile, and apply softmax over the block-row.
153+
3. Multiply the score tile by `V[j*b:(j+1)*b]` and accumulate into the output.
154+
155+
This fused tile-level kernel avoids the `O(seq_len²)` intermediate entirely.
156+
It's the same architectural opportunity as [on-chip iterative solvers](iterative_solvers.md):
157+
load A once, iterate on-chip. The NKI building block is available (the BSR
158+
kernel in `nki/kernels.py`); the row-wise softmax over variable numbers of
159+
tiles is the authoring challenge.
160+
161+
A runnable reference for the current (non-fused) path is in
162+
[`examples/block_sparse_attention.py`](https://github.com/trnsci/trnsparse/blob/main/examples/block_sparse_attention.py).

0 commit comments

Comments
 (0)