|
| 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