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/// The largest block size no greater than `desired` that satisfies the
169/// rule for `window`.
170///
171/// This is what a *configuration* layer should call: an operator asking
172/// for 256-token blocks on a 128-token-window gpt-oss should get 128,
173/// not an error and not silent corruption. It never rounds *up*, since
174/// a block larger than asked for costs more memory per eviction step
175/// than the operator budgeted for; and it never returns 0.
176///
177/// `window == None` returns `desired` unchanged. `desired == 0` is
178/// treated as 1, because there is no smaller honest answer.
179///
180/// The search walks down from `desired`; block sizes are small (tens to
181/// low hundreds of tokens), so this is cheaper than factoring the
182/// window and is called once per model load.
183pub fn aligned_block_size(desired: usize, window: Option<usize>) -> usize {
184    let desired = desired.max(1);
185    let Some(window) = window.filter(|w| *w > 0) else {
186        return desired;
187    };
188    (1..=desired.min(window))
189        .rev()
190        .find(|candidate| window.is_multiple_of(*candidate))
191        // 1 divides every positive window, so the iterator is never
192        // empty -- but expressing the fallback beats an unwrap.
193        .unwrap_or(1)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// The whole point of the module. gpt-oss's real window is 128; a
201    /// 48-token block does not divide it, and the two things an
202    /// implementation could do with the straddling block are both
203    /// wrong-answer bugs, not misses.
204    #[test]
205    fn a_block_size_that_does_not_divide_the_window_is_refused() {
206        let err = BlockLayout::new(48, Some(128)).expect_err("48 does not divide 128");
207        assert_eq!(
208            err,
209            BlockLayoutError::Misaligned {
210                window: 128,
211                block_size: 48,
212                remainder: 32,
213            }
214        );
215        // The operator has to be able to fix it from the message alone.
216        let text = err.to_string();
217        assert!(text.contains("48"), "{text}");
218        assert!(text.contains("128"), "{text}");
219    }
220
221    #[test]
222    fn a_block_size_that_divides_the_window_is_accepted() {
223        let layout = BlockLayout::new(32, Some(128)).expect("32 divides 128");
224        assert_eq!(layout.block_size(), 32);
225        assert_eq!(layout.sliding_window(), Some(128));
226        assert_eq!(layout.blocks_per_window(), Some(4));
227    }
228
229    /// A block larger than the whole window is the most obvious form of
230    /// the bug and must not slip through as "well, it covers it".
231    #[test]
232    fn a_block_larger_than_the_window_is_refused() {
233        assert!(matches!(
234            BlockLayout::new(256, Some(128)),
235            Err(BlockLayoutError::Misaligned { .. })
236        ));
237        // ... unless it is exactly the window, which is aligned.
238        assert!(BlockLayout::new(128, Some(128)).is_ok());
239    }
240
241    #[test]
242    fn a_full_causal_model_constrains_nothing() {
243        let layout = BlockLayout::full_attention(48).expect("no window, no constraint");
244        assert_eq!(layout.sliding_window(), None);
245        assert_eq!(layout.blocks_per_window(), None);
246    }
247
248    /// `Some(0)` and `None` are different claims and only one of them
249    /// is "this model has no SWA".
250    #[test]
251    fn a_zero_window_is_not_the_same_as_no_window() {
252        assert_eq!(
253            BlockLayout::new(16, Some(0)),
254            Err(BlockLayoutError::ZeroWindow)
255        );
256        assert!(BlockLayout::new(16, None).is_ok());
257    }
258
259    #[test]
260    fn a_zero_block_size_is_refused_with_or_without_a_window() {
261        assert_eq!(
262            BlockLayout::new(0, None),
263            Err(BlockLayoutError::ZeroBlockSize)
264        );
265        assert_eq!(
266            BlockLayout::new(0, Some(128)),
267            Err(BlockLayoutError::ZeroBlockSize)
268        );
269    }
270
271    /// Whatever `aligned_block_size` returns must be constructible --
272    /// otherwise the config layer hands the cache a size the cache
273    /// rejects, which is a startup crash rather than a fix.
274    #[test]
275    fn the_aligned_size_is_always_a_size_the_layout_accepts() {
276        for window in [1usize, 2, 3, 128, 512, 1024, 4096, 4099] {
277            for desired in [1usize, 7, 16, 31, 32, 100, 128, 256, 5000] {
278                let size = aligned_block_size(desired, Some(window));
279                assert!(size > 0 && size <= desired, "{desired}/{window} -> {size}");
280                BlockLayout::new(size, Some(window)).unwrap_or_else(|e| {
281                    panic!("aligned_block_size({desired}, {window}) = {size} is not valid: {e}")
282                });
283            }
284        }
285    }
286
287    #[test]
288    fn the_aligned_size_rounds_down_never_up() {
289        // gpt-oss: a 256-token request on a 128 window becomes 128, not
290        // 256 and not 384.
291        assert_eq!(aligned_block_size(256, Some(128)), 128);
292        // Gemma-3: 512-token window, 100 requested -> 64, the largest
293        // divisor at or below 100.
294        assert_eq!(aligned_block_size(100, Some(512)), 64);
295        // Already aligned: untouched.
296        assert_eq!(aligned_block_size(64, Some(512)), 64);
297        // A prime window leaves only 1 below itself.
298        assert_eq!(aligned_block_size(100, Some(4099)), 1);
299    }
300
301    #[test]
302    fn no_window_leaves_the_desired_size_alone() {
303        assert_eq!(aligned_block_size(48, None), 48);
304        assert_eq!(aligned_block_size(0, None), 1);
305    }
306}