Skip to main content

ferrox_core/
kv_swa.rs

1//! Block-size alignment for sliding-window attention (SWA).
2//!
3//! The block cache slices a sequence into fixed runs of `block_size`
4//! token positions and treats each run as an independently storable,
5//! evictable, restorable unit. That works without further thought for
6//! full causal attention: block `b` covers positions
7//! `[b*B, (b+1)*B)`, restoring blocks `0..m` reproduces positions
8//! `[0, m*B)`, and nothing about position `p`'s KV state depends on how
9//! the range was cut.
10//!
11//! **SWA breaks that, and it breaks it silently.** A sliding layer only
12//! ever needs -- and, once the KV is capped rather than kept whole,
13//! only ever *holds* -- the most recent `window` positions. Dropping
14//! stale positions has to happen in whole blocks, because a block is
15//! the eviction unit. So the resident set is a whole number of blocks,
16//! and the only way a whole number of blocks can be exactly the last
17//! `window` positions is:
18//!
19//! ```text
20//! window % block_size == 0
21//! ```
22//!
23//! When it does not divide, the boundary between "still inside the
24//! window" and "safe to drop" falls in the middle of a block, and there
25//! are only two things an implementation can do with that block: keep
26//! it (so the window is silently *wider* than the model's, changing the
27//! attention mask) or drop it (so positions the model must attend to
28//! are silently *missing*). Neither errors. Both produce confident
29//! wrong tokens, which is the same failure class
30//! [`kv_signature`](crate::kv_signature) exists to prevent -- so it is
31//! prevented the same way: refuse, naming both numbers.
32//!
33//! > Wording note, for anyone holding `docs/plans/serving-and-tiered-kv.md`
34//! > open: the plan states this as "block size must be a multiple of the
35//! > sliding-window size". That is the same constraint with the operands
36//! > the other way round, and this direction is the one that is
37//! > implementable -- forcing `block_size` up to a multiple of a 128-token
38//! > gpt-oss window would make every block at least a whole window, which
39//! > defeats the point of blocks. vLLM states it as
40//! > `sliding_window % block_size == 0`; so does this module.
41//!
42//! # Why this is live, not hypothetical
43//!
44//! ferrox runs a real alternating-SWA gpt-oss graph (window 128, every
45//! other layer) and Gemma-3 SWA prefill (window 512, every 6th layer
46//! full-attention). An alternating model does not weaken the rule: the
47//! full-attention layers impose no constraint, and one mis-aligned
48//! sliding layer is enough to corrupt the answer. And the disk tier
49//! ([`kv_disk`](crate::kv_disk)) makes a mis-aligned block *durable* --
50//! it would outlive the process that created it and be handed to the
51//! next one. That is why [`BlockLayout`] is carried in the cache
52//! signature rather than checked once at startup: a block written under
53//! one window is refused by a build expecting another, instead of being
54//! read back as if the window had never changed.
55
56/// Why a block layout was refused.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum BlockLayoutError {
59    /// A zero-token block is not a unit of anything.
60    ZeroBlockSize,
61    /// `Some(0)` is not "no window": a zero window would mean a query
62    /// attends to nothing, which no model does. A model with no
63    /// sliding-window attention must say `None`.
64    ZeroWindow,
65    /// The eviction boundary would fall inside a block. See the module
66    /// note for what the two possible responses to that both corrupt.
67    Misaligned {
68        window: usize,
69        block_size: usize,
70        remainder: usize,
71    },
72}
73
74impl std::fmt::Display for BlockLayoutError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            BlockLayoutError::ZeroBlockSize => write!(f, "KV block size must be positive"),
78            BlockLayoutError::ZeroWindow => write!(
79                f,
80                "sliding window must be positive; a model without SWA has no window at all"
81            ),
82            BlockLayoutError::Misaligned {
83                window,
84                block_size,
85                remainder,
86            } => write!(
87                f,
88                "KV block size {block_size} does not divide the sliding window {window} \
89                 ({window} % {block_size} = {remainder}); a block would straddle the \
90                 window boundary and be either kept too long or dropped too early"
91            ),
92        }
93    }
94}
95
96impl std::error::Error for BlockLayoutError {}
97
98/// How a sequence is cut into cache blocks, and the sliding window (if
99/// any) those blocks must line up with.
100///
101/// Only constructible through [`BlockLayout::new`], so a `BlockLayout`
102/// value is itself the proof that the alignment rule holds -- there is
103/// no path that produces a mis-aligned one to be checked later and
104/// forgotten.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub struct BlockLayout {
107    block_size: usize,
108    sliding_window: Option<usize>,
109}
110
111impl BlockLayout {
112    /// Validates and builds a layout. `sliding_window` is `None` for a
113    /// full-causal model and `Some(w)` for one whose sliding layers use
114    /// window `w` -- including alternating models, where some layers
115    /// are full-attention: those layers are unconstrained, so the
116    /// model's constraint is the window of the layers that have one.
117    pub fn new(block_size: usize, sliding_window: Option<usize>) -> Result<Self, BlockLayoutError> {
118        if block_size == 0 {
119            return Err(BlockLayoutError::ZeroBlockSize);
120        }
121        match sliding_window {
122            None => Ok(BlockLayout {
123                block_size,
124                sliding_window: None,
125            }),
126            Some(0) => Err(BlockLayoutError::ZeroWindow),
127            Some(window) => {
128                let remainder = window % block_size;
129                if remainder != 0 {
130                    return Err(BlockLayoutError::Misaligned {
131                        window,
132                        block_size,
133                        remainder,
134                    });
135                }
136                Ok(BlockLayout {
137                    block_size,
138                    sliding_window: Some(window),
139                })
140            }
141        }
142    }
143
144    /// A full-causal model's layout. Cannot fail except on a zero block
145    /// size.
146    pub fn full_attention(block_size: usize) -> Result<Self, BlockLayoutError> {
147        Self::new(block_size, None)
148    }
149
150    pub fn block_size(&self) -> usize {
151        self.block_size
152    }
153
154    pub fn sliding_window(&self) -> Option<usize> {
155        self.sliding_window
156    }
157
158    /// How many whole blocks a sliding layer keeps resident. `None` for
159    /// a full-causal model, which keeps all of them.
160    ///
161    /// Exact by construction: the layout would not exist if the window
162    /// were not a whole number of blocks.
163    pub fn blocks_per_window(&self) -> Option<usize> {
164        self.sliding_window.map(|w| w / self.block_size)
165    }
166}
167
168/// How many rows a *contiguous* windowed KV cache keeps resident, and
169/// when it drops the ones behind the window.
170///
171/// [`BlockLayout`] above is the same question for the block/paged tier,
172/// where the eviction unit is a block. This is the answer for
173/// `ferrox_core::cache::KvCache`, where the eviction unit is a row and
174/// the only thing stopping a per-token drain is arithmetic.
175///
176/// # Why there is slack
177///
178/// Dropping exactly one row per push moves every remaining row down by
179/// one every single token: `window * n_kv_heads * head_dim` floats per
180/// layer per token, about a megabyte per token per layer on Gemma-3-4B.
181/// So the cache is allowed to run `slack` rows past the window and then
182/// drops `slack + 1` at once, which amortises the move to roughly
183/// `window / (slack + 1)` rows per token.
184///
185/// # Why this is a type and not two lines in `push`
186///
187/// The store keeps these rows and the budget
188/// (`ferrox_models::kv_budget`) has to price exactly what the store
189/// keeps. #33 is the record of what happens when those two are separate
190/// statements of the same rule: the budget capped a sliding layer that
191/// no store ever capped, `-c auto` approved a context that did not fit,
192/// and the failure arrived as an OOM. [`KvWindow::rows_after`] is the
193/// single rule; the store calls it to decide and the budget calls it to
194/// price, so there is nothing left for them to disagree about.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub struct KvWindow {
197    window: usize,
198    slack: usize,
199}
200
201impl KvWindow {
202    /// `None` for a zero window, for [`BlockLayoutError::ZeroWindow`]'s
203    /// reason: a query that attends to nothing is not a model.
204    pub fn new(window: usize, slack: usize) -> Option<Self> {
205        (window > 0).then_some(KvWindow { window, slack })
206    }
207
208    /// The slack a caller gets when it has no opinion: half a window,
209    /// so the cache peaks at 1.5x the window and moves roughly two rows
210    /// per token instead of `window` of them.
211    pub fn with_default_slack(window: usize) -> Option<Self> {
212        Self::new(window, (window / 2).max(1))
213    }
214
215    pub fn window(&self) -> usize {
216        self.window
217    }
218
219    pub fn slack(&self) -> usize {
220        self.slack
221    }
222
223    /// The most rows this window ever leaves resident.
224    pub fn max_rows(&self) -> usize {
225        self.window + self.slack
226    }
227
228    /// **The rule.** Rows resident once the sequence has consumed
229    /// `positions` positions and the holder has evicted at every step.
230    ///
231    /// Grows one per position up to `window + slack`, then drops back to
232    /// `window` and climbs again, so the resident count cycles through
233    /// `[window, window + slack]` with period `slack + 1`.
234    ///
235    /// The invariant every reader depends on is
236    /// `rows_after(p) >= min(p, window)`: the rows kept are always at
237    /// least the last `window` positions, which is exactly the set a
238    /// windowed attention kernel reads. Everything above that is slack
239    /// the kernel skips, so evicting can only ever change *where* a row
240    /// sits, never *whether* it is read. That is why turning eviction on
241    /// is token-identical rather than approximately so, and it is
242    /// asserted in this module's tests rather than argued here.
243    pub fn rows_after(&self, positions: usize) -> usize {
244        let peak = self.window + self.slack;
245        if positions <= peak {
246            return positions;
247        }
248        self.window + (positions - peak - 1) % (self.slack + 1)
249    }
250}
251
252/// The largest block size no greater than `desired` that satisfies the
253/// rule for `window`.
254///
255/// This is what a *configuration* layer should call: an operator asking
256/// for 256-token blocks on a 128-token-window gpt-oss should get 128,
257/// not an error and not silent corruption. It never rounds *up*, since
258/// a block larger than asked for costs more memory per eviction step
259/// than the operator budgeted for; and it never returns 0.
260///
261/// `window == None` returns `desired` unchanged. `desired == 0` is
262/// treated as 1, because there is no smaller honest answer.
263///
264/// The search walks down from `desired`; block sizes are small (tens to
265/// low hundreds of tokens), so this is cheaper than factoring the
266/// window and is called once per model load.
267pub fn aligned_block_size(desired: usize, window: Option<usize>) -> usize {
268    let desired = desired.max(1);
269    let Some(window) = window.filter(|w| *w > 0) else {
270        return desired;
271    };
272    (1..=desired.min(window))
273        .rev()
274        .find(|candidate| window.is_multiple_of(*candidate))
275        // 1 divides every positive window, so the iterator is never
276        // empty -- but expressing the fallback beats an unwrap.
277        .unwrap_or(1)
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    /// The whole point of the module. gpt-oss's real window is 128; a
285    /// 48-token block does not divide it, and the two things an
286    /// implementation could do with the straddling block are both
287    /// wrong-answer bugs, not misses.
288    #[test]
289    fn a_block_size_that_does_not_divide_the_window_is_refused() {
290        let err = BlockLayout::new(48, Some(128)).expect_err("48 does not divide 128");
291        assert_eq!(
292            err,
293            BlockLayoutError::Misaligned {
294                window: 128,
295                block_size: 48,
296                remainder: 32,
297            }
298        );
299        // The operator has to be able to fix it from the message alone.
300        let text = err.to_string();
301        assert!(text.contains("48"), "{text}");
302        assert!(text.contains("128"), "{text}");
303    }
304
305    #[test]
306    fn a_block_size_that_divides_the_window_is_accepted() {
307        let layout = BlockLayout::new(32, Some(128)).expect("32 divides 128");
308        assert_eq!(layout.block_size(), 32);
309        assert_eq!(layout.sliding_window(), Some(128));
310        assert_eq!(layout.blocks_per_window(), Some(4));
311    }
312
313    /// A block larger than the whole window is the most obvious form of
314    /// the bug and must not slip through as "well, it covers it".
315    #[test]
316    fn a_block_larger_than_the_window_is_refused() {
317        assert!(matches!(
318            BlockLayout::new(256, Some(128)),
319            Err(BlockLayoutError::Misaligned { .. })
320        ));
321        // ... unless it is exactly the window, which is aligned.
322        assert!(BlockLayout::new(128, Some(128)).is_ok());
323    }
324
325    #[test]
326    fn a_full_causal_model_constrains_nothing() {
327        let layout = BlockLayout::full_attention(48).expect("no window, no constraint");
328        assert_eq!(layout.sliding_window(), None);
329        assert_eq!(layout.blocks_per_window(), None);
330    }
331
332    /// `Some(0)` and `None` are different claims and only one of them
333    /// is "this model has no SWA".
334    #[test]
335    fn a_zero_window_is_not_the_same_as_no_window() {
336        assert_eq!(
337            BlockLayout::new(16, Some(0)),
338            Err(BlockLayoutError::ZeroWindow)
339        );
340        assert!(BlockLayout::new(16, None).is_ok());
341    }
342
343    #[test]
344    fn a_zero_block_size_is_refused_with_or_without_a_window() {
345        assert_eq!(
346            BlockLayout::new(0, None),
347            Err(BlockLayoutError::ZeroBlockSize)
348        );
349        assert_eq!(
350            BlockLayout::new(0, Some(128)),
351            Err(BlockLayoutError::ZeroBlockSize)
352        );
353    }
354
355    /// Whatever `aligned_block_size` returns must be constructible --
356    /// otherwise the config layer hands the cache a size the cache
357    /// rejects, which is a startup crash rather than a fix.
358    #[test]
359    fn the_aligned_size_is_always_a_size_the_layout_accepts() {
360        for window in [1usize, 2, 3, 128, 512, 1024, 4096, 4099] {
361            for desired in [1usize, 7, 16, 31, 32, 100, 128, 256, 5000] {
362                let size = aligned_block_size(desired, Some(window));
363                assert!(size > 0 && size <= desired, "{desired}/{window} -> {size}");
364                BlockLayout::new(size, Some(window)).unwrap_or_else(|e| {
365                    panic!("aligned_block_size({desired}, {window}) = {size} is not valid: {e}")
366                });
367            }
368        }
369    }
370
371    #[test]
372    fn the_aligned_size_rounds_down_never_up() {
373        // gpt-oss: a 256-token request on a 128 window becomes 128, not
374        // 256 and not 384.
375        assert_eq!(aligned_block_size(256, Some(128)), 128);
376        // Gemma-3: 512-token window, 100 requested -> 64, the largest
377        // divisor at or below 100.
378        assert_eq!(aligned_block_size(100, Some(512)), 64);
379        // Already aligned: untouched.
380        assert_eq!(aligned_block_size(64, Some(512)), 64);
381        // A prime window leaves only 1 below itself.
382        assert_eq!(aligned_block_size(100, Some(4099)), 1);
383    }
384
385    #[test]
386    fn no_window_leaves_the_desired_size_alone() {
387        assert_eq!(aligned_block_size(48, None), 48);
388        assert_eq!(aligned_block_size(0, None), 1);
389    }
390
391    /// A window of zero would mean a query attends to nothing.
392    #[test]
393    fn a_zero_window_is_not_a_window() {
394        assert!(KvWindow::new(0, 4).is_none());
395        assert!(KvWindow::with_default_slack(0).is_none());
396        assert!(KvWindow::new(1, 0).is_some());
397    }
398
399    /// The closed form must agree with the loop it stands for.
400    ///
401    /// `rows_after` is a formula and the store is a loop that pushes one
402    /// row and drops back to the formula's answer. Two statements of one
403    /// rule is this repo's dominant bug shape, so the formula is checked
404    /// against a simulation of the loop rather than against hand-written
405    /// numbers that were themselves derived from the formula.
406    #[test]
407    fn the_closed_form_matches_a_step_by_step_simulation() {
408        for window in 1..=9usize {
409            for slack in 0..=7usize {
410                let w = KvWindow::new(window, slack).expect("positive window");
411                let mut rows = 0usize;
412                for positions in 1..=200usize {
413                    // What the store does: append one row, then drop
414                    // back to whatever the rule allows.
415                    rows += 1;
416                    rows = rows.min(w.rows_after(positions));
417                    assert_eq!(
418                        rows,
419                        w.rows_after(positions),
420                        "window {window} slack {slack} at {positions} positions"
421                    );
422                }
423            }
424        }
425    }
426
427    /// The property attention depends on, and the only reason evicting
428    /// is allowed to be token-identical: whatever else it drops, a
429    /// windowed cache still holds the last `min(positions, window)`
430    /// positions.
431    #[test]
432    fn the_last_window_positions_are_always_still_resident() {
433        for window in 1..=9usize {
434            for slack in 0..=7usize {
435                let w = KvWindow::new(window, slack).expect("positive window");
436                for positions in 0..=200usize {
437                    let rows = w.rows_after(positions);
438                    assert!(
439                        rows >= positions.min(window),
440                        "window {window} slack {slack} at {positions}: kept {rows} rows, \
441                         which is fewer than the {} the kernel reads",
442                        positions.min(window)
443                    );
444                    assert!(rows <= positions, "cannot keep rows that were never pushed");
445                    assert!(rows <= w.max_rows(), "resident rows must stay bounded");
446                }
447            }
448        }
449    }
450
451    /// The point of the whole exercise: resident rows STOP GROWING.
452    #[test]
453    fn a_windowed_layer_stops_growing_while_positions_do_not() {
454        let w = KvWindow::with_default_slack(1024).expect("positive window");
455        assert_eq!(w.rows_after(512), 512);
456        assert!(w.rows_after(32_768) <= w.max_rows());
457        assert_eq!(w.max_rows(), 1024 + 512);
458        // 21x fewer rows than positions at a 32k context.
459        assert!(w.rows_after(32_768) * 20 < 32_768);
460    }
461
462    /// Slack is what makes the drain a block move instead of a per-token
463    /// one: with `slack` rows of headroom the cache only actually drops
464    /// rows once every `slack + 1` positions.
465    #[test]
466    fn rows_are_dropped_once_per_slack_plus_one_positions() {
467        let w = KvWindow::new(8, 3).expect("positive window");
468        let drops = (1..=400usize)
469            .filter(|p| w.rows_after(*p) < w.rows_after(p - 1) + 1)
470            .count();
471        // The first drop is at position 12 (the first that would take
472        // the cache past `window + slack` = 11), then one every
473        // `slack + 1` = 4 positions.
474        assert_eq!(drops, (400 - 12) / 4 + 1);
475        // The same statement the other way round: 400 positions cost 98
476        // block moves, not 400 per-token ones.
477        assert!(drops * 4 <= 400);
478    }
479}