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