ferrox_core/cache.rs
1//! A per-layer KV cache, growable one position at a time during decode.
2//! Two growth strategies exist:
3//!
4//! - `with_pool`: PagedAttention-style block allocation. Many caches
5//! (one per concurrent request, typically) draw fixed-size blocks
6//! from one shared, bounded `KvBlockPool` instead of each
7//! independently pre-committing to a worst-case context length.
8//! Growth happens in fixed block-sized quanta, and a cache's blocks
9//! return to the shared pool when it's dropped, so the pool's free
10//! count is a real, live admission-control signal a caller can check
11//! before accepting a new request. This is the block-*allocation*
12//! half of PagedAttention; it does not (yet) change how attention
13//! reads a cache -- `k`/`v` are still read as one contiguous slice
14//! per sequence (see `Decoder::forward_token`/`forward_batch`), just
15//! backed by capacity that grows in block-sized steps instead of
16//! Rust's default exponential `Vec` growth. Wiring this into
17//! `ferrox-server` as live per-request admission control via
18//! `FERROX_KV_POOL_BLOCKS`/`FERROX_KV_POOL_BLOCK_SIZE`.
19
20use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
21
22use crate::kv_swa::KvWindow;
23
24/// Returned by `KvCache::push` (and `with_pool`) when a pool-backed
25/// cache needs another block but its shared `KvBlockPool` has none
26/// free. Caches built with `new`/`with_capacity` never return this --
27/// their growth is unconditional, matching their pre-paging behavior
28/// exactly.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct KvPoolExhausted;
31
32impl std::fmt::Display for KvPoolExhausted {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 write!(f, "KV cache block pool exhausted: no free blocks remain")
35 }
36}
37
38impl std::error::Error for KvPoolExhausted {}
39
40/// A bounded pool of fixed-size KV-cache blocks (in positions) shared
41/// across many `KvCache` instances, typically one pool per server
42/// process. Each `KvCache::with_pool` acquires one block up front and
43/// one more each time it grows past its currently held capacity;
44/// `free_blocks` is therefore a live, accurate admission-control
45/// signal -- a caller can check it before accepting a new request
46/// rather than discovering exhaustion only after committing memory.
47pub struct KvBlockPool {
48 block_size: usize,
49 total_blocks: usize,
50 free_blocks: usize,
51}
52
53impl KvBlockPool {
54 /// `block_size` positions per block, `total_blocks` blocks in the
55 /// whole shared budget (so `block_size * total_blocks` positions
56 /// total, across however many caches draw from this pool at once).
57 pub fn new(block_size: usize, total_blocks: usize) -> Self {
58 assert!(block_size > 0, "block_size must be positive");
59 KvBlockPool {
60 block_size,
61 total_blocks,
62 free_blocks: total_blocks,
63 }
64 }
65
66 pub fn block_size(&self) -> usize {
67 self.block_size
68 }
69
70 pub fn total_blocks(&self) -> usize {
71 self.total_blocks
72 }
73
74 pub fn free_blocks(&self) -> usize {
75 self.free_blocks
76 }
77
78 /// Re-budget the pool.
79 ///
80 /// The pool is an *accounting* budget, not an allocator: each
81 /// `KvCache` owns its own buffer and this counts how many blocks
82 /// the deployment has promised. So a resize is arithmetic, with one
83 /// rule that is not.
84 ///
85 /// Shrinking below what is currently held is REFUSED and the pool
86 /// is left exactly as it was. `free_blocks` would have to go
87 /// negative to represent it, and the alternative -- clamping it to
88 /// zero -- silently over-promises: the caches already holding those
89 /// blocks do not give them back, so every later `try_acquire`
90 /// would be deciding against a budget that does not describe the
91 /// memory in use.
92 ///
93 /// Returns the number of blocks currently held when it refuses, so
94 /// the caller can say what the floor actually is rather than making
95 /// the operator find it by being rejected.
96 pub fn resize(&mut self, total_blocks: usize) -> Result<(), usize> {
97 let in_use = self.total_blocks - self.free_blocks;
98 if total_blocks < in_use {
99 return Err(in_use);
100 }
101 self.free_blocks = total_blocks - in_use;
102 self.total_blocks = total_blocks;
103 Ok(())
104 }
105
106 fn try_acquire(&mut self, n: usize) -> bool {
107 if n <= self.free_blocks {
108 self.free_blocks -= n;
109 true
110 } else {
111 false
112 }
113 }
114
115 fn release(&mut self, n: usize) {
116 self.free_blocks = (self.free_blocks + n).min(self.total_blocks);
117 }
118}
119
120struct PooledState {
121 pool: Arc<Mutex<KvBlockPool>>,
122 block_size: usize,
123 blocks_held: usize,
124}
125
126pub struct KvCache {
127 pub n_kv_heads: usize,
128 /// The K head width. Also the Q head width and the width RoPE
129 /// rotates within (`n_embd_head_k`).
130 pub head_dim: usize,
131 /// The V head width (`n_embd_head_v`). Equal to `head_dim` for every
132 /// architecture but MiMo-V2 (`head_dim: 192, v_head_dim: 128`);
133 /// `ferrox_models::kv_head_dims` is the seam. Every row of `k` is
134 /// `n_kv_heads * head_dim` wide and every row of `v` is
135 /// `n_kv_heads * v_head_dim`, so the two buffers are sized and
136 /// indexed by their OWN width throughout this file.
137 pub v_head_dim: usize,
138 pub k: Vec<f32>, // [rows, n_kv_heads, head_dim], flattened
139 pub v: Vec<f32>, // [rows, n_kv_heads, v_head_dim], flattened
140 /// Positions this sequence has consumed.
141 ///
142 /// **Not the same thing as the number of rows in `k`/`v`**, and the
143 /// distinction is the whole reason this field is private. They are
144 /// equal today because nothing evicts, and they stop being equal
145 /// the moment a windowed layer drops a position behind its window
146 /// (#61): `positions` keeps counting, `rows` does not.
147 ///
148 /// Every reader has to say which one it meant, so there is no
149 /// `seq_len` any more. [`Self::positions`] is what RoPE, a resume
150 /// point and a truncate target mean; [`Self::rows`] is what
151 /// attention iterates and what the bytes cost.
152 ///
153 /// Two bugs have already been caused by the two being one field.
154 /// `PrefillState` read the KV's length as the position to resume at
155 /// (#37), and `DraftModelSpeculator::sync` trusted a counter beside
156 /// a cache that a device-resident backend leaves empty. Both were
157 /// right to read the store rather than keep a copy; both would be
158 /// wrong the day the store evicts.
159 positions: usize,
160 /// The capacity (in positions) this cache was pre-allocated for,
161 /// if any. `None` for caches built with `new` or `with_pool`.
162 planned_capacity: Option<usize>,
163 /// `Some` for caches built with `with_pool`; tracks the shared
164 /// pool and how many blocks this cache currently holds, so its
165 /// blocks can be returned on drop.
166 pool_state: Option<PooledState>,
167 /// `Some` once a windowed layer's cache has been told it may drop
168 /// rows behind its window (#61). `None` -- the default, and what
169 /// every constructor produces -- means this cache keeps every
170 /// position it was ever pushed, which is what every store in this
171 /// engine did before.
172 ///
173 /// Armed by the decoder, never by a constructor, because the fact
174 /// that a layer is windowed lives in `ModelConfig` and the decision
175 /// that eviction is *safe for this run* lives in
176 /// `ferrox_models::decoder::kv_window`. See [`Self::arm_window`].
177 window: Option<KvWindow>,
178 /// A recurrent layer's state (`crate::recurrent_state`), `None` for
179 /// every attention layer and for a recurrent layer's sequence that
180 /// has not run a token yet. Created by the layer's block at the
181 /// size its weights name; cloned with the cache; cleared with it;
182 /// and the reason [`Self::truncate`] can refuse
183 /// ([`Self::can_truncate_to`]). Public because the block that owns
184 /// the geometry lives in another crate.
185 pub recurrent: Option<crate::recurrent_state::RecurrentState>,
186}
187
188/// Cloning a pool-backed cache detaches the clone from pool accounting
189/// (its `k`/`v`/`seq_len` data is copied normally, but the clone does
190/// not hold or later release any blocks itself) -- mirroring how
191/// `ferrox-models::prefix_cache` already uses `KvCache::clone` to fork
192/// a cached prefix into a new, independent request's cache. Only the
193/// original cache's blocks are released, exactly once, when it drops.
194impl Clone for KvCache {
195 fn clone(&self) -> Self {
196 KvCache {
197 n_kv_heads: self.n_kv_heads,
198 head_dim: self.head_dim,
199 v_head_dim: self.v_head_dim,
200 k: self.k.clone(),
201 v: self.v.clone(),
202 positions: self.positions,
203 planned_capacity: self.planned_capacity,
204 pool_state: None,
205 // Carried, not reset: a clone of a cache that has already
206 // dropped rows is a cache that has already dropped rows,
207 // and pretending otherwise would let the clone's
208 // `positions` be read as a row count again.
209 window: self.window,
210 recurrent: self.recurrent.clone(),
211 }
212 }
213}
214
215impl Drop for KvCache {
216 fn drop(&mut self) {
217 if let Some(state) = &self.pool_state {
218 if let Ok(mut pool) = state.pool.lock() {
219 pool.release(state.blocks_held);
220 }
221 }
222 }
223}
224
225impl KvCache {
226 /// Positions this sequence has consumed: what RoPE means, what a
227 /// resume point means, and what a truncate target is measured in.
228 ///
229 /// Monotonic except through [`Self::truncate`] and [`Self::clear`].
230 /// Equal to [`Self::rows`] today, and deliberately a different
231 /// method so it stops being equal safely (#61).
232 #[inline]
233 pub fn positions(&self) -> usize {
234 self.positions
235 }
236
237 /// Sets the position counter directly, for a constructor that
238 /// filled `k`/`v` by hand rather than through `push`.
239 ///
240 /// Deliberately narrow and deliberately not `pub`: the only honest
241 /// caller is one that has just written exactly this many rows, and
242 /// a public setter on a counter the buffer should imply is how the
243 /// two drift apart again.
244 pub(crate) fn set_positions(&mut self, positions: usize) {
245 debug_assert_eq!(
246 positions,
247 self.rows(),
248 "set_positions must agree with the rows just written"
249 );
250 self.positions = positions;
251 }
252
253 /// Test-only: sets the position counter WITHOUT the agreement check
254 /// [`Self::set_positions`] makes.
255 ///
256 /// Exists for one caller: `kv_signature`'s test that a serialized
257 /// payload whose declared count contradicts its buffers is
258 /// rejected. That contradiction is the thing under test, so it has
259 /// to be constructible, and it must not be constructible anywhere
260 /// else.
261 #[cfg(test)]
262 pub(crate) fn force_positions_for_test(&mut self, positions: usize) {
263 self.positions = positions;
264 }
265
266 /// Rows of K/V actually resident: what attention iterates over and
267 /// what the memory costs.
268 ///
269 /// Derived from the buffer rather than counted alongside it, so it
270 /// cannot drift from what is really there. That is the same rule
271 /// the batched prefill learned in #37: read the cursor, do not keep
272 /// a copy of it.
273 #[inline]
274 pub fn rows(&self) -> usize {
275 let k_width = self.k_width();
276 if k_width == 0 {
277 return 0;
278 }
279 self.k.len() / k_width
280 }
281
282 /// One position's K row: `n_kv_heads * head_dim` elements.
283 #[inline]
284 pub fn k_width(&self) -> usize {
285 self.n_kv_heads * self.head_dim
286 }
287
288 /// One position's V row: `n_kv_heads * v_head_dim` elements.
289 #[inline]
290 pub fn v_width(&self) -> usize {
291 self.n_kv_heads * self.v_head_dim
292 }
293
294 /// A cache whose K and V heads share one width -- every architecture
295 /// but MiMo-V2. [`Self::new_split`] is the general form.
296 pub fn new(n_kv_heads: usize, head_dim: usize) -> Self {
297 Self::new_split(n_kv_heads, head_dim, head_dim)
298 }
299
300 pub fn new_split(n_kv_heads: usize, head_dim: usize, v_head_dim: usize) -> Self {
301 KvCache {
302 n_kv_heads,
303 head_dim,
304 v_head_dim,
305 k: Vec::new(),
306 v: Vec::new(),
307 positions: 0,
308 planned_capacity: None,
309 pool_state: None,
310 window: None,
311 recurrent: None,
312 }
313 }
314
315 /// Pre-allocates storage for up to `max_seq_len` positions, so
316 /// `push` never triggers a reallocation-and-copy during decode.
317 /// Use this when the maximum context length is known ahead of time
318 pub fn with_capacity(n_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
319 Self::with_capacity_split(n_kv_heads, head_dim, head_dim, max_seq_len)
320 }
321
322 /// [`Self::with_capacity`] with a V head width of its own.
323 pub fn with_capacity_split(
324 n_kv_heads: usize,
325 head_dim: usize,
326 v_head_dim: usize,
327 max_seq_len: usize,
328 ) -> Self {
329 KvCache {
330 n_kv_heads,
331 head_dim,
332 v_head_dim,
333 k: Vec::with_capacity(max_seq_len * n_kv_heads * head_dim),
334 v: Vec::with_capacity(max_seq_len * n_kv_heads * v_head_dim),
335 positions: 0,
336 planned_capacity: Some(max_seq_len),
337 pool_state: None,
338 window: None,
339 recurrent: None,
340 }
341 }
342
343 /// Acquires up front however many blocks from `pool` are needed to
344 /// cover `max_seq_len` positions (at least one, even if
345 /// `max_seq_len` is `0`), so a caller that knows its worst-case
346 /// sequence length ahead of time (as `ferrox-server` does: prompt
347 /// length + `max_tokens`) never needs to acquire another block
348 /// mid-decode. This matters beyond performance: `push` growing past
349 /// its currently held capacity can fail if the pool is exhausted by
350 /// *other* requests by then, and callers like
351 /// `ferrox_models::Decoder::forward_token` treat `push` as
352 /// infallible for non-pooled caches -- a pooled cache that
353 /// under-reserves at construction and then fails to grow later
354 /// would violate that assumption and panic mid-decode. Sizing to
355 /// `max_seq_len` up front turns that into an admission-control
356 /// decision made once, honestly, before any generation work starts,
357 /// exactly mirroring `with_capacity`'s worst-case pre-allocation --
358 /// just drawn from a shared pool instead of a private allocation.
359 /// Returns `Err(KvPoolExhausted)` without mutating anything if the
360 /// pool doesn't have that many blocks free.
361 pub fn with_pool(
362 n_kv_heads: usize,
363 head_dim: usize,
364 pool: Arc<Mutex<KvBlockPool>>,
365 max_seq_len: usize,
366 ) -> Result<Self, KvPoolExhausted> {
367 Self::with_pool_split(n_kv_heads, head_dim, head_dim, pool, max_seq_len)
368 }
369
370 /// [`Self::with_pool`] with a V head width of its own.
371 pub fn with_pool_split(
372 n_kv_heads: usize,
373 head_dim: usize,
374 v_head_dim: usize,
375 pool: Arc<Mutex<KvBlockPool>>,
376 max_seq_len: usize,
377 ) -> Result<Self, KvPoolExhausted> {
378 let block_size = pool.lock().unwrap().block_size();
379 let blocks_needed = max_seq_len.div_ceil(block_size).max(1);
380 if !pool.lock().unwrap().try_acquire(blocks_needed) {
381 return Err(KvPoolExhausted);
382 }
383 Ok(KvCache {
384 n_kv_heads,
385 head_dim,
386 v_head_dim,
387 k: Vec::with_capacity(blocks_needed * block_size * n_kv_heads * head_dim),
388 v: Vec::with_capacity(blocks_needed * block_size * n_kv_heads * v_head_dim),
389 positions: 0,
390 planned_capacity: None,
391 pool_state: Some(PooledState {
392 pool,
393 block_size,
394 blocks_held: blocks_needed,
395 }),
396 window: None,
397 recurrent: None,
398 })
399 }
400
401 /// Appends one position's key/value vectors (each
402 /// `n_kv_heads * head_dim` long) to the cache. For pool-backed
403 /// caches, this may need to acquire another block first; if the
404 /// shared pool has none free, no data is appended and
405 /// `Err(KvPoolExhausted)` is returned. Caches built with `new` or
406 /// `with_capacity` always return `Ok`.
407 pub fn push(&mut self, k_step: &[f32], v_step: &[f32]) -> Result<(), KvPoolExhausted> {
408 assert_eq!(k_step.len(), self.k_width());
409 assert_eq!(v_step.len(), self.v_width());
410
411 let (k_width, v_width) = (self.k_width(), self.v_width());
412 if let Some(state) = &mut self.pool_state {
413 let capacity_positions = self.k.capacity() / k_width;
414 // ROWS, not positions: this asks whether the buffer is
415 // full, and an evicting cache's buffer is shorter than its
416 // position count. Equal for a cache that never evicts.
417 let rows = self.k.len() / k_width;
418 if rows == capacity_positions {
419 if !state.pool.lock().unwrap().try_acquire(1) {
420 return Err(KvPoolExhausted);
421 }
422 state.blocks_held += 1;
423 self.k.reserve_exact(state.block_size * k_width);
424 self.v.reserve_exact(state.block_size * v_width);
425 }
426 }
427
428 self.k.extend_from_slice(k_step);
429 self.v.extend_from_slice(v_step);
430 self.positions += 1;
431 Ok(())
432 }
433
434 /// Advance length by `n` positions without storing real K/V values
435 /// (zero-fill). Used when Metal owns the KV plane and the host cache
436 /// only needs matching `seq_len` for sync checks.
437 pub fn advance_len(&mut self, n: usize) -> Result<(), KvPoolExhausted> {
438 if n == 0 {
439 return Ok(());
440 }
441 let k_zeros = vec![0f32; self.k_width()];
442 let v_zeros = vec![0f32; self.v_width()];
443 for _ in 0..n {
444 self.push(&k_zeros, &v_zeros)?;
445 }
446 Ok(())
447 }
448
449 /// Returns this cache's blocks to its shared pool immediately
450 /// (rather than waiting for `Drop`) and detaches it from pool
451 /// accounting; a no-op for caches that aren't pool-backed, and
452 /// idempotent if called more than once.
453 pub fn release_to_pool(&mut self) {
454 if let Some(state) = self.pool_state.take() {
455 if let Ok(mut pool) = state.pool.lock() {
456 pool.release(state.blocks_held);
457 }
458 }
459 }
460
461 pub fn clear(&mut self) {
462 self.k.clear();
463 self.v.clear();
464 self.positions = 0;
465 self.recurrent = None;
466 }
467
468 /// Whether [`Self::truncate`] to `new_seq_len` is possible.
469 ///
470 /// Always, for an attention layer's cache: its rows are a history.
471 /// For a cache holding a recurrent state only to zero (the state
472 /// resets) or to where it already is: the state is a reduction
473 /// over the whole prefix and has no "one token ago"
474 /// (`crate::recurrent_state`). Every caller that rolls a cache back
475 /// to a middle position asks this first, or is fenced off the model.
476 pub fn can_truncate_to(&self, new_seq_len: usize) -> bool {
477 self.recurrent.is_none() || new_seq_len == 0 || new_seq_len == self.positions
478 }
479
480 /// Rolls the cache back to exactly `new_seq_len` positions,
481 /// discarding everything after. Used to reject speculatively
482 /// decoded draft tokens that turned out wrong: their K/V were
483 /// already pushed during batched verification, and rejection means
484 /// removing them so the next real decode step continues from the
485 /// last *accepted* position, not the last *attempted* one.
486 /// Rolls the cache back to exactly `new_seq_len` POSITIONS.
487 ///
488 /// A windowed cache can only roll back into rows it still holds. A
489 /// target further back than [`Self::rows`] names a position this
490 /// cache dropped behind its window, and there is no honest thing to
491 /// return for it -- so it stops, rather than silently rolling back
492 /// to the oldest row it happens to have and answering the next
493 /// token out of a history with a hole in it.
494 ///
495 /// Unreachable for a cache that has not been armed by
496 /// [`Self::arm_window`], which is every cache unless
497 /// `FERROX_KV_WINDOW` is on: `rows == positions` there, so the
498 /// second precondition is implied by the first.
499 pub fn truncate(&mut self, new_seq_len: usize) {
500 assert!(
501 new_seq_len <= self.positions,
502 "truncate target {new_seq_len} must not exceed current seq_len {}",
503 self.positions
504 );
505 let rows = self.rows();
506 let dropped = self.positions - new_seq_len;
507 assert!(
508 dropped <= rows,
509 "truncate target {new_seq_len} is {dropped} positions back but only {rows} rows \
510 are resident: this cache evicted behind a {:?} window (#61). Turn off \
511 FERROX_KV_WINDOW for a workload that rolls the KV cache back this far.",
512 self.window.map(|w| w.window())
513 );
514 assert!(
515 self.can_truncate_to(new_seq_len),
516 "truncate target {new_seq_len} on a cache holding a recurrent state at {} positions: \
517 a Mamba state is a reduction over the whole prefix and cannot be rolled back to a \
518 middle position (`crate::recurrent_state`); the caller should have asked \
519 `can_truncate_to` or been fenced off a recurrent model",
520 self.positions
521 );
522 if new_seq_len == 0 {
523 self.recurrent = None;
524 }
525 let keep_rows = rows - dropped;
526 self.k.truncate(keep_rows * self.k_width());
527 self.v.truncate(keep_rows * self.v_width());
528 self.positions = new_seq_len;
529 }
530
531 /// Tells this cache it may drop rows that have fallen behind
532 /// `window` (#61 step 2).
533 ///
534 /// Arming alone drops nothing: [`Self::evict_behind_window`] is what
535 /// drops, and the holder calls it at a point where it knows nothing
536 /// is mid-read. That split is deliberate. `push` is the obvious
537 /// place to evict and it is the wrong one: `Decoder::forward_batch`
538 /// writes a whole prefill batch into the cache and only then reads
539 /// it back, against a row offset it captured BEFORE the writes.
540 /// Evicting inside `push` would move every row out from under that
541 /// offset, and the prompt would be attended over shifted keys. So
542 /// eviction is something the holder asks for, and asking for it too
543 /// rarely only costs memory -- over-retention is always correct,
544 /// under-retention is wrong logits.
545 ///
546 /// Idempotent; a second call with a different window replaces the
547 /// first, and rows already dropped stay dropped.
548 pub fn arm_window(&mut self, window: KvWindow) {
549 self.window = Some(window);
550 }
551
552 /// The window this cache evicts behind, or `None` if it keeps
553 /// everything.
554 pub fn window(&self) -> Option<KvWindow> {
555 self.window
556 }
557
558 /// Drops rows that have fallen behind the armed window, returning
559 /// how many rows went. Zero, always, for an unarmed cache.
560 ///
561 /// The rows dropped are the OLDEST ones, so what remains is still a
562 /// contiguous suffix of the sequence: row `i` of `rows` holds
563 /// absolute position `positions - rows + i`. Every windowed
564 /// attention kernel reads the last `window` rows and nothing else,
565 /// and [`KvWindow::rows_after`] guarantees at least that many
566 /// survive, so the set of rows a kernel reads is byte-for-byte the
567 /// set it would have read with no eviction at all.
568 pub fn evict_behind_window(&mut self) -> usize {
569 let Some(window) = self.window else {
570 return 0;
571 };
572 let (k_width, v_width) = (self.k_width(), self.v_width());
573 if k_width == 0 {
574 return 0;
575 }
576 let rows = self.k.len() / k_width;
577 let keep = window.rows_after(self.positions).min(rows);
578 let drop_rows = rows - keep;
579 if drop_rows == 0 {
580 return 0;
581 }
582 // One `drain` per eviction, not one per token: the whole reason
583 // `KvWindow` carries slack. This moves `keep` rows down, and it
584 // happens once every `slack + 1` positions.
585 self.k.drain(..drop_rows * k_width);
586 self.v.drain(..drop_rows * v_width);
587 // `drain` frees no memory, and the saving this exists for is
588 // memory. A cache that just absorbed a 32k-token prefill holds
589 // 32k rows of capacity behind `window + slack` rows of data
590 // until something hands it back. Only when the excess is large
591 // enough to be worth a realloc-and-copy: shrinking on every
592 // eviction would trade the block drain for a full copy.
593 //
594 // **Never for a pool-backed cache**, and the reason is a bug
595 // rather than a preference. `push` asks whether the buffer is
596 // full by comparing rows against `k.capacity()`, and takes
597 // another block from the shared pool when they meet. Shrinking
598 // the buffer to a window's worth would make that condition true
599 // every `slack + 1` tokens forever, so a windowed pooled cache
600 // would draw a fresh block from the pool on a cadence, hold
601 // every one of them until it drops, and exhaust the pool
602 // mid-answer -- where `push` is documented infallible and the
603 // caller panics. The pool has already promised this cache its
604 // blocks; handing the capacity back without handing the blocks
605 // back saves nothing and costs that.
606 if self.pool_state.is_none() {
607 let want_k = window.max_rows() * k_width;
608 if self.k.capacity() > want_k.saturating_mul(2) {
609 self.k.shrink_to(want_k);
610 self.v.shrink_to(window.max_rows() * v_width);
611 }
612 }
613 drop_rows
614 }
615
616 /// Bytes currently resident for this cache's K and V buffers
617 /// combined (actual allocated capacity, not just used length) --
618 /// the number that matters for "does this fit in the context
619 /// budget,"
620 pub fn allocated_bytes(&self) -> usize {
621 (self.k.capacity() + self.v.capacity()) * std::mem::size_of::<f32>()
622 }
623
624 /// True if this cache was pre-allocated via `with_capacity` and
625 /// has not yet grown past that planned capacity (i.e. `push` has
626 /// never had to reallocate). Useful for tests/diagnostics
627 /// confirming the pre-allocation path actually avoided reallocs.
628 pub fn is_within_planned_capacity(&self) -> bool {
629 match self.planned_capacity {
630 Some(cap) => {
631 // POSITIONS against the plan (the plan was made in
632 // positions), ROWS against the buffer (the buffer holds
633 // rows). Equal unless this cache evicts.
634 self.positions <= cap && self.k.capacity() >= self.rows() * self.k_width()
635 }
636 None => false,
637 }
638 }
639}
640
641/// The other half of PagedAttention that `KvBlockPool`/`KvCache::with_pool`
642/// deliberately don't implement (see this module's doc comment): real,
643/// *shared* physical block storage that many sequences' block tables can
644/// address into, instead of each `KvCache` still owning its own private,
645/// contiguous `Vec`. `KvBlockPool` only ever bounds a *count* of blocks
646/// each cache may grow to; `PagedKvStore` is the actual backing memory,
647/// and a sequence's `PagedKvCache` holds a block table (an ordered list
648/// of block IDs into this shared store) instead of owning K/V data
649/// directly. This is what makes non-contiguous-block reads during
650/// attention (`causal_gqa_attention_paged`, in `attention.rs`) possible
651/// at all -- `causal_gqa_attention`'s existing contiguous-slice read
652/// pattern has no way to express "position 37 lives in block 12, cached
653/// out of order relative to block 5."
654pub struct PagedKvStore {
655 block_size: usize,
656 n_kv_heads: usize,
657 /// K head width; see [`KvCache::head_dim`].
658 head_dim: usize,
659 /// V head width; see [`KvCache::v_head_dim`].
660 v_head_dim: usize,
661 k: Vec<f32>, // [total_blocks * block_size, n_kv_heads, head_dim], flattened
662 v: Vec<f32>, // [total_blocks * block_size, n_kv_heads, v_head_dim], flattened
663 free_block_ids: Vec<usize>,
664}
665
666impl PagedKvStore {
667 /// One width for K and V; [`Self::new_split`] is the general form.
668 pub fn new(block_size: usize, total_blocks: usize, n_kv_heads: usize, head_dim: usize) -> Self {
669 Self::new_split(block_size, total_blocks, n_kv_heads, head_dim, head_dim)
670 }
671
672 pub fn new_split(
673 block_size: usize,
674 total_blocks: usize,
675 n_kv_heads: usize,
676 head_dim: usize,
677 v_head_dim: usize,
678 ) -> Self {
679 assert!(block_size > 0, "block_size must be positive");
680 PagedKvStore {
681 block_size,
682 n_kv_heads,
683 head_dim,
684 v_head_dim,
685 k: vec![0.0; total_blocks * block_size * n_kv_heads * head_dim],
686 v: vec![0.0; total_blocks * block_size * n_kv_heads * v_head_dim],
687 // Pushed in descending order so `pop()` hands out ascending
688 // block IDs -- not load-bearing for correctness (any free ID
689 // works), just makes manual debugging/inspection saner.
690 free_block_ids: (0..total_blocks).rev().collect(),
691 }
692 }
693
694 pub fn block_size(&self) -> usize {
695 self.block_size
696 }
697
698 pub fn free_block_count(&self) -> usize {
699 self.free_block_ids.len()
700 }
701
702 pub fn n_kv_heads(&self) -> usize {
703 self.n_kv_heads
704 }
705
706 pub fn head_dim(&self) -> usize {
707 self.head_dim
708 }
709
710 pub fn v_head_dim(&self) -> usize {
711 self.v_head_dim
712 }
713
714 /// One position's K row: `n_kv_heads * head_dim` elements.
715 pub fn k_width(&self) -> usize {
716 self.n_kv_heads * self.head_dim
717 }
718
719 /// One position's V row: `n_kv_heads * v_head_dim` elements.
720 pub fn v_width(&self) -> usize {
721 self.n_kv_heads * self.v_head_dim
722 }
723
724 fn acquire_block(&mut self) -> Option<usize> {
725 self.free_block_ids.pop()
726 }
727
728 fn release_block(&mut self, id: usize) {
729 self.free_block_ids.push(id);
730 }
731
732 fn k_elems_per_block(&self) -> usize {
733 self.block_size * self.k_width()
734 }
735
736 fn v_elems_per_block(&self) -> usize {
737 self.block_size * self.v_width()
738 }
739
740 /// One position's K (or V) row within block `id` at `offset` (0-based
741 /// within the block) -- `[n_kv_heads * head_dim]` long. Used by
742 /// `causal_gqa_attention_paged` to read attention inputs directly out
743 /// of shared physical storage via a block table, and by
744 /// `PagedKvCache::push` to write a new position into it.
745 pub fn k_row(&self, id: usize, offset: usize) -> &[f32] {
746 let width = self.k_width();
747 let start = id * self.k_elems_per_block() + offset * width;
748 &self.k[start..start + width]
749 }
750
751 pub fn v_row(&self, id: usize, offset: usize) -> &[f32] {
752 let width = self.v_width();
753 let start = id * self.v_elems_per_block() + offset * width;
754 &self.v[start..start + width]
755 }
756
757 fn k_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
758 let width = self.k_width();
759 let start = id * self.k_elems_per_block() + offset * width;
760 &mut self.k[start..start + width]
761 }
762
763 fn v_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
764 let width = self.v_width();
765 let start = id * self.v_elems_per_block() + offset * width;
766 &mut self.v[start..start + width]
767 }
768}
769
770/// Returned when a `PagedKvCache` needs another block but its
771/// `PagedKvStore` has none free -- the paged-storage analog of
772/// `KvPoolExhausted`.
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774pub struct PagedStoreExhausted;
775
776impl std::fmt::Display for PagedStoreExhausted {
777 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778 write!(f, "paged KV store exhausted: no free blocks remain")
779 }
780}
781
782impl std::error::Error for PagedStoreExhausted {}
783
784/// One sequence's view into a shared `PagedKvStore`: a block table
785/// (which physical blocks this sequence's positions live in, in order)
786/// plus how many positions have been written so far. Unlike `KvCache`,
787/// this holds no K/V data itself -- every read and write goes through
788/// the shared store.
789#[derive(Debug, Clone, Default)]
790pub struct PagedKvCache {
791 block_table: Vec<usize>,
792 seq_len: usize,
793 /// A recurrent layer's state, per sequence and NOT paged: the store
794 /// holds rows and a Mamba layer has none. Same rules as
795 /// `KvCache::recurrent`.
796 pub recurrent: Option<crate::recurrent_state::RecurrentState>,
797}
798
799impl PagedKvCache {
800 pub fn new() -> Self {
801 PagedKvCache {
802 block_table: Vec::new(),
803 seq_len: 0,
804 recurrent: None,
805 }
806 }
807
808 /// See `KvCache::can_truncate_to`.
809 pub fn can_truncate_to(&self, new_seq_len: usize) -> bool {
810 self.recurrent.is_none() || new_seq_len == 0 || new_seq_len == self.seq_len
811 }
812
813 pub fn seq_len(&self) -> usize {
814 self.seq_len
815 }
816
817 pub fn block_table(&self) -> &[usize] {
818 &self.block_table
819 }
820
821 /// Appends one position's key/value vectors, acquiring a new block
822 /// from `store` first if the current tail block is full (or none
823 /// held yet). Mirrors `KvCache::push`'s signature/semantics exactly,
824 /// just against shared storage instead of a private buffer.
825 pub fn push(
826 &mut self,
827 store: &mut PagedKvStore,
828 k_step: &[f32],
829 v_step: &[f32],
830 ) -> Result<(), PagedStoreExhausted> {
831 let block_size = store.block_size();
832 let offset_in_block = self.seq_len % block_size;
833 // Index by position rather than taking the tail block: a
834 // sequence that pre-reserved (see `reserve`) already holds the
835 // block this position belongs in, and appending another would
836 // both leak a block and write the row in the wrong place.
837 let block_index = self.seq_len / block_size;
838 if block_index >= self.block_table.len() {
839 let id = store.acquire_block().ok_or(PagedStoreExhausted)?;
840 self.block_table.push(id);
841 }
842 let block_id = self.block_table[block_index];
843 store
844 .k_row_mut(block_id, offset_in_block)
845 .copy_from_slice(k_step);
846 store
847 .v_row_mut(block_id, offset_in_block)
848 .copy_from_slice(v_step);
849 self.seq_len += 1;
850 Ok(())
851 }
852
853 /// Appends a block the caller already owns, without taking one from
854 /// the store.
855 ///
856 /// This is how a sliding window recycles. A block whose positions
857 /// have fallen behind the window is never read again -- the paged
858 /// attention kernel indexes `block_table[t / block_size]` only for
859 /// `t >= seq_len - window` -- so its storage can back a *later*
860 /// position instead of being handed back and re-acquired. The table
861 /// keeps its absolute-position indexing and simply names the same
862 /// physical block at two indices: the stale one, which nothing
863 /// reads, and the live one.
864 ///
865 /// That aliasing is the reason this is a separate method rather than
866 /// a flag on [`Self::reserve`]. A caller that recycles owns the
867 /// obligation to release each distinct block exactly once, and to
868 /// have established that the donor index really is out of window --
869 /// neither of which this type can check for itself.
870 pub fn append_block(&mut self, block_id: usize) {
871 self.block_table.push(block_id);
872 }
873
874 /// Releases every block this sequence holds back to `store`. Must be
875 /// called explicitly (there's no `Drop` here, since dropping needs a
876 /// `&mut PagedKvStore` this type doesn't own a reference to) --
877 /// mirrors `KvCache::release_to_pool`, just not automatic.
878 ///
879 /// Each *distinct* block once: a table that has recycled through
880 /// [`Self::append_block`] names one block at more than one index, and
881 /// releasing per index would put the same id on the free list twice,
882 /// after which two sequences are handed the same memory.
883 pub fn release(&mut self, store: &mut PagedKvStore) {
884 let mut seen: Vec<usize> = Vec::new();
885 for id in self.block_table.drain(..) {
886 if !seen.contains(&id) {
887 seen.push(id);
888 store.release_block(id);
889 }
890 }
891 self.seq_len = 0;
892 self.recurrent = None;
893 }
894
895 /// How many *additional* blocks appending `n_new` positions would
896 /// take from `store`, given what this sequence already holds.
897 ///
898 /// Counted against held CAPACITY rather than against `seq_len`, so
899 /// it is right in both cases. The tail block is usually part-full,
900 /// so the answer is never simply `n_new / block_size`: positions
901 /// that land in a block already held cost nothing. And a sequence
902 /// that pre-reserved (see [`Self::reserve`]) holds blocks beyond
903 /// its length, which a `seq_len`-only sum would ask for twice.
904 ///
905 /// Callers that must not fail part-way through a write check this
906 /// against [`PagedKvStore::free_block_count`] before touching
907 /// anything.
908 pub fn blocks_needed_for(&self, store: &PagedKvStore, n_new: usize) -> usize {
909 let held_capacity = self.block_table.len() * store.block_size();
910 let unused = held_capacity.saturating_sub(self.seq_len);
911 n_new.saturating_sub(unused).div_ceil(store.block_size())
912 }
913
914 /// Takes the blocks `n_new` more positions will need, without
915 /// advancing `seq_len`.
916 ///
917 /// This is what makes a multi-layer append all-or-nothing. The
918 /// check and the taking happen together, so every later
919 /// [`Self::push`] writes into a block this sequence already owns
920 /// and cannot fail. Reserving and then not filling is harmless: the
921 /// blocks are this sequence's until it releases, and `seq_len`
922 /// still says how far it really got.
923 pub fn reserve(
924 &mut self,
925 store: &mut PagedKvStore,
926 n_new: usize,
927 ) -> Result<(), PagedStoreExhausted> {
928 let need = self.blocks_needed_for(store, n_new);
929 if need > store.free_block_count() {
930 return Err(PagedStoreExhausted);
931 }
932 for _ in 0..need {
933 let id = store
934 .acquire_block()
935 .expect("checked against free_block_count immediately above");
936 self.block_table.push(id);
937 }
938 Ok(())
939 }
940
941 /// Installs a block table the caller allocated, with `seq_len`
942 /// positions already computed in it.
943 ///
944 /// This is how a sequence starts life on top of a cached prefix:
945 /// the blocks are somebody else's, already full, and this sequence
946 /// appends past them.
947 ///
948 /// The `seq_len` installed here is therefore also the POSITION the
949 /// caller's next forward pass must run at, and the caller has no
950 /// second source for that number: [`Self::push`] writes at `seq_len`
951 /// and ignores whatever position its caller believes it is at. A
952 /// prefill that started from zero over an adopted prefix put the
953 /// prompt in the rows *after* the prefix while carrying positions
954 /// `0..n`, which is a wrong answer served with a 200.
955 ///
956 /// `seq_len` MUST be a whole number of blocks,
957 /// because the first append writes at `seq_len` and a shared block
958 /// must never be written -- another sequence is attending over it.
959 /// A ragged length would put that write inside the last shared
960 /// block, corrupting a prefix every other holder is reading.
961 pub fn adopt_blocks(&mut self, block_table: Vec<usize>, seq_len: usize, block_size: usize) {
962 assert_eq!(
963 seq_len % block_size,
964 0,
965 "an adopted prefix must end on a block boundary, or the first \
966 append writes into a block another sequence is reading"
967 );
968 assert!(
969 seq_len / block_size <= block_table.len(),
970 "block table too short for the adopted length"
971 );
972 self.block_table = block_table;
973 self.seq_len = seq_len;
974 }
975
976 /// Copies this sequence's KV out of the shared store into a plain
977 /// contiguous [`KvCache`].
978 ///
979 /// This is what lets the batched prefill path run *unchanged* over
980 /// paged storage. Its fast arm hands `cache.k` / `cache.v` to a
981 /// blocked kernel that reads them as flat slices, and a block table
982 /// cannot be expressed that way. Rather than maintain a second
983 /// prefill kernel that reads through the table -- a copy that could
984 /// drift from the one every other model path uses -- the pages are
985 /// materialised once per layer, the existing kernel runs, and the
986 /// new rows go back with [`Self::append_contiguous`].
987 ///
988 /// The cost is one `seq_len * n_kv_heads * head_dim` copy per layer
989 /// per prefill call, against matmuls that dominate prefill. Decode
990 /// still reads through the block table and copies nothing, which is
991 /// where page sharing actually pays.
992 pub fn to_contiguous(&self, store: &PagedKvStore) -> KvCache {
993 let mut cache = KvCache::with_capacity_split(
994 store.n_kv_heads,
995 store.head_dim,
996 store.v_head_dim,
997 self.seq_len,
998 );
999 cache.k.reserve_exact(self.seq_len * store.k_width());
1000 cache.v.reserve_exact(self.seq_len * store.v_width());
1001 for pos in 0..self.seq_len {
1002 let block_id = self.block_table[pos / store.block_size];
1003 let offset = pos % store.block_size;
1004 cache.k.extend_from_slice(store.k_row(block_id, offset));
1005 cache.v.extend_from_slice(store.v_row(block_id, offset));
1006 }
1007 // The paged store does not evict either, so its positions and
1008 // its rows agree and this one assignment is both.
1009 cache.set_positions(self.seq_len);
1010 cache.recurrent = self.recurrent.clone();
1011 cache
1012 }
1013
1014 /// Appends `count` positions' worth of contiguous K/V rows, the
1015 /// inverse of [`Self::to_contiguous`].
1016 ///
1017 /// Blocks are reserved for the whole append *before* the first row
1018 /// is written, so a store that cannot hold the request refuses it
1019 /// having changed nothing. Writing rows until the store runs dry
1020 /// would leave the sequence with a `seq_len` that disagrees with
1021 /// the model's own idea of how far it has got, which is not a
1022 /// recoverable state.
1023 pub fn append_contiguous(
1024 &mut self,
1025 store: &mut PagedKvStore,
1026 k: &[f32],
1027 v: &[f32],
1028 count: usize,
1029 ) -> Result<(), PagedStoreExhausted> {
1030 let (k_width, v_width) = (store.k_width(), store.v_width());
1031 assert_eq!(k.len(), count * k_width, "k row count");
1032 assert_eq!(v.len(), count * v_width, "v row count");
1033 if self.blocks_needed_for(store, count) > store.free_block_count() {
1034 return Err(PagedStoreExhausted);
1035 }
1036 for i in 0..count {
1037 self.push(
1038 store,
1039 &k[i * k_width..(i + 1) * k_width],
1040 &v[i * v_width..(i + 1) * v_width],
1041 )
1042 .expect("blocks reserved above, so no push here can exhaust the store");
1043 }
1044 Ok(())
1045 }
1046}
1047
1048/// Per-layer [`PagedKvStore`]s that many concurrent requests share.
1049///
1050/// # Why a lock per layer, and why two phases
1051///
1052/// `ferrox-server` runs generation on `spawn_blocking` with, in its own
1053/// words, "no I/O and no shared lock". `KvBlockPool` survives that
1054/// because it only bounds a *count*: each `KvCache` owns a private
1055/// `Vec`, and the pool mutex is taken briefly at acquire and release,
1056/// never during a forward. A `PagedKvStore` is the opposite -- it IS
1057/// the backing memory -- so sharing one across concurrent requests
1058/// needs an answer to "who may touch these bytes when".
1059///
1060/// The answer the API already implies: attention takes
1061/// `&PagedKvStore` and only `push` takes `&mut`. So the accesses split
1062/// cleanly into many concurrent readers and one short exclusive write
1063/// per position, which is exactly an `RwLock` -- and one per LAYER
1064/// rather than one for the whole model, so two requests contend only
1065/// when both are writing the same layer at the same instant.
1066///
1067/// A caller must therefore take the write guard for the push alone and
1068/// drop it before attending under a read guard. Holding the write
1069/// guard across attention would serialise the expensive half and give
1070/// back a global lock with extra steps. Nothing breaks in the gap: a
1071/// sequence's block table and length are its own, and another
1072/// request's push in between only touches blocks it exclusively holds.
1073///
1074/// # Deadlock
1075///
1076/// [`Self::write_all`] is the one place several layers are held at
1077/// once, and it takes them in ascending layer order. Every caller
1078/// getting the same order is what makes that safe; there is no other
1079/// multi-layer acquisition in the codebase, and a new one must follow
1080/// the same rule.
1081///
1082/// # Poisoning
1083///
1084/// A panic while holding a store leaves the KV mid-write, which is not
1085/// recoverable state, but it is also not *unsound* -- the bytes are
1086/// plain `f32`. Poison is stepped over with `into_inner`, matching how
1087/// `ferrox-server` already treats its pool mutex: a poisoned lock
1088/// should not turn one request's panic into a permanently dead server.
1089pub struct SharedPagedKv {
1090 layers: Vec<RwLock<PagedKvStore>>,
1091 /// Guarded separately from the layers, and always taken BEFORE
1092 /// them, never while a layer guard is held. That one-way order is
1093 /// what keeps group allocation and the per-layer push paths from
1094 /// deadlocking against each other.
1095 groups: Mutex<GroupTable>,
1096}
1097
1098impl SharedPagedKv {
1099 /// One store per layer, each with `blocks_per_layer` blocks.
1100 pub fn new(
1101 n_layers: usize,
1102 block_size: usize,
1103 blocks_per_layer: usize,
1104 n_kv_heads: usize,
1105 head_dim: usize,
1106 ) -> Self {
1107 SharedPagedKv {
1108 layers: (0..n_layers)
1109 .map(|_| {
1110 RwLock::new(PagedKvStore::new(
1111 block_size,
1112 blocks_per_layer,
1113 n_kv_heads,
1114 head_dim,
1115 ))
1116 })
1117 .collect(),
1118 groups: Mutex::new(GroupTable::default()),
1119 }
1120 }
1121
1122 /// Wraps stores the caller built, for tests and for callers that
1123 /// size layers differently.
1124 pub fn from_stores(stores: Vec<PagedKvStore>) -> Self {
1125 SharedPagedKv {
1126 layers: stores.into_iter().map(RwLock::new).collect(),
1127 groups: Mutex::new(GroupTable::default()),
1128 }
1129 }
1130
1131 pub fn layer_count(&self) -> usize {
1132 self.layers.len()
1133 }
1134
1135 /// Shared access to one layer, for attention.
1136 pub fn read(&self, layer: usize) -> RwLockReadGuard<'_, PagedKvStore> {
1137 self.layers[layer]
1138 .read()
1139 .unwrap_or_else(|poisoned| poisoned.into_inner())
1140 }
1141
1142 /// Exclusive access to one layer, for a push. Hold it for the push
1143 /// and nothing else -- see the type docs.
1144 pub fn write(&self, layer: usize) -> RwLockWriteGuard<'_, PagedKvStore> {
1145 self.layers[layer]
1146 .write()
1147 .unwrap_or_else(|poisoned| poisoned.into_inner())
1148 }
1149
1150 /// Every layer at once, in ascending order, so a multi-layer append
1151 /// is atomic against other requests.
1152 ///
1153 /// This is what makes "all layers advance or none do" hold under
1154 /// concurrency rather than only single-threaded: checking free
1155 /// space and then appending are separate steps, and without the
1156 /// guards spanning both, another request can take the blocks in
1157 /// between and leave this one half-written.
1158 ///
1159 /// Ascending order is the deadlock rule; see the type docs.
1160 pub fn write_all(&self) -> Vec<RwLockWriteGuard<'_, PagedKvStore>> {
1161 self.layers
1162 .iter()
1163 .map(|l| l.write().unwrap_or_else(|poisoned| poisoned.into_inner()))
1164 .collect()
1165 }
1166
1167 /// Free blocks in one layer, for admission control. A snapshot: by
1168 /// the time a caller acts on it another request may have taken
1169 /// them, which is why the append itself re-checks under the guard.
1170 pub fn free_blocks(&self, layer: usize) -> usize {
1171 self.read(layer).free_block_count()
1172 }
1173
1174 /// Takes one block from EVERY layer as a single group, refcount 1.
1175 ///
1176 /// All layers or none: a group that existed in some layers and not
1177 /// others could not answer "which block holds position p in layer
1178 /// l", which is the only question it exists to answer.
1179 pub fn acquire_group(&self) -> Option<PageGroup> {
1180 let mut guards = self.write_all();
1181 if guards.iter().any(|s| s.free_block_count() == 0) {
1182 return None;
1183 }
1184 let blocks: Vec<usize> = guards
1185 .iter_mut()
1186 .map(|s| {
1187 s.acquire_block()
1188 .expect("checked every layer under these same guards")
1189 })
1190 .collect();
1191 let mut groups = self
1192 .groups
1193 .lock()
1194 .unwrap_or_else(|poisoned| poisoned.into_inner());
1195 Some(PageGroup(groups.insert(blocks)))
1196 }
1197
1198 /// One more holder of `group`.
1199 ///
1200 /// Called when a second sequence adopts a cached prefix. Without
1201 /// it, the first sequence to finish frees pages the second is
1202 /// still attending over -- a use-after-free that shows up as
1203 /// another conversation's tokens rather than as a crash.
1204 pub fn retain_group(&self, group: PageGroup) {
1205 let mut groups = self
1206 .groups
1207 .lock()
1208 .unwrap_or_else(|poisoned| poisoned.into_inner());
1209 groups.retain(group.0);
1210 }
1211
1212 /// One fewer holder. At zero the blocks go back to their layers.
1213 ///
1214 /// Returns whether this was the last holder, so a caller can assert
1215 /// on it rather than guess.
1216 pub fn release_group(&self, group: PageGroup) -> bool {
1217 let blocks = {
1218 let mut groups = self
1219 .groups
1220 .lock()
1221 .unwrap_or_else(|poisoned| poisoned.into_inner());
1222 match groups.release(group.0) {
1223 Some(blocks) => blocks,
1224 None => return false,
1225 }
1226 };
1227 // The groups lock is dropped before the layer guards are taken,
1228 // so the lock order is always groups-then-layers and never the
1229 // reverse. See the type docs on deadlock.
1230 let mut guards = self.write_all();
1231 for (store, block) in guards.iter_mut().zip(blocks) {
1232 store.release_block(block);
1233 }
1234 true
1235 }
1236
1237 /// Which block in each layer this group owns, indexed by layer.
1238 pub fn group_blocks(&self, group: PageGroup) -> Vec<usize> {
1239 let groups = self
1240 .groups
1241 .lock()
1242 .unwrap_or_else(|poisoned| poisoned.into_inner());
1243 groups.blocks(group.0).to_vec()
1244 }
1245
1246 /// How many holders `group` has. Zero means it does not exist.
1247 pub fn group_refs(&self, group: PageGroup) -> u32 {
1248 let groups = self
1249 .groups
1250 .lock()
1251 .unwrap_or_else(|poisoned| poisoned.into_inner());
1252 groups.refs(group.0)
1253 }
1254
1255 /// Groups that could still be allocated, bounded by the layer with
1256 /// the fewest free blocks: a group needs one from each.
1257 pub fn free_groups(&self) -> usize {
1258 (0..self.layers.len())
1259 .map(|l| self.free_blocks(l))
1260 .min()
1261 .unwrap_or(0)
1262 }
1263}
1264
1265/// A handle to one block in every layer.
1266///
1267/// The unit of sharing between sequences, and the only thing small
1268/// enough to be what a radix prefix cache stores: that cache maps a
1269/// token prefix to ONE index per token, while a position's KV lives in
1270/// `n_layers` different blocks. A group is the name for all of them.
1271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1272pub struct PageGroup(pub u32);
1273
1274/// Group ids, their per-layer blocks, and how many holders each has.
1275#[derive(Debug, Default)]
1276struct GroupTable {
1277 /// Indexed by group id. `None` for an id currently on the free list.
1278 blocks: Vec<Option<Vec<usize>>>,
1279 refs: Vec<u32>,
1280 free_ids: Vec<u32>,
1281}
1282
1283impl GroupTable {
1284 fn insert(&mut self, blocks: Vec<usize>) -> u32 {
1285 if let Some(id) = self.free_ids.pop() {
1286 self.blocks[id as usize] = Some(blocks);
1287 self.refs[id as usize] = 1;
1288 return id;
1289 }
1290 self.blocks.push(Some(blocks));
1291 self.refs.push(1);
1292 (self.blocks.len() - 1) as u32
1293 }
1294
1295 fn retain(&mut self, id: u32) {
1296 let refs = &mut self.refs[id as usize];
1297 assert!(*refs > 0, "cannot retain group {id}, which has no holders");
1298 *refs += 1;
1299 }
1300
1301 /// Drops one holder, returning the blocks to free only when the
1302 /// last one goes.
1303 fn release(&mut self, id: u32) -> Option<Vec<usize>> {
1304 let refs = &mut self.refs[id as usize];
1305 assert!(*refs > 0, "double free of group {id}");
1306 *refs -= 1;
1307 if *refs > 0 {
1308 return None;
1309 }
1310 // The id is reusable now, but only after the blocks are out:
1311 // handing the id back while it still named blocks would let a
1312 // later `acquire_group` believe it owns them too.
1313 let blocks = self.blocks[id as usize]
1314 .take()
1315 .expect("a group with holders always has blocks");
1316 self.free_ids.push(id);
1317 Some(blocks)
1318 }
1319
1320 fn blocks(&self, id: u32) -> &[usize] {
1321 self.blocks[id as usize]
1322 .as_deref()
1323 .expect("group has no blocks; it was already released")
1324 }
1325
1326 fn refs(&self, id: u32) -> u32 {
1327 self.refs.get(id as usize).copied().unwrap_or(0)
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334
1335 /// `blocks_needed_for` is the reservation the whole no-partial-write
1336 /// guarantee rests on, and it is wrong in two opposite directions
1337 /// that fail very differently.
1338 ///
1339 /// UNDER-counting is the dangerous one: `append_contiguous` reserves
1340 /// on this answer and then pushes with an `expect`, so too small a
1341 /// number panics part-way through a layer -- exactly the corrupted
1342 /// state the reservation exists to prevent. Over-counting merely
1343 /// refuses a request that would have fitted.
1344 ///
1345 /// Both mistakes are one edit away. Flooring instead of ceiling
1346 /// under-counts whenever the append does not land on a block
1347 /// boundary; ignoring the part-full tail over-counts whenever a
1348 /// sequence is mid-block, which after the first token is almost
1349 /// always. Neither shows up when the numbers happen to divide
1350 /// evenly, so the cases here are chosen so that they do not.
1351 #[test]
1352 fn blocks_needed_for_accounts_for_the_part_full_tail_block() {
1353 let mut store = PagedKvStore::new(/* block_size = */ 4, 64, 1, 1);
1354 let mut cache = PagedKvCache::new();
1355 let row = [1.0f32];
1356 // Real pushes rather than poking `seq_len`: the count is
1357 // against blocks this sequence HOLDS, so a length with no
1358 // blocks behind it is a state that cannot occur and would only
1359 // let the test agree with an arithmetic nothing produces.
1360 let advance = |cache: &mut PagedKvCache, store: &mut PagedKvStore, n: usize| {
1361 for _ in 0..n {
1362 cache.push(store, &row, &row).unwrap();
1363 }
1364 };
1365
1366 // Empty: a whole-block boundary, and a remainder that a floor
1367 // would round away.
1368 assert_eq!(cache.blocks_needed_for(&store, 0), 0);
1369 assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1370 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1371 assert_eq!(cache.blocks_needed_for(&store, 5), 2, "5 into 4s needs 2");
1372
1373 // One position in: three slots free in the tail, so appending up
1374 // to three costs NOTHING. Ignoring the tail would say 1.
1375 advance(&mut cache, &mut store, 1);
1376 assert_eq!(cache.blocks_needed_for(&store, 3), 0, "fits in the tail");
1377 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1378 assert_eq!(cache.blocks_needed_for(&store, 8), 2);
1379
1380 // The awkward case: 1 free in the tail, 6 to append. 5 spill
1381 // over 4-wide blocks, so 2. A floor gives 1 and a tail-blind
1382 // ceil gives 2 for the wrong reason, so this pins the shape.
1383 advance(&mut cache, &mut store, 2); // seq_len = 3
1384 assert_eq!(cache.blocks_needed_for(&store, 6), 2);
1385 assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1386
1387 // Tail exactly full: no free slots, so this behaves like empty.
1388 advance(&mut cache, &mut store, 1); // seq_len = 4
1389 assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1390 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1391
1392 // A RESERVED block is capacity this sequence already holds, so
1393 // it must not be asked for twice. Counting from `seq_len` alone
1394 // would say 1 here and take a second block for positions the
1395 // reservation already covers.
1396 cache.reserve(&mut store, 4).unwrap();
1397 assert_eq!(
1398 cache.blocks_needed_for(&store, 4),
1399 0,
1400 "a reserved block is already held"
1401 );
1402 assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1403 }
1404
1405 /// A group takes one block from every layer, and gives them all
1406 /// back together.
1407 ///
1408 /// All-or-nothing is the point: a group holding blocks in some
1409 /// layers and not others cannot answer "which block holds position
1410 /// p in layer l", which is the only question it exists for.
1411 #[test]
1412 fn a_group_takes_one_block_from_every_layer_and_returns_them_together() {
1413 let kv = SharedPagedKv::new(3, 2, 4, 1, 1);
1414 assert_eq!(kv.free_groups(), 4);
1415
1416 let g = kv.acquire_group().expect("4 groups available");
1417 let blocks = kv.group_blocks(g);
1418 assert_eq!(blocks.len(), 3, "one block per layer");
1419 for l in 0..3 {
1420 assert_eq!(kv.free_blocks(l), 3, "layer {l} gave up exactly one");
1421 }
1422 assert_eq!(kv.free_groups(), 3);
1423
1424 assert!(kv.release_group(g), "sole holder, so this frees it");
1425 for l in 0..3 {
1426 assert_eq!(kv.free_blocks(l), 4, "layer {l} got its block back");
1427 }
1428 assert_eq!(kv.free_groups(), 4);
1429 }
1430
1431 /// A group survives until its LAST holder releases it.
1432 ///
1433 /// This is what makes prefix sharing safe. Two sequences off one
1434 /// system prompt hold the same pages; if the first to finish freed
1435 /// them, the second would keep attending over blocks the store had
1436 /// already handed to somebody else -- surfacing as another
1437 /// conversation's tokens, not as a crash.
1438 #[test]
1439 fn a_group_shared_by_two_holders_survives_the_first_release() {
1440 let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1441 let g = kv.acquire_group().unwrap();
1442 let blocks = kv.group_blocks(g);
1443 kv.retain_group(g);
1444 assert_eq!(kv.group_refs(g), 2);
1445
1446 assert!(
1447 !kv.release_group(g),
1448 "one holder remains, so nothing is freed"
1449 );
1450 assert_eq!(kv.group_refs(g), 1);
1451 assert_eq!(kv.free_blocks(0), 1, "the blocks are still held");
1452 assert_eq!(kv.group_blocks(g), blocks, "and still name the same blocks");
1453
1454 assert!(kv.release_group(g), "last holder frees it");
1455 assert_eq!(kv.group_refs(g), 0);
1456 assert_eq!(kv.free_blocks(0), 2);
1457 }
1458
1459 /// Exhaustion is per group, bounded by the tightest layer.
1460 ///
1461 /// A layer with one block left caps the whole pool at one more
1462 /// group however much room the others have, because a group needs
1463 /// one block from each.
1464 #[test]
1465 fn group_capacity_is_bounded_by_the_layer_with_the_fewest_blocks() {
1466 let kv = SharedPagedKv::from_stores(vec![
1467 PagedKvStore::new(2, 5, 1, 1),
1468 PagedKvStore::new(2, 1, 1, 1),
1469 ]);
1470 assert_eq!(kv.free_groups(), 1, "layer 1 has only one block");
1471
1472 let g = kv.acquire_group().expect("one group fits");
1473 assert_eq!(kv.free_groups(), 0);
1474 assert!(
1475 kv.acquire_group().is_none(),
1476 "layer 1 is empty, so no group can be formed"
1477 );
1478 // The refused attempt must not have taken layer 0's block.
1479 assert_eq!(kv.free_blocks(0), 4, "a refused group leaks nothing");
1480 kv.release_group(g);
1481 assert_eq!(kv.free_blocks(0), 5);
1482 }
1483
1484 /// A released id is reused, with a refcount that starts over.
1485 #[test]
1486 fn a_released_group_id_is_reused_with_a_fresh_refcount() {
1487 let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1488 let first = kv.acquire_group().unwrap();
1489 kv.retain_group(first);
1490 assert_eq!(kv.group_refs(first), 2);
1491 kv.release_group(first);
1492 kv.release_group(first);
1493 assert_eq!(kv.group_refs(first), 0, "gone, not merely decremented");
1494
1495 let second = kv.acquire_group().unwrap();
1496 assert_eq!(second, first, "the id is reused");
1497 assert_eq!(
1498 kv.group_refs(second),
1499 1,
1500 "a reused id must not inherit the old count"
1501 );
1502 assert_eq!(kv.group_blocks(second).len(), 1);
1503 assert_eq!(kv.free_blocks(0), 1);
1504 }
1505
1506 /// Reading a group after its last holder released it PANICS rather
1507 /// than answering with stale blocks.
1508 ///
1509 /// This is the observable half of clearing the entry on release,
1510 /// and the reason it is `take` rather than `clone`: a caller still
1511 /// holding a `PageGroup` after releasing it is exactly the bug
1512 /// refcounting exists to prevent, and blocks that now belong to
1513 /// somebody else are the worst possible answer -- the caller reads
1514 /// another sequence's KV and nothing says so.
1515 ///
1516 /// Written after sabotage showed the previous test here passed with
1517 /// `clone` in place of `take`: `insert` overwrites the entry on
1518 /// reuse, so a stale entry was never reachable through the path
1519 /// that test took. This one reaches it.
1520 #[test]
1521 #[should_panic(expected = "already released")]
1522 fn reading_a_released_group_panics_rather_than_returning_stale_blocks() {
1523 let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1524 let g = kv.acquire_group().unwrap();
1525 assert!(kv.release_group(g));
1526 let _ = kv.group_blocks(g);
1527 }
1528
1529 /// Releasing a group nobody holds is a bug, not a no-op.
1530 ///
1531 /// Silently ignoring it would let a double release return the same
1532 /// blocks to the store twice, after which two sequences are handed
1533 /// the same page and both write it.
1534 #[test]
1535 #[should_panic(expected = "double free of group")]
1536 fn releasing_a_group_twice_panics_rather_than_freeing_it_twice() {
1537 let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1538 let g = kv.acquire_group().unwrap();
1539 assert!(kv.release_group(g));
1540 kv.release_group(g);
1541 }
1542
1543 /// A recycled block backs a later position without the store ever
1544 /// being asked for another one, and the later position's writes are
1545 /// what a read at that position returns.
1546 ///
1547 /// This is the whole sliding-window mechanism in miniature. Blocks
1548 /// of two, four positions, and only two blocks in the store: without
1549 /// recycling, position 2 has nowhere to go.
1550 #[test]
1551 fn a_recycled_block_backs_a_later_position_without_touching_the_store() {
1552 let mut store = PagedKvStore::new(2, 2, 1, 2);
1553 let mut cache = PagedKvCache::new();
1554 cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1555 cache.push(&mut store, &[2.0, 2.0], &[2.0, 2.0]).unwrap();
1556 assert_eq!(store.free_block_count(), 1, "one block per position pair");
1557
1558 // Positions 0..2 have fallen behind a window of two. Their block
1559 // backs positions 2..4 instead, and the store is untouched.
1560 let recycled = cache.block_table()[0];
1561 cache.append_block(recycled);
1562 assert_eq!(
1563 store.free_block_count(),
1564 1,
1565 "recycling must not take a block from the store"
1566 );
1567 cache.push(&mut store, &[3.0, 3.0], &[3.0, 3.0]).unwrap();
1568 assert_eq!(cache.seq_len(), 3);
1569 assert_eq!(
1570 cache.block_table(),
1571 &[recycled, recycled],
1572 "the same block at the stale index and the live one"
1573 );
1574
1575 // Reading position 2 sees the new row. Position 0's row is gone,
1576 // which is exactly what "behind the window" means -- the kernel
1577 // never indexes it.
1578 let flat = cache.to_contiguous(&store);
1579 assert_eq!(&flat.k[4..6], &[3.0, 3.0], "position 2 reads its own row");
1580 assert_eq!(
1581 &flat.k[0..2],
1582 &[3.0, 3.0],
1583 "position 0 now reads the recycled row, and nothing may read it"
1584 );
1585 }
1586
1587 /// Releasing an aliased table hands each block back ONCE.
1588 ///
1589 /// Per index instead of per distinct block would put the recycled id
1590 /// on the free list twice, and the next two acquisitions would hand
1591 /// two sequences the same memory -- which does not fail, it
1592 /// interleaves two conversations' KV.
1593 #[test]
1594 fn releasing_a_recycled_table_gives_each_block_back_once() {
1595 // Exactly one block in the store, so "handed back twice" is
1596 // observable as a second acquisition succeeding.
1597 let mut store = PagedKvStore::new(2, 1, 1, 2);
1598 let mut cache = PagedKvCache::new();
1599 cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1600 let held = cache.block_table()[0];
1601 cache.append_block(held);
1602 cache.append_block(held);
1603
1604 let free_before = store.free_block_count();
1605 cache.release(&mut store);
1606 assert_eq!(
1607 store.free_block_count(),
1608 free_before + 1,
1609 "three table entries naming one block are one block back"
1610 );
1611 // And the store agrees: it can hand out that block once.
1612 assert!(store.acquire_block().is_some());
1613 assert!(store.acquire_block().is_none());
1614 }
1615
1616 #[test]
1617 fn a_gathered_sequence_round_trips_through_the_store() {
1618 let mut store = PagedKvStore::new(2, 8, 2, 2);
1619 let mut cache = PagedKvCache::new();
1620 // Five positions over blocks of two: the tail block is half
1621 // full, which is where an off-by-one in the gather shows up.
1622 let rows: Vec<[f32; 4]> = (0..5)
1623 .map(|i| {
1624 let b = i as f32 * 10.0;
1625 [b + 1.0, b + 2.0, b + 3.0, b + 4.0]
1626 })
1627 .collect();
1628 for r in &rows {
1629 cache.push(&mut store, r, r).unwrap();
1630 }
1631
1632 let flat = cache.to_contiguous(&store);
1633 assert_eq!(flat.positions(), 5);
1634 assert_eq!(flat.k.len(), 5 * 4);
1635 for (i, r) in rows.iter().enumerate() {
1636 assert_eq!(&flat.k[i * 4..(i + 1) * 4], r, "position {i} k");
1637 assert_eq!(&flat.v[i * 4..(i + 1) * 4], r, "position {i} v");
1638 }
1639
1640 // And appending those same rows back onto a fresh sequence
1641 // reproduces the store's view of them exactly.
1642 let mut rebuilt = PagedKvCache::new();
1643 let mut store2 = PagedKvStore::new(2, 8, 2, 2);
1644 rebuilt
1645 .append_contiguous(&mut store2, &flat.k, &flat.v, 5)
1646 .unwrap();
1647 let again = rebuilt.to_contiguous(&store2);
1648 assert_eq!(again.k, flat.k);
1649 assert_eq!(again.v, flat.v);
1650 assert_eq!(again.positions(), flat.positions());
1651 }
1652
1653 #[test]
1654 fn push_grows_seq_len_and_stores_values() {
1655 let mut cache = KvCache::new(2, 2);
1656 cache
1657 .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1658 .unwrap();
1659 assert_eq!(cache.positions(), 1);
1660 cache
1661 .push(&[9.0, 10.0, 11.0, 12.0], &[13.0, 14.0, 15.0, 16.0])
1662 .unwrap();
1663 assert_eq!(cache.positions(), 2);
1664 assert_eq!(cache.k.len(), 2 * 2 * 2);
1665 assert_eq!(cache.k[4], 9.0);
1666 }
1667
1668 #[test]
1669 #[should_panic]
1670 fn push_wrong_size_panics() {
1671 let mut cache = KvCache::new(2, 2);
1672 let _ = cache.push(&[1.0, 2.0], &[1.0, 2.0]); // too short
1673 }
1674
1675 #[test]
1676 fn clear_resets_state() {
1677 let mut cache = KvCache::new(1, 1);
1678 cache.push(&[1.0], &[2.0]).unwrap();
1679 cache.clear();
1680 assert_eq!(cache.positions(), 0);
1681 assert!(cache.k.is_empty());
1682 }
1683
1684 #[test]
1685 fn truncate_rolls_back_to_exact_length_preserving_earlier_data() {
1686 let mut cache = KvCache::new(2, 2);
1687 cache
1688 .push(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0])
1689 .unwrap();
1690 cache
1691 .push(&[5.0, 6.0, 7.0, 8.0], &[50.0, 60.0, 70.0, 80.0])
1692 .unwrap();
1693 cache
1694 .push(&[9.0, 9.0, 9.0, 9.0], &[90.0, 90.0, 90.0, 90.0])
1695 .unwrap();
1696 assert_eq!(cache.positions(), 3);
1697
1698 cache.truncate(1);
1699 assert_eq!(cache.positions(), 1);
1700 assert_eq!(cache.k, vec![1.0, 2.0, 3.0, 4.0]);
1701 assert_eq!(cache.v, vec![10.0, 20.0, 30.0, 40.0]);
1702 }
1703
1704 #[test]
1705 fn truncate_to_current_length_is_a_no_op() {
1706 let mut cache = KvCache::new(1, 2);
1707 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1708 cache.truncate(1);
1709 assert_eq!(cache.positions(), 1);
1710 assert_eq!(cache.k, vec![1.0, 2.0]);
1711 }
1712
1713 #[test]
1714 fn truncate_to_zero_empties_the_cache() {
1715 let mut cache = KvCache::new(1, 2);
1716 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1717 cache.truncate(0);
1718 assert_eq!(cache.positions(), 0);
1719 assert!(cache.k.is_empty());
1720 assert!(cache.v.is_empty());
1721 }
1722
1723 #[test]
1724 #[should_panic]
1725 fn truncate_beyond_current_length_panics() {
1726 let mut cache = KvCache::new(1, 2);
1727 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1728 cache.truncate(5);
1729 }
1730
1731 #[test]
1732 fn push_after_truncate_continues_correctly() {
1733 let mut cache = KvCache::new(1, 1);
1734 cache.push(&[1.0], &[10.0]).unwrap();
1735 cache.push(&[2.0], &[20.0]).unwrap();
1736 cache.push(&[3.0], &[30.0]).unwrap(); // this one will be "rejected"
1737 cache.truncate(2);
1738 cache.push(&[99.0], &[990.0]).unwrap(); // real continuation after rejection
1739 assert_eq!(cache.positions(), 3);
1740 assert_eq!(cache.k, vec![1.0, 2.0, 99.0]);
1741 assert_eq!(cache.v, vec![10.0, 20.0, 990.0]);
1742 }
1743
1744 #[test]
1745 fn with_capacity_preallocates_and_never_reallocates_within_plan() {
1746 let n_kv_heads = 4;
1747 let head_dim = 8;
1748 let max_seq_len = 16;
1749 let mut cache = KvCache::with_capacity(n_kv_heads, head_dim, max_seq_len);
1750
1751 let expected_elems = max_seq_len * n_kv_heads * head_dim;
1752 assert!(cache.k.capacity() >= expected_elems);
1753 assert!(cache.v.capacity() >= expected_elems);
1754
1755 let step = vec![0.5f32; n_kv_heads * head_dim];
1756 let k_ptr_before = cache.k.as_ptr();
1757 for _ in 0..max_seq_len {
1758 cache.push(&step, &step).unwrap();
1759 }
1760 let k_ptr_after = cache.k.as_ptr();
1761 assert_eq!(
1762 k_ptr_before, k_ptr_after,
1763 "pushing exactly up to the planned capacity must not reallocate"
1764 );
1765 assert!(cache.is_within_planned_capacity());
1766 }
1767
1768 #[test]
1769 fn allocated_bytes_reflects_preallocated_capacity_not_just_used_length() {
1770 let cache = KvCache::with_capacity(4, 8, 100);
1771 // 100 positions * 4 kv_heads * 8 head_dim * 2 (k+v) * 4 bytes/f32
1772 let expected_min = 100 * 4 * 8 * 2 * 4;
1773 assert!(
1774 cache.allocated_bytes() >= expected_min,
1775 "allocated_bytes={} expected_min={expected_min}",
1776 cache.allocated_bytes()
1777 );
1778 // Nothing has been pushed yet, but the memory is already reserved.
1779 assert_eq!(cache.positions(), 0);
1780 }
1781
1782 #[test]
1783 fn grow_as_you_go_cache_reports_not_within_planned_capacity() {
1784 let mut cache = KvCache::new(2, 2);
1785 cache
1786 .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1787 .unwrap();
1788 assert!(
1789 !cache.is_within_planned_capacity(),
1790 "a cache built with `new` has no plan to be within"
1791 );
1792 }
1793
1794 #[test]
1795 fn with_pool_acquires_one_block_and_reports_it_in_free_blocks() {
1796 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1797 let cache = KvCache::with_pool(2, 2, pool.clone(), 0).unwrap();
1798 assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1799 assert_eq!(cache.positions(), 0);
1800 }
1801
1802 #[test]
1803 fn with_pool_fails_without_mutating_the_pool_when_exhausted() {
1804 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 0)));
1805 let result = KvCache::with_pool(2, 2, pool.clone(), 0);
1806 assert!(result.is_err());
1807 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1808 }
1809
1810 #[test]
1811 fn push_acquires_additional_blocks_as_the_cache_crosses_block_boundaries() {
1812 let block_size = 2;
1813 let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 10)));
1814 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1815 assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1816
1817 // First block holds `block_size` = 2 positions; pushing them
1818 // must not need a second block.
1819 cache.push(&[1.0], &[1.0]).unwrap();
1820 cache.push(&[2.0], &[2.0]).unwrap();
1821 assert_eq!(
1822 pool.lock().unwrap().free_blocks(),
1823 9,
1824 "filling exactly the first block must not acquire a second one"
1825 );
1826
1827 // The third position crosses into a second block.
1828 cache.push(&[3.0], &[3.0]).unwrap();
1829 assert_eq!(pool.lock().unwrap().free_blocks(), 8);
1830 assert_eq!(cache.positions(), 3);
1831 assert_eq!(cache.k, vec![1.0, 2.0, 3.0]);
1832 }
1833
1834 #[test]
1835 fn push_returns_pool_exhausted_and_leaves_state_unchanged_when_no_blocks_remain() {
1836 let block_size = 1;
1837 let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 1)));
1838 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1839 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1840
1841 cache.push(&[1.0], &[1.0]).unwrap(); // fills the one held block
1842
1843 let before_k = cache.k.clone();
1844 let result = cache.push(&[2.0], &[2.0]);
1845 assert_eq!(result, Err(KvPoolExhausted));
1846 assert_eq!(
1847 cache.positions(),
1848 1,
1849 "a failed push must not change seq_len"
1850 );
1851 assert_eq!(cache.k, before_k, "a failed push must not append data");
1852 }
1853
1854 #[test]
1855 fn dropping_a_pooled_cache_returns_its_blocks_to_the_pool() {
1856 let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 2)));
1857 {
1858 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1859 cache.push(&[1.0], &[1.0]).unwrap(); // fills the first (only held) block
1860 cache.push(&[2.0], &[2.0]).unwrap(); // crosses into a second block
1861 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1862 }
1863 assert_eq!(
1864 pool.lock().unwrap().free_blocks(),
1865 2,
1866 "both blocks held by the dropped cache must return to the pool"
1867 );
1868 }
1869
1870 #[test]
1871 fn release_to_pool_is_explicit_and_idempotent() {
1872 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 5)));
1873 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1874 assert_eq!(pool.lock().unwrap().free_blocks(), 4);
1875
1876 cache.release_to_pool();
1877 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1878
1879 cache.release_to_pool(); // no-op, must not over-release
1880 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1881
1882 drop(cache); // must not release again either
1883 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1884 }
1885
1886 #[test]
1887 fn two_pooled_caches_share_one_bounded_budget() {
1888 let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 1)));
1889 let cache_a = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1890 let cache_b = KvCache::with_pool(1, 1, pool.clone(), 0);
1891 assert!(
1892 cache_b.is_err(),
1893 "a second concurrent request must not be admitted when the shared budget is full"
1894 );
1895
1896 drop(cache_a);
1897 let cache_c = KvCache::with_pool(1, 1, pool, 0);
1898 assert!(
1899 cache_c.is_ok(),
1900 "once the first request's cache is dropped, its budget must become available again"
1901 );
1902 }
1903
1904 #[test]
1905 fn cloning_a_pooled_cache_detaches_the_clone_from_pool_accounting() {
1906 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 3)));
1907 let original = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1908 assert_eq!(pool.lock().unwrap().free_blocks(), 2);
1909
1910 let clone = original.clone();
1911 assert_eq!(
1912 pool.lock().unwrap().free_blocks(),
1913 2,
1914 "cloning must not acquire additional blocks"
1915 );
1916 assert_eq!(clone.k, original.k);
1917
1918 drop(clone);
1919 assert_eq!(
1920 pool.lock().unwrap().free_blocks(),
1921 2,
1922 "dropping a detached clone must not release the original's blocks"
1923 );
1924
1925 drop(original);
1926 assert_eq!(
1927 pool.lock().unwrap().free_blocks(),
1928 3,
1929 "dropping the original must release its blocks exactly once"
1930 );
1931 }
1932
1933 /// A resize is arithmetic, and the one rule that is not: shrinking
1934 /// past what is held is refused, and the pool is left exactly as it
1935 /// was.
1936 ///
1937 /// Clamping to zero instead would silently over-promise -- the
1938 /// caches holding those blocks do not give them back, so every
1939 /// later acquire would decide against a budget that does not
1940 /// describe the memory in use. This test fails under that clamp.
1941 #[test]
1942 fn a_pool_refuses_to_shrink_below_what_is_already_held() {
1943 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1944 let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 24).expect("blocks");
1945 let in_use = {
1946 let p = pool.lock().unwrap();
1947 p.total_blocks() - p.free_blocks()
1948 };
1949 assert!(in_use > 0, "the fixture must actually hold blocks");
1950
1951 let mut p = pool.lock().unwrap();
1952 assert_eq!(p.resize(in_use - 1), Err(in_use));
1953 assert_eq!(p.total_blocks(), 10, "a refused resize changes nothing");
1954 assert_eq!(p.free_blocks(), 10 - in_use);
1955
1956 // Down to exactly what is held is legal, and leaves nothing free.
1957 assert_eq!(p.resize(in_use), Ok(()));
1958 assert_eq!(p.free_blocks(), 0);
1959 drop(p);
1960 drop(held);
1961 }
1962
1963 /// Growing hands the new blocks to the free list without disturbing
1964 /// what is held, which is the whole point of a live re-split.
1965 #[test]
1966 fn growing_a_pool_adds_to_what_is_free_and_not_to_what_is_held() {
1967 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
1968 let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 16).expect("blocks");
1969 let mut p = pool.lock().unwrap();
1970 let in_use = p.total_blocks() - p.free_blocks();
1971
1972 assert_eq!(p.resize(32), Ok(()));
1973 assert_eq!(p.total_blocks(), 32);
1974 assert_eq!(p.free_blocks(), 32 - in_use);
1975 drop(p);
1976 drop(held);
1977 }
1978
1979 /// Positions and rows agree for every store that does not evict,
1980 /// and this pins that they are DERIVED separately rather than one
1981 /// being an alias for the other.
1982 ///
1983 /// The point of the split is #61: a windowed layer will drop rows
1984 /// behind its window while its position count keeps climbing. Until
1985 /// then the two are equal, so this test cannot prove much about
1986 /// eviction. What it CAN prove, and what matters, is that `rows`
1987 /// reads the buffer rather than the counter: set the counter to a
1988 /// lie and `rows` still reports the truth.
1989 #[test]
1990 fn rows_is_read_from_the_buffer_and_positions_from_the_counter() {
1991 let mut cache = KvCache::new(2, 4);
1992 for i in 0..5 {
1993 let step = vec![i as f32; 8];
1994 cache.push(&step, &step).expect("unbounded growth");
1995 }
1996 assert_eq!(cache.positions(), 5);
1997 assert_eq!(cache.rows(), 5, "nothing evicts, so they agree");
1998
1999 // The counter lies; the buffer does not.
2000 cache.force_positions_for_test(99);
2001 assert_eq!(cache.positions(), 99);
2002 assert_eq!(
2003 cache.rows(),
2004 5,
2005 "rows must come from k/v, or it is just a second name for the counter"
2006 );
2007 }
2008
2009 /// `truncate` is measured in POSITIONS, and it moves both, because
2010 /// nothing evicts yet. Written down because it is the first thing
2011 /// eviction changes: a truncate target will stop being a row index.
2012 #[test]
2013 fn truncate_moves_positions_and_rows_together_while_nothing_evicts() {
2014 let mut cache = KvCache::new(1, 2);
2015 for i in 0..4 {
2016 let step = vec![i as f32; 2];
2017 cache.push(&step, &step).expect("unbounded growth");
2018 }
2019 cache.truncate(2);
2020 assert_eq!(cache.positions(), 2);
2021 assert_eq!(cache.rows(), 2);
2022 assert_eq!(cache.k.len(), 4, "two positions of two elements each");
2023 }
2024
2025 /// The store's rule must be *the* rule, not a second copy of it.
2026 ///
2027 /// `KvWindow::rows_after` is what `ferrox_models::kv_budget` prices
2028 /// against. If the store drained to anything else the budget would
2029 /// be describing a cache that does not exist, which is #33 again.
2030 #[test]
2031 fn a_windowed_cache_holds_exactly_what_the_rule_says_it_holds() {
2032 let window = KvWindow::new(8, 3).expect("positive window");
2033 let mut cache = KvCache::new(2, 4);
2034 cache.arm_window(window);
2035 let step = vec![1.0f32; 8];
2036 for p in 1..=200usize {
2037 cache.push(&step, &step).expect("unbounded growth");
2038 cache.evict_behind_window();
2039 assert_eq!(cache.positions(), p);
2040 assert_eq!(
2041 cache.rows(),
2042 window.rows_after(p),
2043 "at {p} positions the store and the rule disagree"
2044 );
2045 }
2046 }
2047
2048 /// The point of the issue: positions keep counting, rows do not.
2049 #[test]
2050 fn a_windowed_layers_resident_rows_stop_growing() {
2051 let window = KvWindow::with_default_slack(16).expect("positive window");
2052 let mut cache = KvCache::new(2, 4);
2053 cache.arm_window(window);
2054 let step = vec![0.5f32; 8];
2055 for _ in 0..2000 {
2056 cache.push(&step, &step).expect("unbounded growth");
2057 cache.evict_behind_window();
2058 }
2059 assert_eq!(cache.positions(), 2000);
2060 assert!(
2061 cache.rows() <= window.max_rows(),
2062 "{} rows resident after 2000 positions",
2063 cache.rows()
2064 );
2065 // And the bytes really went back, not just the length: `drain`
2066 // frees nothing, and memory is the whole point.
2067 assert!(
2068 cache.allocated_bytes() <= window.max_rows() * 8 * 2 * 4 * 2,
2069 "capacity was never handed back: {} bytes",
2070 cache.allocated_bytes()
2071 );
2072 }
2073
2074 /// **The correctness argument, measured.**
2075 ///
2076 /// Eviction is only allowed to be token-identical because the rows a
2077 /// windowed kernel reads -- the last `window` of them -- are the
2078 /// same bytes whether or not anything behind them was dropped. This
2079 /// pushes distinguishable rows into an evicting cache and a plain
2080 /// one and compares exactly that slice.
2081 #[test]
2082 fn the_rows_a_windowed_kernel_reads_are_identical_with_and_without_eviction() {
2083 let window = KvWindow::new(6, 2).expect("positive window");
2084 let (n_kv_heads, head_dim) = (2usize, 4usize);
2085 let per = n_kv_heads * head_dim;
2086 let mut evicting = KvCache::new(n_kv_heads, head_dim);
2087 evicting.arm_window(window);
2088 let mut plain = KvCache::new(n_kv_heads, head_dim);
2089
2090 for p in 0..120usize {
2091 // A row nothing else could produce, so a misplaced row is
2092 // not merely a different number but an identifiable one.
2093 let k: Vec<f32> = (0..per).map(|i| (p * 100 + i) as f32).collect();
2094 let v: Vec<f32> = k.iter().map(|x| -x).collect();
2095 evicting.push(&k, &v).expect("unbounded growth");
2096 evicting.evict_behind_window();
2097 plain.push(&k, &v).expect("unbounded growth");
2098
2099 let read = window.window().min(p + 1);
2100 let e_start = (evicting.rows() - read) * per;
2101 let p_start = (plain.rows() - read) * per;
2102 assert_eq!(
2103 &evicting.k[e_start..],
2104 &plain.k[p_start..],
2105 "K read set diverged at position {p}"
2106 );
2107 assert_eq!(
2108 &evicting.v[e_start..],
2109 &plain.v[p_start..],
2110 "V read set diverged at position {p}"
2111 );
2112 assert_eq!(evicting.positions(), plain.positions());
2113 }
2114 }
2115
2116 /// An unarmed cache is the cache this engine has always had.
2117 #[test]
2118 fn an_unarmed_cache_never_drops_a_row() {
2119 let mut cache = KvCache::new(2, 4);
2120 let step = vec![1.0f32; 8];
2121 for _ in 0..64 {
2122 cache.push(&step, &step).expect("unbounded growth");
2123 assert_eq!(cache.evict_behind_window(), 0);
2124 }
2125 assert_eq!(cache.rows(), 64);
2126 assert_eq!(cache.positions(), 64);
2127 assert!(cache.window().is_none());
2128 }
2129
2130 /// Speculative decoding rolls the cache back by the number of
2131 /// rejected draft tokens. That is representable while the target is
2132 /// still resident.
2133 #[test]
2134 fn truncate_still_works_inside_the_resident_window() {
2135 let window = KvWindow::new(8, 3).expect("positive window");
2136 let mut cache = KvCache::new(1, 2);
2137 cache.arm_window(window);
2138 let step = vec![1.0f32; 2];
2139 for _ in 0..50 {
2140 cache.push(&step, &step).expect("unbounded growth");
2141 cache.evict_behind_window();
2142 }
2143 let rows_before = cache.rows();
2144 cache.truncate(46);
2145 assert_eq!(cache.positions(), 46);
2146 assert_eq!(cache.rows(), rows_before - 4);
2147 }
2148
2149 /// ...and stops, loudly, when it is not. A cache that rolled back to
2150 /// the oldest row it happened to have would answer the next token
2151 /// out of a history with a hole in it.
2152 #[test]
2153 #[should_panic(expected = "rows are resident")]
2154 fn truncate_refuses_to_roll_back_past_what_the_window_kept() {
2155 let window = KvWindow::new(4, 1).expect("positive window");
2156 let mut cache = KvCache::new(1, 2);
2157 cache.arm_window(window);
2158 let step = vec![1.0f32; 2];
2159 for _ in 0..50 {
2160 cache.push(&step, &step).expect("unbounded growth");
2161 cache.evict_behind_window();
2162 }
2163 cache.truncate(3);
2164 }
2165
2166 /// A clone of an evicting cache is still an evicting cache. Reset
2167 /// the window on clone and `positions` becomes a row count again in
2168 /// whatever reads the clone.
2169 #[test]
2170 fn a_clone_carries_the_window() {
2171 let window = KvWindow::new(4, 1).expect("positive window");
2172 let mut cache = KvCache::new(1, 2);
2173 cache.arm_window(window);
2174 let step = vec![1.0f32; 2];
2175 for _ in 0..20 {
2176 cache.push(&step, &step).expect("unbounded growth");
2177 cache.evict_behind_window();
2178 }
2179 let copy = cache.clone();
2180 assert_eq!(copy.window(), Some(window));
2181 assert_eq!(copy.rows(), cache.rows());
2182 assert_eq!(copy.positions(), cache.positions());
2183 }
2184
2185 /// A pool-backed cache grows when its BUFFER is full, not when its
2186 /// position counter reaches the buffer's size. Those are the same
2187 /// number until something evicts, and an evicting pooled cache keyed
2188 /// on positions would acquire a block per token forever and exhaust
2189 /// the pool.
2190 #[test]
2191 fn a_pooled_windowed_cache_stops_acquiring_blocks() {
2192 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
2193 let mut cache =
2194 KvCache::with_pool(1, 2, Arc::clone(&pool), 8).expect("the pool was sized for this");
2195 cache.arm_window(KvWindow::new(4, 1).expect("positive window"));
2196 let step = vec![1.0f32; 2];
2197 for _ in 0..64 {
2198 cache
2199 .push(&step, &step)
2200 .expect("the buffer never fills again");
2201 cache.evict_behind_window();
2202 }
2203 assert_eq!(cache.positions(), 64);
2204 assert!(cache.rows() <= 5);
2205 }
2206
2207 /// **The sibling above passes for the wrong reason on its own
2208 /// sizing, so this is the same property where the trap is armed.**
2209 ///
2210 /// Eviction hands surplus CAPACITY back with `shrink_to`, which is
2211 /// where its memory saving actually comes from. For a pool-backed
2212 /// cache that is a bug rather than a saving: the pool has already
2213 /// promised these blocks and does not take them back, while `push`
2214 /// decides it needs another block by comparing rows against
2215 /// `k.capacity()`. Shrink that capacity to a window and the
2216 /// condition is true again every `slack + 1` tokens, forever -- so
2217 /// the cache draws a fresh block from the shared pool on a cadence,
2218 /// never releases one, and runs the pool dry underneath every OTHER
2219 /// request in the process. It surfaces where `push` is documented
2220 /// infallible, so the caller panics mid-answer.
2221 ///
2222 /// The sibling's cache is 8 positions against a 5-row ceiling,
2223 /// which is under the 2x threshold `shrink_to` is gated on, so it
2224 /// never reaches the shrink at all. This one is 64 positions
2225 /// against the same ceiling, and the pool is given spare blocks so
2226 /// the leak shows up as blocks quietly gone rather than only as the
2227 /// error at the end of them.
2228 #[test]
2229 fn an_evicting_pooled_cache_never_hands_its_capacity_back_to_reacquire_it() {
2230 let pool = Arc::new(Mutex::new(KvBlockPool::new(8, 24)));
2231 let mut cache =
2232 KvCache::with_pool(1, 2, Arc::clone(&pool), 64).expect("8 of the 24 blocks");
2233 let free_after_construction = pool.lock().unwrap().free_blocks();
2234 assert_eq!(free_after_construction, 16, "8 blocks cover 64 positions");
2235
2236 cache.arm_window(KvWindow::new(4, 1).expect("positive window"));
2237 let step = vec![1.0f32; 2];
2238 for _ in 0..512 {
2239 cache
2240 .push(&step, &step)
2241 .expect("a reservation made up front must not be re-made per token");
2242 cache.evict_behind_window();
2243 }
2244
2245 assert_eq!(
2246 pool.lock().unwrap().free_blocks(),
2247 free_after_construction,
2248 "the cache drew more blocks from the shared pool while evicting"
2249 );
2250 assert_eq!(cache.positions(), 512);
2251 assert!(cache.rows() <= 5);
2252 }
2253}