Skip to main content

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}