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
3738 top_k = number of blocks kept per (group, query)
3839
3940implemented:
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
323410if __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