Skip to main content

ghostlink_core/
kv_cache.rs

1//! KV cache primitives for a single sequence within a single transformer layer.
2//!
3//! Stores one contiguous, pre-allocated buffer for all tokens' keys and one
4//! for all values (shape: `max_tokens_per_seq * num_heads * hidden_dim_per_head`
5//! floats each), instead of one small heap allocation per token. The previous
6//! design called `resize_with` on a `Vec<KVCacheEntry>` where every entry
7//! independently allocated its own `keys`/`values` `Vec<f32>` — for the
8//! default 8192-token max sequence length, a full-length `initialize()`
9//! performed up to 16,384 separate small heap allocations. A single upfront
10//! allocation eliminates that, keeps a sequence's KV data in one contiguous
11//! cache-friendly region, and enables `read_range` — a single batched read
12//! over a token span, the shape attention actually needs, instead of N
13//! separate per-token reads.
14//!
15//! Reads and writes are guarded by an `RwLock` rather than a `Mutex`: real
16//! attention reads happen far more often than writes (every position reads
17//! all previous positions' KV every decode step; only one write happens per
18//! step), so letting reads proceed concurrently with each other matters.
19//!
20//! This module has no current caller in `runtime.rs` — Ghostlink delegates
21//! actual model execution to an external inference engine (llama-server /
22//! Ollama, see `ghost-link::native_engine`) rather than computing attention
23//! itself. It stays self-contained as a ready-to-use primitive for a future
24//! local execution path, so it can evolve without affecting the stable
25//! public API exported from `lib.rs`.
26
27use std::sync::{Arc, RwLock};
28
29pub const DEFAULT_MAX_TOKENS_PER_SEQ: usize = 8192;
30pub const DEFAULT_NUM_HEADS: usize = 4;
31pub const DEFAULT_HIDDEN_DIM_PER_HEAD: usize = 64;
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct KVCacheConfig {
35    pub max_tokens_per_seq: usize,
36    pub num_heads: usize,
37    pub hidden_dim_per_head: usize,
38}
39
40impl KVCacheConfig {
41    /// Number of `f32` elements one token's keys (or values) occupy.
42    pub const fn token_width(&self) -> usize {
43        self.num_heads * self.hidden_dim_per_head
44    }
45}
46
47impl Default for KVCacheConfig {
48    fn default() -> Self {
49        Self {
50            max_tokens_per_seq: DEFAULT_MAX_TOKENS_PER_SEQ,
51            num_heads: DEFAULT_NUM_HEADS,
52            hidden_dim_per_head: DEFAULT_HIDDEN_DIM_PER_HEAD,
53        }
54    }
55}
56
57/// Owned copy of one token's cached key/value vectors, returned by `read_kv`.
58/// Small and fixed-size (`config.token_width()` floats each) — not a copy of
59/// the whole cache.
60#[derive(Clone, Debug, PartialEq)]
61pub struct KVCacheEntry {
62    pub keys: Vec<f32>,
63    pub values: Vec<f32>,
64}
65
66struct KvCacheState {
67    /// Empty until the first `initialize()` call; allocated exactly once
68    /// after that, sized for `config.max_tokens_per_seq` tokens.
69    keys: Vec<f32>,
70    values: Vec<f32>,
71    /// Number of tokens actually written so far (<= config.max_tokens_per_seq).
72    current_len: usize,
73}
74
75/// Cheap to clone (an `Arc` around the shared, lock-guarded buffer) — matches
76/// the shared-state pattern used elsewhere in this crate (e.g. `ClusterState`).
77#[derive(Clone)]
78pub struct LayerKvCache {
79    pub config: KVCacheConfig,
80    state: Arc<RwLock<KvCacheState>>,
81}
82
83impl std::fmt::Debug for LayerKvCache {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        // Deliberately does not print the backing buffers — they can be
86        // megabytes (max_tokens_per_seq * token_width * 2 floats).
87        f.debug_struct("LayerKvCache")
88            .field("config", &self.config)
89            .field("current_len", &self.len())
90            .finish()
91    }
92}
93
94impl Default for LayerKvCache {
95    fn default() -> Self {
96        Self::new(KVCacheConfig::default())
97    }
98}
99
100impl LayerKvCache {
101    /// Creates an empty cache. Does not allocate the backing buffer yet —
102    /// `initialize()` performs the single upfront allocation, sized once for
103    /// `config.max_tokens_per_seq`.
104    pub fn new(config: KVCacheConfig) -> Self {
105        Self {
106            config,
107            state: Arc::new(RwLock::new(KvCacheState {
108                keys: Vec::new(),
109                values: Vec::new(),
110                current_len: 0,
111            })),
112        }
113    }
114
115    /// Ensures the backing buffer exists (allocating it on the first call
116    /// only) and marks the first `seq_len` tokens (clamped to
117    /// `config.max_tokens_per_seq`) as valid.
118    ///
119    /// Safe to call again on the same cache — e.g. to grow `current_len` as
120    /// more tokens are generated — without losing previously written data:
121    /// the buffer is allocated once and never reallocated by this method.
122    pub fn initialize(&self, seq_len: usize) -> Result<(), String> {
123        if seq_len == 0 {
124            return Err("sequence length must be greater than zero".to_string());
125        }
126
127        let bounded_len = seq_len.min(self.config.max_tokens_per_seq);
128        let width = self.config.token_width();
129        let mut state = self
130            .state
131            .write()
132            .map_err(|_| "kv cache lock poisoned".to_string())?;
133
134        if state.keys.is_empty() {
135            let capacity = self.config.max_tokens_per_seq * width;
136            state.keys = vec![0.0; capacity];
137            state.values = vec![0.0; capacity];
138        }
139
140        state.current_len = state.current_len.max(bounded_len);
141        Ok(())
142    }
143
144    /// Writes one token's key/value vectors into the cache at `token_idx`.
145    /// Copies directly into the pre-allocated buffer slice — no allocation.
146    pub fn write_kv(&self, token_idx: usize, keys: &[f32], values: &[f32]) -> Result<(), String> {
147        if keys.len() != values.len() {
148            return Err("keys/values length mismatch".to_string());
149        }
150        let width = self.config.token_width();
151        if keys.len() != width {
152            return Err(format!(
153                "expected {width} elements per token (num_heads * hidden_dim_per_head), got {}",
154                keys.len()
155            ));
156        }
157        if token_idx >= self.config.max_tokens_per_seq {
158            return Err(format!(
159                "token index {token_idx} out of bounds (max {})",
160                self.config.max_tokens_per_seq
161            ));
162        }
163
164        let mut state = self
165            .state
166            .write()
167            .map_err(|_| "kv cache lock poisoned".to_string())?;
168        if state.keys.is_empty() {
169            return Err("cache not initialized — call initialize() first".to_string());
170        }
171
172        let start = token_idx * width;
173        state.keys[start..start + width].copy_from_slice(keys);
174        state.values[start..start + width].copy_from_slice(values);
175        state.current_len = state.current_len.max(token_idx + 1);
176        Ok(())
177    }
178
179    /// Writes multiple tokens' key/value vectors under a single write-lock
180    /// acquisition, validating every entry upfront (before taking the lock)
181    /// so a bad entry anywhere in the batch fails the whole call without
182    /// partially writing it. Same per-token semantics as calling `write_kv`
183    /// in a loop — this only changes how many times the lock is acquired.
184    pub fn write_kv_batch(&self, entries: &[(usize, &[f32], &[f32])]) -> Result<(), String> {
185        if entries.is_empty() {
186            return Ok(());
187        }
188
189        let width = self.config.token_width();
190
191        // Validate all entries upfront before taking write lock
192        for (token_idx, keys, values) in entries {
193            if keys.len() != values.len() {
194                return Err("keys/values length mismatch".to_string());
195            }
196            if keys.len() != width {
197                return Err(format!("expected {width} elements, got {}", keys.len()));
198            }
199            if *token_idx >= self.config.max_tokens_per_seq {
200                return Err(format!("token index {token_idx} out of bounds"));
201            }
202        }
203
204        // Single write lock acquisition
205        let mut state = self
206            .state
207            .write()
208            .map_err(|_| "kv cache lock poisoned".to_string())?;
209        if state.keys.is_empty() {
210            return Err("cache not initialized".to_string());
211        }
212
213        let mut max_token_idx = 0;
214        for (token_idx, keys, values) in entries {
215            let start = token_idx * width;
216            state.keys[start..start + width].copy_from_slice(keys);
217            state.values[start..start + width].copy_from_slice(values);
218            max_token_idx = max_token_idx.max(*token_idx);
219        }
220
221        state.current_len = state.current_len.max(max_token_idx + 1);
222        Ok(())
223    }
224
225    /// Reads one token's cached key/value vectors as an owned, fixed-size
226    /// copy (`config.token_width()` floats each — not the whole cache).
227    pub fn read_kv(&self, token_idx: usize) -> Result<KVCacheEntry, String> {
228        let width = self.config.token_width();
229        let state = self
230            .state
231            .read()
232            .map_err(|_| "kv cache lock poisoned".to_string())?;
233
234        if token_idx >= state.current_len {
235            return Err(format!(
236                "token index {token_idx} out of bounds (current length {})",
237                state.current_len
238            ));
239        }
240
241        let start = token_idx * width;
242        Ok(KVCacheEntry {
243            keys: state.keys[start..start + width].to_vec(),
244            values: state.values[start..start + width].to_vec(),
245        })
246    }
247
248    /// Reads keys/values for a contiguous token range (`[start_token,
249    /// end_token)`) in one call — the shape attention actually wants (all
250    /// prior positions at once), instead of `end_token - start_token`
251    /// separate lock acquisitions and small allocations.
252    pub fn read_range(
253        &self,
254        start_token: usize,
255        end_token: usize,
256    ) -> Result<(Vec<f32>, Vec<f32>), String> {
257        if start_token > end_token {
258            return Err("start_token must be <= end_token".to_string());
259        }
260        let width = self.config.token_width();
261        let state = self
262            .state
263            .read()
264            .map_err(|_| "kv cache lock poisoned".to_string())?;
265
266        if end_token > state.current_len {
267            return Err(format!(
268                "end_token {end_token} exceeds current length {}",
269                state.current_len
270            ));
271        }
272
273        let start = start_token * width;
274        let end = end_token * width;
275        Ok((
276            state.keys[start..end].to_vec(),
277            state.values[start..end].to_vec(),
278        ))
279    }
280
281    pub fn len(&self) -> usize {
282        self.state.read().map(|s| s.current_len).unwrap_or(0)
283    }
284
285    pub fn is_empty(&self) -> bool {
286        self.len() == 0
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn small_config() -> KVCacheConfig {
295        // Small width keeps test assertions easy to read; 2 heads * 4 dims = 8.
296        KVCacheConfig {
297            max_tokens_per_seq: 16,
298            num_heads: 2,
299            hidden_dim_per_head: 4,
300        }
301    }
302
303    #[test]
304    fn initialize_rejects_zero_len() {
305        let cache = LayerKvCache::default();
306        assert!(cache.initialize(0).is_err());
307    }
308
309    #[test]
310    fn initialize_and_round_trip() {
311        let cache = LayerKvCache::new(small_config());
312        cache.initialize(8).expect("cache should initialize");
313        assert_eq!(cache.len(), 8);
314
315        let width = cache.config.token_width();
316        let keys = vec![1.0_f32; width];
317        let values = vec![2.0_f32; width];
318        cache
319            .write_kv(2, &keys, &values)
320            .expect("write should succeed");
321
322        let entry = cache.read_kv(2).expect("entry should exist");
323        assert_eq!(entry.keys, keys);
324        assert_eq!(entry.values, values);
325    }
326
327    #[test]
328    fn growing_seq_len_preserves_prior_writes() {
329        let cache = LayerKvCache::new(small_config());
330        cache.initialize(4).unwrap();
331
332        let width = cache.config.token_width();
333        let keys = vec![7.0_f32; width];
334        let values = vec![9.0_f32; width];
335        cache.write_kv(1, &keys, &values).unwrap();
336
337        // Growing current_len must not touch the already-allocated buffer,
338        // so token 1's data must survive.
339        cache.initialize(10).unwrap();
340        assert_eq!(cache.len(), 10);
341
342        let entry = cache.read_kv(1).unwrap();
343        assert_eq!(entry.keys, keys);
344        assert_eq!(entry.values, values);
345    }
346
347    #[test]
348    fn write_kv_rejects_wrong_width() {
349        let cache = LayerKvCache::new(small_config());
350        cache.initialize(4).unwrap();
351        let wrong_width = vec![0.0_f32; cache.config.token_width() + 1];
352        assert!(cache.write_kv(0, &wrong_width, &wrong_width).is_err());
353    }
354
355    #[test]
356    fn write_kv_rejects_out_of_bounds_token() {
357        let cache = LayerKvCache::new(small_config());
358        cache.initialize(4).unwrap();
359        let width = cache.config.token_width();
360        let data = vec![0.0_f32; width];
361        assert!(cache.write_kv(999, &data, &data).is_err());
362    }
363
364    #[test]
365    fn write_kv_before_initialize_is_rejected() {
366        let cache = LayerKvCache::new(small_config());
367        let width = cache.config.token_width();
368        let data = vec![0.0_f32; width];
369        assert!(cache.write_kv(0, &data, &data).is_err());
370    }
371
372    #[test]
373    fn read_range_returns_contiguous_data() {
374        let cache = LayerKvCache::new(small_config());
375        cache.initialize(4).unwrap();
376        let width = cache.config.token_width();
377
378        for token_idx in 0..4 {
379            let keys = vec![token_idx as f32; width];
380            let values = vec![(token_idx * 10) as f32; width];
381            cache.write_kv(token_idx, &keys, &values).unwrap();
382        }
383
384        let (keys, values) = cache.read_range(1, 3).unwrap();
385        assert_eq!(keys.len(), 2 * width);
386        assert_eq!(values.len(), 2 * width);
387        // First token in the range is token_idx 1.
388        assert_eq!(keys[0], 1.0);
389        assert_eq!(values[0], 10.0);
390        // Second token in the range is token_idx 2.
391        assert_eq!(keys[width], 2.0);
392        assert_eq!(values[width], 20.0);
393    }
394
395    #[test]
396    fn read_range_rejects_end_beyond_current_len() {
397        let cache = LayerKvCache::new(small_config());
398        cache.initialize(4).unwrap();
399        assert!(cache.read_range(0, 100).is_err());
400    }
401
402    #[test]
403    fn is_empty_and_len_track_initialization() {
404        let cache = LayerKvCache::new(small_config());
405        assert!(cache.is_empty());
406        cache.initialize(5).unwrap();
407        assert!(!cache.is_empty());
408        assert_eq!(cache.len(), 5);
409    }
410
411    #[test]
412    fn concurrent_reads_do_not_corrupt_or_deadlock() {
413        let cache = LayerKvCache::new(small_config());
414        cache.initialize(8).unwrap();
415        let width = cache.config.token_width();
416        for token_idx in 0..8 {
417            let v = vec![token_idx as f32; width];
418            cache.write_kv(token_idx, &v, &v).unwrap();
419        }
420
421        let mut handles = Vec::new();
422        for _ in 0..8 {
423            let cache = cache.clone();
424            handles.push(std::thread::spawn(move || {
425                for token_idx in 0..8 {
426                    let entry = cache.read_kv(token_idx).unwrap();
427                    assert_eq!(entry.keys[0], token_idx as f32);
428                }
429            }));
430        }
431        for h in handles {
432            h.join().unwrap();
433        }
434    }
435
436    #[test]
437    fn clone_shares_the_same_underlying_cache() {
438        let cache = LayerKvCache::new(small_config());
439        cache.initialize(4).unwrap();
440        let width = cache.config.token_width();
441        let data = vec![3.0_f32; width];
442
443        let cache_clone = cache.clone();
444        cache_clone.write_kv(0, &data, &data).unwrap();
445
446        // Written through the clone, visible through the original — same
447        // underlying Arc<RwLock<..>>, not a deep copy.
448        assert_eq!(cache.read_kv(0).unwrap().keys, data);
449    }
450
451    #[test]
452    fn write_kv_batch_multiple() {
453        let cache = LayerKvCache::new(small_config());
454        cache.initialize(8).unwrap();
455        let width = cache.config.token_width();
456
457        let k0 = vec![0.0_f32; width];
458        let v0 = vec![0.0_f32; width];
459        let k1 = vec![1.0_f32; width];
460        let v1 = vec![10.0_f32; width];
461
462        let entries = [
463            (0, k0.as_slice(), v0.as_slice()),
464            (1, k1.as_slice(), v1.as_slice()),
465        ];
466
467        cache.write_kv_batch(&entries).unwrap();
468        assert_eq!(cache.read_kv(0).unwrap().keys[0], 0.0);
469        assert_eq!(cache.read_kv(1).unwrap().values[0], 10.0);
470    }
471
472    #[test]
473    fn write_kv_batch_empty() {
474        let cache = LayerKvCache::new(small_config());
475        cache.initialize(4).unwrap();
476        assert!(cache.write_kv_batch(&[]).is_ok());
477    }
478
479    #[test]
480    fn write_kv_batch_validation_fails() {
481        let cache = LayerKvCache::new(small_config());
482        cache.initialize(4).unwrap();
483        let width = cache.config.token_width();
484
485        let k0 = vec![1.0_f32; width];
486        let v0 = vec![2.0_f32; width];
487        let bad_k = vec![0.0_f32; width + 1];
488
489        let entries = [
490            (0, k0.as_slice(), v0.as_slice()),
491            (1, bad_k.as_slice(), v0.as_slice()),
492        ];
493
494        assert!(cache.write_kv_batch(&entries).is_err());
495    }
496
497    #[test]
498    fn write_kv_batch_is_atomic_on_validation_failure() {
499        let cache = LayerKvCache::new(small_config());
500        cache.initialize(4).unwrap();
501        let width = cache.config.token_width();
502
503        let good_keys = vec![5.0_f32; width];
504        let bad_keys = vec![0.0_f32; width + 1];
505
506        let entries = [
507            (0, good_keys.as_slice(), good_keys.as_slice()),
508            (1, bad_keys.as_slice(), good_keys.as_slice()),
509        ];
510
511        assert!(cache.write_kv_batch(&entries).is_err());
512        // Upfront validation must reject the whole batch before the write
513        // lock is even taken, so entry 0 must still hold its zero-initialized
514        // default rather than the batch's (never-applied) value.
515        assert_eq!(cache.read_kv(0).unwrap().keys[0], 0.0);
516    }
517}