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}