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}