ferrox_core/block_sparse.rs
1//! MiniMax-M3's block-sparse attention selection: which 128-token KV
2//! blocks a query may look at.
3//!
4//! Ported from FreeToken's `models/minimax_m3/args.py` selection rule.
5//! This is the *decision* half -- which blocks are visible. The
6//! attention itself is then an ordinary masked pass over the positions
7//! those blocks cover, which
8//! [`crate::attention::causal_mla_attention_sparse`] already does.
9//!
10//! # Four rules, and one of them is what stops a NaN
11//!
12//! - **A block's score is the MAX over its positions**, not the mean
13//! and not the sum. A block earns its place on its single best
14//! match: one strongly-related token in a block of 128 is exactly the
15//! case sparse attention exists to catch, and a mean would average it
16//! away against 127 unrelated neighbours.
17//!
18//! - **No softmax scale.** The raw dot product is used directly,
19//! because only the ORDER of the scores is consumed. Dividing by
20//! `sqrt(d)` would scale every score by the same positive constant
21//! and change nothing, so the reference does not, and neither does
22//! this.
23//!
24//! - **Selection is per KV head, with no cross-head reduction.** One
25//! index head scores for one KV head, and that KV head's whole GQA
26//! group reads the blocks it picked. Reducing across heads first --
27//! by summing or maxing the scores -- would give every group the same
28//! block set, which is the opposite of what per-head selection is
29//! for.
30//!
31//! - **The newest `local_blocks` and the first `init_blocks` are
32//! force-included**, before any scoring. This is not a quality
33//! heuristic bolted on top: it is what guarantees the selection is
34//! never empty. A query early in a sequence, or one whose scores are
35//! all equally poor, would otherwise select zero blocks, and
36//! attention over zero positions is a softmax over an empty set --
37//! which is a NaN that propagates through the whole forward pass and
38//! surfaces as garbage output, not as an error.
39//!
40//! # The block size is an ABI, not a tuning knob
41//!
42//! [`MINIMAX_BLOCK_SIZE`] is 128 and also pins the KV page size: the
43//! selection hands back block indices, and a pager whose page is a
44//! different size cannot honour them without splitting or merging
45//! pages, which is exactly the bookkeeping block-sparse attention
46//! exists to avoid.
47
48/// MiniMax-M3's KV block, in tokens. Also the KV page size -- see the
49/// module docs.
50pub const MINIMAX_BLOCK_SIZE: usize = 128;
51
52/// How many blocks a query may see, and which are free.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct BlockSparseConfig {
55 /// Tokens per block. [`MINIMAX_BLOCK_SIZE`] for a real checkpoint;
56 /// configurable only so the rules can be tested at a readable size.
57 pub block_size: usize,
58 /// Total blocks visible per query, force-included ones counted.
59 pub top_blocks: usize,
60 /// Blocks at the start of the sequence that are always visible.
61 pub init_blocks: usize,
62 /// Blocks nearest the query that are always visible.
63 pub local_blocks: usize,
64}
65
66impl Default for BlockSparseConfig {
67 fn default() -> Self {
68 BlockSparseConfig {
69 block_size: MINIMAX_BLOCK_SIZE,
70 top_blocks: 32,
71 init_blocks: 1,
72 local_blocks: 2,
73 }
74 }
75}
76
77impl BlockSparseConfig {
78 /// Blocks covering positions `0..=query_pos`, the last one possibly
79 /// partial.
80 pub fn causal_blocks(&self, query_pos: usize) -> usize {
81 if self.block_size == 0 {
82 return 0;
83 }
84 query_pos / self.block_size + 1
85 }
86}
87
88/// The blocks each KV head may read for one query position, ascending.
89///
90/// `index_q` is one index head per KV head (`[n_kv_heads]
91/// [index_head_dim]`); `index_k` is one index key per position
92/// (`[n_positions][index_head_dim]`), shared across heads. Returns one
93/// selection per KV head, in the same order.
94///
95/// Never returns an empty selection for a query at a valid position --
96/// see the module docs on why that matters.
97pub fn block_sparse_select(
98 index_q: &[Vec<f32>],
99 index_k: &[Vec<f32>],
100 query_pos: usize,
101 cfg: &BlockSparseConfig,
102) -> Vec<Vec<usize>> {
103 let n_blocks = cfg.causal_blocks(query_pos).min(
104 // A query cannot see past the keys that exist, even if its
105 // position claims otherwise.
106 if cfg.block_size == 0 {
107 0
108 } else {
109 index_k.len().div_ceil(cfg.block_size)
110 },
111 );
112 if n_blocks == 0 {
113 return vec![Vec::new(); index_q.len()];
114 }
115
116 index_q
117 .iter()
118 .map(|q| select_for_head(q, index_k, query_pos, n_blocks, cfg))
119 .collect()
120}
121
122fn select_for_head(
123 q: &[f32],
124 index_k: &[Vec<f32>],
125 query_pos: usize,
126 n_blocks: usize,
127 cfg: &BlockSparseConfig,
128) -> Vec<usize> {
129 let mut forced = vec![false; n_blocks];
130 for slot in forced.iter_mut().take(cfg.init_blocks.min(n_blocks)) {
131 *slot = true;
132 }
133 for slot in forced
134 .iter_mut()
135 .skip(n_blocks.saturating_sub(cfg.local_blocks))
136 {
137 *slot = true;
138 }
139
140 let budget = cfg.top_blocks.max(forced.iter().filter(|f| **f).count());
141 let mut chosen: Vec<usize> = (0..n_blocks).filter(|&b| forced[b]).collect();
142 if chosen.len() >= budget || chosen.len() == n_blocks {
143 chosen.sort_unstable();
144 return chosen;
145 }
146
147 // Score the rest. MAX over the block's causally-visible positions,
148 // raw dot product, no scale.
149 let mut scored: Vec<(usize, f32)> = (0..n_blocks)
150 .filter(|&b| !forced[b])
151 .map(|b| {
152 let start = b * cfg.block_size;
153 let end = ((b + 1) * cfg.block_size)
154 .min(index_k.len())
155 .min(query_pos + 1);
156 let best = (start..end)
157 .map(|p| dot(q, &index_k[p]))
158 .fold(f32::NEG_INFINITY, f32::max);
159 (b, best)
160 })
161 .collect();
162
163 // Ties break toward the LOWER block index, deterministically: a
164 // selection that varied run to run would make a cached prefix
165 // disagree with the run that produced it.
166 scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
167 for (b, _) in scored.into_iter().take(budget - chosen.len()) {
168 chosen.push(b);
169 }
170 chosen.sort_unstable();
171 chosen
172}
173
174fn dot(a: &[f32], b: &[f32]) -> f32 {
175 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
176}
177
178/// The positions a block selection covers, ascending and
179/// causally clipped -- the form
180/// [`crate::attention::causal_mla_attention_sparse`] takes.
181pub fn positions_of_blocks(
182 blocks: &[usize],
183 query_pos: usize,
184 n_positions: usize,
185 cfg: &BlockSparseConfig,
186) -> Vec<usize> {
187 let mut out = Vec::new();
188 for &b in blocks {
189 let start = b * cfg.block_size;
190 let end = ((b + 1) * cfg.block_size)
191 .min(n_positions)
192 .min(query_pos + 1);
193 out.extend(start..end);
194 }
195 out.sort_unstable();
196 out.dedup();
197 out
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn keys(n: usize, hot: &[usize]) -> Vec<Vec<f32>> {
205 (0..n)
206 .map(|p| {
207 if hot.contains(&p) {
208 vec![1.0, 0.0]
209 } else {
210 vec![0.0, 0.01]
211 }
212 })
213 .collect()
214 }
215
216 fn cfg(block: usize, top: usize, init: usize, local: usize) -> BlockSparseConfig {
217 BlockSparseConfig {
218 block_size: block,
219 top_blocks: top,
220 init_blocks: init,
221 local_blocks: local,
222 }
223 }
224
225 /// The rule that stops a NaN. A query whose scores are all equally
226 /// poor -- and one at the very start of a sequence -- still gets at
227 /// least one block, because the forced ones are added before any
228 /// scoring. Attention over zero positions is a softmax over an
229 /// empty set, which propagates NaN through the whole forward pass
230 /// and surfaces as garbage rather than as an error.
231 #[test]
232 fn a_selection_is_never_empty_however_poor_the_scores() {
233 let c = cfg(4, 1, 1, 1);
234 // Every key identical and orthogonal to the query: no block can
235 // win on score.
236 let k: Vec<Vec<f32>> = (0..64).map(|_| vec![0.0, 0.0]).collect();
237 let q = vec![vec![1.0, 0.0]];
238 for pos in [0usize, 1, 3, 4, 17, 63] {
239 let sel = block_sparse_select(&q, &k, pos, &c);
240 assert!(
241 !sel[0].is_empty(),
242 "position {pos} selected nothing, which is a NaN downstream"
243 );
244 let covered = positions_of_blocks(&sel[0], pos, k.len(), &c);
245 assert!(!covered.is_empty(), "position {pos} covers no positions");
246 assert!(
247 covered.iter().all(|&p| p <= pos),
248 "position {pos} saw the future"
249 );
250 }
251 }
252
253 /// A block earns its place on its single best position, not its
254 /// average. One strongly-related token among 127 unrelated ones is
255 /// exactly what block-sparse attention exists to find, and a mean
256 /// would average it away.
257 #[test]
258 fn a_block_is_scored_by_its_best_position_not_its_average() {
259 // Four blocks of four. Block 1 holds a single hot key; block 2
260 // holds none. With one free slot, block 1 must win.
261 let c = cfg(4, 3, 1, 1);
262 let k = keys(16, &[5]);
263 let q = vec![vec![1.0, 0.0]];
264 let sel = block_sparse_select(&q, &k, 15, &c);
265 assert!(
266 sel[0].contains(&1),
267 "the block holding the one hot key must be chosen, got {:?}",
268 sel[0]
269 );
270 }
271
272 /// Selection is per KV head with no cross-head reduction: two heads
273 /// looking for different things get different blocks. Reducing
274 /// across heads first would hand every GQA group one shared block
275 /// set, which is the opposite of what per-head selection is for.
276 #[test]
277 fn each_kv_head_selects_for_itself() {
278 let c = cfg(4, 3, 1, 1);
279 let mut k: Vec<Vec<f32>> = (0..16).map(|_| vec![0.0, 0.0]).collect();
280 k[5] = vec![1.0, 0.0]; // block 1, only head 0 wants it
281 k[9] = vec![0.0, 1.0]; // block 2, only head 1 wants it
282 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
283 let sel = block_sparse_select(&q, &k, 15, &c);
284 assert_eq!(sel.len(), 2, "one selection per KV head");
285 assert!(sel[0].contains(&1), "head 0: {:?}", sel[0]);
286 assert!(sel[1].contains(&2), "head 1: {:?}", sel[1]);
287 assert_ne!(sel[0], sel[1], "the heads must not be reduced together");
288 }
289
290 /// The first `init_blocks` and the newest `local_blocks` are in
291 /// before anything is scored, even when they score worst.
292 #[test]
293 fn the_first_and_newest_blocks_are_included_before_scoring() {
294 let c = cfg(4, 3, 1, 1);
295 // The hot keys are all in the middle blocks, so on score alone
296 // blocks 0 and 3 would both lose.
297 let k = keys(16, &[4, 5, 8, 9]);
298 let q = vec![vec![1.0, 0.0]];
299 let sel = block_sparse_select(&q, &k, 15, &c);
300 assert!(sel[0].contains(&0), "init block missing: {:?}", sel[0]);
301 assert!(sel[0].contains(&3), "local block missing: {:?}", sel[0]);
302 }
303
304 /// Only the ORDER of the scores is consumed, so no softmax scale is
305 /// applied. Scaling every index key by a positive constant must
306 /// leave the selection identical -- if a scale were being applied
307 /// and compared against anything absolute, it would not.
308 #[test]
309 fn the_selection_depends_only_on_the_order_of_the_scores() {
310 let c = cfg(4, 3, 1, 1);
311 let k = keys(16, &[5]);
312 let scaled: Vec<Vec<f32>> = k
313 .iter()
314 .map(|row| row.iter().map(|v| v * 1000.0).collect())
315 .collect();
316 let q = vec![vec![1.0, 0.0]];
317 assert_eq!(
318 block_sparse_select(&q, &k, 15, &c),
319 block_sparse_select(&q, &scaled, 15, &c)
320 );
321 }
322
323 /// A query never sees a block that starts after it, and the block
324 /// containing it is clipped to its own position.
325 #[test]
326 fn selection_is_causal_and_the_current_block_is_clipped() {
327 let c = cfg(4, 8, 1, 1);
328 let k = keys(16, &[]);
329 let q = vec![vec![1.0, 0.0]];
330 let sel = block_sparse_select(&q, &k, 6, &c);
331 assert!(
332 sel[0].iter().all(|&b| b <= 1),
333 "block 2 starts at position 8, after the query at 6: {:?}",
334 sel[0]
335 );
336 let covered = positions_of_blocks(&sel[0], 6, k.len(), &c);
337 assert_eq!(covered, vec![0, 1, 2, 3, 4, 5, 6]);
338 }
339
340 /// The budget is a ceiling, and the forced blocks are inside it --
341 /// not added on top of it, which would silently widen every query's
342 /// working set beyond what the KV pool was sized for.
343 #[test]
344 fn the_budget_bounds_the_selection_including_the_forced_blocks() {
345 let c = cfg(4, 3, 1, 1);
346 let k = keys(64, &[]);
347 let q = vec![vec![1.0, 0.0]];
348 let sel = block_sparse_select(&q, &k, 63, &c);
349 assert_eq!(sel[0].len(), 3, "budget of 3: {:?}", sel[0]);
350
351 // A budget smaller than the forced set cannot drop a forced
352 // block -- doing so would reintroduce the empty selection --
353 // so it widens to hold exactly them.
354 let tight = cfg(4, 1, 2, 2);
355 let sel = block_sparse_select(&q, &k, 63, &tight);
356 assert_eq!(sel[0], vec![0, 1, 14, 15]);
357 }
358
359 /// Ties break toward the lower block index rather than by whatever
360 /// order the sort happened to see, so a cached prefix and the run
361 /// that produced it cannot disagree.
362 #[test]
363 fn ties_break_deterministically_toward_the_lower_block() {
364 let c = cfg(4, 3, 0, 0);
365 // Every key identical: every block ties.
366 let k: Vec<Vec<f32>> = (0..16).map(|_| vec![1.0, 0.0]).collect();
367 let q = vec![vec![1.0, 0.0]];
368 let first = block_sparse_select(&q, &k, 15, &c);
369 assert_eq!(first[0], vec![0, 1, 2]);
370 for _ in 0..8 {
371 assert_eq!(block_sparse_select(&q, &k, 15, &c), first);
372 }
373 }
374
375 /// A query past the end of the keys is clipped to what exists,
376 /// rather than naming blocks the pager never allocated.
377 #[test]
378 fn a_query_beyond_the_keys_is_clipped_to_what_exists() {
379 let c = cfg(4, 8, 1, 1);
380 let k = keys(6, &[]);
381 let q = vec![vec![1.0, 0.0]];
382 let sel = block_sparse_select(&q, &k, 100, &c);
383 assert_eq!(sel[0], vec![0, 1], "only two blocks of keys exist");
384 let covered = positions_of_blocks(&sel[0], 100, k.len(), &c);
385 assert_eq!(covered, vec![0, 1, 2, 3, 4, 5]);
386 }
387
388 /// The real geometry: 128-token blocks, and the block size is the
389 /// KV page size too.
390 #[test]
391 fn the_real_block_size_is_the_kv_page_size() {
392 assert_eq!(MINIMAX_BLOCK_SIZE, 128);
393 let c = BlockSparseConfig::default();
394 assert_eq!(c.block_size, MINIMAX_BLOCK_SIZE);
395 assert_eq!(c.causal_blocks(0), 1);
396 assert_eq!(c.causal_blocks(127), 1);
397 assert_eq!(c.causal_blocks(128), 2);
398 }
399}