Skip to content

Commit e1a3421

Browse files
fix(attention): align msa with paper
1 parent 2d018af commit e1a3421

3 files changed

Lines changed: 200 additions & 59 deletions

File tree

docs/attention.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ Heavily compressed attention is also tagged to the [DeepSeek-V4 docs](https://hu
108108

109109
![MSA architecture](assets/msa.png)
110110

111-
MiniMax sparse attention is a GQA-based block-sparse self-attention. A cheap index branch runs one index query per GQA group against a single shared index key head, block-max-pools those scores into per-block scores, and top-k selects a few KV blocks per group. The sparse branch then runs ordinary GQA attention, but only over the keys gathered from the selected blocks — so each query attends to at most `top_k * block_size` keys instead of the whole sequence.
111+
MiniMax sparse attention follows [MiniMax Sparse Attention](https://arxiv.org/abs/2606.13392). It is a GQA-based block-sparse self-attention: a cheap index branch runs one index query per GQA group against a single shared index key head, block-max-pools token scores into per-block scores, always keeps the query's local block, and uses the remaining budget for top-k block selection. The main branch then runs exact GQA attention only over tokens gathered from those selected blocks, so each query attends to at most `top_k * block_size` keys instead of the whole sequence.
112+
113+
The implementation keeps the paper's separation between routing and attention: index scores choose blocks, but they are not added as a bias to the main attention logits. Because hard top-k routing is not differentiable, the module exposes `kl_alignment_loss(...)` to train the index branch against the group-averaged main-branch attention distribution on the selected token support, with detached teacher and hidden-state inputs.
112114

113115
- Implementation: [`msa.py`](../src/components/attention/msa.py)
114116
- Config: [`msa.yaml`](../configs/attention/msa.yaml)
115-
- Use when: you want long-context attention that keeps GQA's KV savings and adds learned block sparsity, picking which KV blocks each query reads instead of reading them all.
117+
- Use when: you want long-context attention that keeps GQA's KV savings and adds learned block sparsity, picking which KV blocks each query reads instead of reading them all. For training, add the returned KL alignment term to the language-modeling loss.
116118

117119
## Model compatibility
118120

src/components/attention/msa.py

Lines changed: 152 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
# 1) index branch — one lightweight index query per gqa group (q_idx, h heads)
1010
# attends to a single shared index key head (k_idx). that gives a score
1111
# matrix per group; block-max-pool collapses each key block to its best
12-
# score, then top-k picks the strongest blocks. one set of block indices
13-
# per gqa group (i_1, i_2, ... in the diagram).
12+
# score, then top-k keeps the local block plus the strongest non-local
13+
# blocks. one set of block indices per gqa group (i_1, i_2, ... in the
14+
# diagram).
1415
# 2) sparse branch — the real q (H heads), k, v (h kv heads) partitioned into
1516
# blocks. each query gathers only the k/v inside its group's selected
1617
# blocks and attends over those, with the usual causal mask. heads inside
@@ -37,7 +38,8 @@
3738
top_k = number of blocks kept per (group, query)
3839
3940
implemented:
40-
index branch (block-max-pool + top-k), gather-based sparse gqa attention,
41+
index branch (block-max-pool + forced local block + top-k), gather-based
42+
sparse gqa attention, paper-style KL alignment helper for the index branch,
4143
vectorized-over-t prefill, per-t serial reference, kv-cache for incremental
4244
decode (full k/v retained plus the index keys, since block selection needs
4345
every past block available to gather from).
@@ -77,25 +79,43 @@ def block_max_pool(scores: Tensor, block_size: int) -> Tensor:
7779
return scores.view(*lead, n_blocks, block_size).amax(dim=-1)
7880

7981

80-
def select_topk_blocks(block_scores: Tensor, top_k: int) -> Tensor:
81-
"""top-k block indices along the last axis. returns [..., k_eff] where
82-
k_eff = min(top_k, n_blocks)."""
82+
def select_topk_blocks(
83+
block_scores: Tensor,
84+
top_k: int,
85+
local_blocks: Tensor | None = None,
86+
) -> Tensor:
87+
"""top-k block indices along the last axis.
88+
89+
When local_blocks is provided, top_k is the total selected-block budget:
90+
one slot is reserved for the block containing the query, and the remaining
91+
slots come from the best non-local blocks.
92+
"""
8393
k_eff = min(top_k, block_scores.shape[-1])
84-
return block_scores.topk(k_eff, dim=-1).indices
94+
if local_blocks is None:
95+
return block_scores.topk(k_eff, dim=-1).indices
8596

97+
local = local_blocks.clamp(max=block_scores.shape[-1] - 1).unsqueeze(-1)
98+
if k_eff == 1:
99+
return local
86100

87-
def block_score_bias(block_scores: Tensor, sel: Tensor, block_size: int, group_size: int) -> Tensor:
88-
"""turn selected block scores into an additive attention-logit bias.
101+
scores = block_scores.scatter(dim=-1, index=local, value=float("-inf"))
102+
extra_k = k_eff - 1
103+
extra = scores.topk(extra_k, dim=-1).indices
104+
return torch.cat([local, extra], dim=-1)
89105

90-
The forward sparse pattern stays hard top-k, but the selected blocks carry
91-
a differentiable prior from the index branch so w_iq / w_ik receive
92-
training signal from the LM loss.
93-
"""
94-
sel_scores = block_scores.gather(dim=-1, index=sel) # [..., k_eff]
95-
sel_logp = torch.log_softmax(sel_scores, dim=-1) # [..., k_eff]
96-
bias = sel_logp[..., None].expand(*sel_logp.shape, block_size)
97-
bias = bias.reshape(*sel_logp.shape[:-1], sel_logp.shape[-1] * block_size)
98-
return bias.repeat_interleave(group_size, dim=0)
106+
107+
def selected_token_positions(
108+
sel: Tensor,
109+
block_size: int,
110+
seq_len: int,
111+
query_positions: Tensor,
112+
) -> tuple[Tensor, Tensor, Tensor]:
113+
"""expand selected blocks to token positions and validity masks."""
114+
within = torch.arange(block_size, device=sel.device)
115+
kpos = (sel[..., None] * block_size + within).reshape(*sel.shape[:-1], sel.shape[-1] * block_size)
116+
valid = (kpos < seq_len) & (kpos <= query_positions)
117+
kpos_c = kpos.clamp(max=seq_len - 1)
118+
return kpos, kpos_c, valid
99119

100120

101121
@ATTENTION.register("msa")
@@ -114,6 +134,12 @@ def __init__(
114134
super().__init__(d_model, n_heads, dropout)
115135
if n_heads % n_kv_heads != 0:
116136
raise ValueError(f"n_heads ({n_heads}) must be divisible by n_kv_heads ({n_kv_heads})")
137+
if block_size <= 0:
138+
raise ValueError(f"block_size ({block_size}) must be positive")
139+
if top_k <= 0:
140+
raise ValueError(f"top_k ({top_k}) must be positive")
141+
if d_idx <= 0:
142+
raise ValueError(f"d_idx ({d_idx}) must be positive")
117143
self.n_kv_heads = n_kv_heads
118144
self.group_size = n_heads // n_kv_heads
119145
self.block_size = block_size
@@ -167,26 +193,28 @@ def _forward_seq(self, H: Tensor) -> Tensor:
167193
q = self.wq(H).view(s, n_h, d_head)
168194
k = self.wk(H).view(s, h, d_head)
169195
v = self.wv(H).view(s, h, d_head)
170-
iq = self.w_iq(H).view(s, h, d_idx)
171-
ik = self.w_ik(H) # [s, d_idx]
196+
H_idx = H.detach()
197+
iq = self.w_iq(H_idx).view(s, h, d_idx)
198+
ik = self.w_ik(H_idx) # [s, d_idx]
172199

173200
ts = torch.arange(s, device=H.device)
174201
key_ok = ts[None, :] <= ts[:, None] # [s_q, s_k] : kj <= qi
202+
local_blocks = (ts // block_size)[None].expand(h, s)
175203

176204
# index branch: [h, s_q, s_k] scores, future keys masked before pooling.
177205
idx_scores = torch.einsum("qgi,ki->gqk", iq, ik) / math.sqrt(d_idx)
178206
idx_scores = idx_scores.masked_fill(~key_ok[None], float("-inf"))
179207
block_scores = block_max_pool(idx_scores, block_size) # [h, s_q, n_blocks]
180-
sel = select_topk_blocks(block_scores, self.top_k) # [h, s_q, k_eff]
181-
k_eff = sel.shape[-1]
182-
score_bias = block_score_bias(block_scores, sel, block_size, group_size)
208+
sel = select_topk_blocks(block_scores, self.top_k, local_blocks)
183209

184210
# expand selected blocks to key positions [h, s_q, k_eff*block_size]
185-
within = torch.arange(block_size, device=H.device)
186-
kpos = (sel[..., None] * block_size + within).reshape(h, s, k_eff * block_size)
187-
n_keys = kpos.shape[-1]
188-
valid = (kpos < s) & (kpos <= ts[None, :, None]) # in-range and causal
189-
kpos_c = kpos.clamp(max=s - 1)
211+
_, kpos_c, valid = selected_token_positions(
212+
sel,
213+
block_size,
214+
s,
215+
ts[None, :, None],
216+
)
217+
n_keys = kpos_c.shape[-1]
190218

191219
# gather the selected group kv, then expand groups to heads
192220
k_g = k.permute(1, 0, 2) # [h, s, d_head]
@@ -201,7 +229,6 @@ def _forward_seq(self, H: Tensor) -> Tensor:
201229
# sparse attention over the gathered keys only
202230
q_h = q.permute(1, 0, 2) # [H, s_q, d_head]
203231
scores = torch.einsum("hqd,hqnd->hqn", q_h, k_sel) / math.sqrt(d_head)
204-
scores = scores + score_bias
205232
scores = scores.masked_fill(~valid_h, float("-inf"))
206233
any_valid = valid_h.any(dim=-1) # [H, s_q]
207234
# a query with no visible key would softmax an all-(-inf) row -> NaN;
@@ -219,7 +246,7 @@ def _populate_cache_from_prefill(self, H: Tensor, cache: MSACache) -> None:
219246
b, s, _ = H.shape
220247
k = self.wk(H).view(b, s, self.n_kv_heads, self.d_head).permute(0, 2, 1, 3)
221248
v = self.wv(H).view(b, s, self.n_kv_heads, self.d_head).permute(0, 2, 1, 3)
222-
ik = self.w_ik(H) # [b, s, d_idx]
249+
ik = self.w_ik(H.detach()) # [b, s, d_idx]
223250
cache.k, cache.v, cache.ik = k.contiguous(), v.contiguous(), ik.contiguous()
224251
cache.total_seen = s
225252

@@ -234,25 +261,27 @@ def _decode_step(self, q: Tensor, cache: MSACache) -> Tensor:
234261
# project and append the new token's kv + index key.
235262
k_new = self.wk(h_new).view(b, h, 1, d_head)
236263
v_new = self.wv(h_new).view(b, h, 1, d_head)
237-
ik_new = self.w_ik(h_new)[:, None, :] # [b, 1, d_idx]
264+
h_idx = h_new.detach()
265+
ik_new = self.w_ik(h_idx)[:, None, :] # [b, 1, d_idx]
238266
cache.update(k_new, v_new, ik_new)
239267
t_abs = cache.total_seen - 1 # position of the new query
240268

241269
outs = []
242270
for bi in range(b):
243-
iq = self.w_iq(h_new[bi]).view(h, d_idx) # [h, d_idx]
271+
iq = self.w_iq(h_idx[bi]).view(h, d_idx) # [h, d_idx]
244272
ik = cache.ik[bi] # [s_k, d_idx]
245273
# every cached key is causal for the new query, so no key mask here.
246274
idx_scores = (iq @ ik.T) / math.sqrt(d_idx) # [h, s_k]
247275
block_scores = block_max_pool(idx_scores, block_size)
248-
sel = select_topk_blocks(block_scores, self.top_k) # [h, k_eff]
249-
k_eff = sel.shape[-1]
250-
score_bias = block_score_bias(block_scores, sel, block_size, group_size)
251-
252-
within = torch.arange(block_size, device=q.device)
253-
kpos = (sel[..., None] * block_size + within).reshape(h, k_eff * block_size)
254-
valid = kpos <= t_abs # [h, n_keys]
255-
kpos_c = kpos.clamp(max=t_abs)
276+
local_blocks = torch.full((h,), t_abs // block_size, device=q.device, dtype=torch.long)
277+
sel = select_topk_blocks(block_scores, self.top_k, local_blocks)
278+
279+
_, kpos_c, valid = selected_token_positions(
280+
sel,
281+
block_size,
282+
cache.total_seen,
283+
torch.full((h, 1), t_abs, device=q.device, dtype=torch.long),
284+
)
256285

257286
k_bi = cache.k[bi] # [h, s_k, d_head]
258287
v_bi = cache.v[bi]
@@ -265,7 +294,6 @@ def _decode_step(self, q: Tensor, cache: MSACache) -> Tensor:
265294

266295
q_bi = self.wq(h_new[bi]).view(n_h, d_head) # [H, d_head]
267296
scores = torch.einsum("hd,hnd->hn", q_bi, k_sel) / math.sqrt(d_head)
268-
scores = scores + score_bias
269297
scores = scores.masked_fill(~valid_h, float("-inf"))
270298
any_valid = valid_h.any(dim=-1)
271299
scores = scores.masked_fill(~any_valid[:, None], 0.0)
@@ -286,20 +314,22 @@ def _forward_seq_serial(self, H: Tensor) -> Tensor:
286314
q = self.wq(H).view(s, n_h, d_head)
287315
k = self.wk(H).view(s, h, d_head)
288316
v = self.wv(H).view(s, h, d_head)
289-
iq = self.w_iq(H).view(s, h, d_idx)
290-
ik = self.w_ik(H)
317+
H_idx = H.detach()
318+
iq = self.w_iq(H_idx).view(s, h, d_idx)
319+
ik = self.w_ik(H_idx)
291320

292321
for t in range(s):
293322
idx_scores = (iq[t] @ ik[: t + 1].T) / math.sqrt(d_idx) # [h, t+1]
294323
block_scores = block_max_pool(idx_scores, block_size) # [h, n_blocks]
295-
sel = select_topk_blocks(block_scores, self.top_k) # [h, k_eff]
296-
k_eff = sel.shape[-1]
297-
score_bias = block_score_bias(block_scores, sel, block_size, group_size)
298-
299-
within = torch.arange(block_size, device=H.device)
300-
kpos = (sel[..., None] * block_size + within).reshape(h, k_eff * block_size)
301-
valid = kpos <= t # [h, n_keys]
302-
kpos_c = kpos.clamp(max=t)
324+
local_blocks = torch.full((h,), t // block_size, device=H.device, dtype=torch.long)
325+
sel = select_topk_blocks(block_scores, self.top_k, local_blocks)
326+
327+
_, kpos_c, valid = selected_token_positions(
328+
sel,
329+
block_size,
330+
s,
331+
torch.full((h, 1), t, device=H.device, dtype=torch.long),
332+
)
303333

304334
gidx = torch.arange(h, device=H.device)[:, None].expand_as(kpos_c)
305335
k_sel = k[kpos_c, gidx] # [h, n_keys, d_head]
@@ -309,7 +339,6 @@ def _forward_seq_serial(self, H: Tensor) -> Tensor:
309339
valid_h = valid.repeat_interleave(group_size, dim=0)
310340

311341
scores = torch.einsum("hd,hnd->hn", q[t], k_sel) / math.sqrt(d_head)
312-
scores = scores + score_bias
313342
scores = scores.masked_fill(~valid_h, float("-inf"))
314343
any_valid = valid_h.any(dim=-1)
315344
scores = scores.masked_fill(~any_valid[:, None], 0.0)
@@ -319,6 +348,64 @@ def _forward_seq_serial(self, H: Tensor) -> Tensor:
319348
out[t] = self.wo(o.reshape(n_h * d_head))
320349
return out
321350

351+
def kl_alignment_loss(self, H: Tensor) -> Tensor:
352+
"""paper-style auxiliary loss for the index branch.
353+
354+
The main forward pass uses hard top-k routing, so the LM loss does not
355+
train the index projections. This KL matches the index distribution to
356+
the group-averaged main-branch attention distribution on the selected
357+
token support, with the teacher and hidden-state inputs detached.
358+
"""
359+
if H.dim() == 2:
360+
return self._kl_alignment_loss_seq(H)
361+
losses = [self._kl_alignment_loss_seq(H[bi]) for bi in range(H.shape[0])]
362+
return torch.stack(losses).mean()
363+
364+
def _kl_alignment_loss_seq(self, H: Tensor) -> Tensor:
365+
s, _ = H.shape
366+
h, group_size = self.n_kv_heads, self.group_size
367+
d_idx, d_head, block_size = self.d_idx, self.d_head, self.block_size
368+
369+
H_idx = H.detach()
370+
iq = self.w_iq(H_idx).view(s, h, d_idx)
371+
ik = self.w_ik(H_idx)
372+
q = self.wq(H).view(s, h, group_size, d_head).detach()
373+
k = self.wk(H).view(s, h, d_head).detach()
374+
375+
ts = torch.arange(s, device=H.device)
376+
key_ok = ts[None, :] <= ts[:, None]
377+
local_blocks = (ts // block_size)[None].expand(h, s)
378+
379+
idx_scores = torch.einsum("qgi,ki->gqk", iq, ik) / math.sqrt(d_idx)
380+
idx_scores = idx_scores.masked_fill(~key_ok[None], float("-inf"))
381+
block_scores = block_max_pool(idx_scores, block_size)
382+
sel = select_topk_blocks(block_scores, self.top_k, local_blocks)
383+
384+
_, kpos_c, valid = selected_token_positions(
385+
sel,
386+
block_size,
387+
s,
388+
ts[None, :, None],
389+
)
390+
n_keys = kpos_c.shape[-1]
391+
gidx = torch.arange(h, device=H.device)[:, None, None].expand(h, s, n_keys)
392+
k_sel = k.permute(1, 0, 2)[gidx, kpos_c] # [h, s, n, d_head]
393+
394+
idx_selected = idx_scores.gather(dim=-1, index=kpos_c)
395+
idx_selected = idx_selected.masked_fill(~valid, float("-inf"))
396+
idx_logp = torch.log_softmax(idx_selected, dim=-1)
397+
398+
q_g = q.permute(1, 2, 0, 3) # [h, group, s, d_head]
399+
main_scores = torch.einsum("hgsd,hsnd->hgsn", q_g, k_sel) / math.sqrt(d_head)
400+
main_scores = main_scores.masked_fill(~valid[:, None], float("-inf"))
401+
teacher = torch.softmax(main_scores, dim=-1).mean(dim=1).detach()
402+
teacher = teacher.masked_fill(~valid, 0.0)
403+
404+
loss_terms = F.kl_div(idx_logp, teacher, reduction="none")
405+
loss_terms = torch.where(valid, loss_terms, torch.zeros_like(loss_terms))
406+
loss = loss_terms.sum(dim=-1)
407+
return loss.mean()
408+
322409

323410
if __name__ == "__main__":
324411
# smoke test for the module — not a unit suite.
@@ -354,11 +441,21 @@ def _forward_seq_serial(self, H: Tensor) -> Tensor:
354441
pd = (full - decoded).abs().max().item()
355442
assert pd < 1e-4, f"prefill != decode, max abs diff {pd}"
356443

357-
# selected block scores also bias the sparse logits, so the index branch
358-
# receives gradient even though the gather pattern is still hard top-k.
444+
# The LM loss trains the main branch only; the paper trains the index
445+
# branch with an auxiliary KL alignment loss.
359446
loss = out.sum()
360447
loss.backward()
361-
for name in ("wq", "wk", "wv", "wo", "w_iq", "w_ik"):
448+
for name in ("wq", "wk", "wv", "wo"):
362449
p = getattr(mod, name).weight
363450
assert p.grad is not None, f"{name} did not receive a gradient"
451+
assert mod.w_iq.weight.grad is None
452+
assert mod.w_ik.weight.grad is None
453+
454+
mod.zero_grad()
455+
kl = mod.kl_alignment_loss(x)
456+
kl.backward()
457+
assert mod.w_iq.weight.grad is not None
458+
assert mod.w_ik.weight.grad is not None
459+
assert mod.wq.weight.grad is None
460+
assert mod.wk.weight.grad is None
364461
print(f"msa smoke test ok (vec vs serial {diff:.2e}, prefill vs decode {pd:.2e})")

0 commit comments

Comments
 (0)