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    /// Reads one token's cached key/value vectors as an owned, fixed-size
180    /// copy (`config.token_width()` floats each — not the whole cache).
181    pub fn read_kv(&self, token_idx: usize) -> Result<KVCacheEntry, String> {
182        let width = self.config.token_width();
183        let state = self
184            .state
185            .read()
186            .map_err(|_| "kv cache lock poisoned".to_string())?;
187
188        if token_idx >= state.current_len {
189            return Err(format!(
190                "token index {token_idx} out of bounds (current length {})",
191                state.current_len
192            ));
193        }
194
195        let start = token_idx * width;
196        Ok(KVCacheEntry {
197            keys: state.keys[start..start + width].to_vec(),
198            values: state.values[start..start + width].to_vec(),
199        })
200    }
201
202    /// Reads keys/values for a contiguous token range (`[start_token,
203    /// end_token)`) in one call — the shape attention actually wants (all
204    /// prior positions at once), instead of `end_token - start_token`
205    /// separate lock acquisitions and small allocations.
206    pub fn read_range(
207        &self,
208        start_token: usize,
209        end_token: usize,
210    ) -> Result<(Vec<f32>, Vec<f32>), String> {
211        if start_token > end_token {
212            return Err("start_token must be <= end_token".to_string());
213        }
214        let width = self.config.token_width();
215        let state = self
216            .state
217            .read()
218            .map_err(|_| "kv cache lock poisoned".to_string())?;
219
220        if end_token > state.current_len {
221            return Err(format!(
222                "end_token {end_token} exceeds current length {}",
223                state.current_len
224            ));
225        }
226
227        let start = start_token * width;
228        let end = end_token * width;
229        Ok((
230            state.keys[start..end].to_vec(),
231            state.values[start..end].to_vec(),
232        ))
233    }
234
235    pub fn len(&self) -> usize {
236        self.state.read().map(|s| s.current_len).unwrap_or(0)
237    }
238
239    pub fn is_empty(&self) -> bool {
240        self.len() == 0
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn small_config() -> KVCacheConfig {
249        // Small width keeps test assertions easy to read; 2 heads * 4 dims = 8.
250        KVCacheConfig {
251            max_tokens_per_seq: 16,
252            num_heads: 2,
253            hidden_dim_per_head: 4,
254        }
255    }
256
257    #[test]
258    fn initialize_rejects_zero_len() {
259        let cache = LayerKvCache::default();
260        assert!(cache.initialize(0).is_err());
261    }
262
263    #[test]
264    fn initialize_and_round_trip() {
265        let cache = LayerKvCache::new(small_config());
266        cache.initialize(8).expect("cache should initialize");
267        assert_eq!(cache.len(), 8);
268
269        let width = cache.config.token_width();
270        let keys = vec![1.0_f32; width];
271        let values = vec![2.0_f32; width];
272        cache
273            .write_kv(2, &keys, &values)
274            .expect("write should succeed");
275
276        let entry = cache.read_kv(2).expect("entry should exist");
277        assert_eq!(entry.keys, keys);
278        assert_eq!(entry.values, values);
279    }
280
281    #[test]
282    fn growing_seq_len_preserves_prior_writes() {
283        let cache = LayerKvCache::new(small_config());
284        cache.initialize(4).unwrap();
285
286        let width = cache.config.token_width();
287        let keys = vec![7.0_f32; width];
288        let values = vec![9.0_f32; width];
289        cache.write_kv(1, &keys, &values).unwrap();
290
291        // Growing current_len must not touch the already-allocated buffer,
292        // so token 1's data must survive.
293        cache.initialize(10).unwrap();
294        assert_eq!(cache.len(), 10);
295
296        let entry = cache.read_kv(1).unwrap();
297        assert_eq!(entry.keys, keys);
298        assert_eq!(entry.values, values);
299    }
300
301    #[test]
302    fn write_kv_rejects_wrong_width() {
303        let cache = LayerKvCache::new(small_config());
304        cache.initialize(4).unwrap();
305        let wrong_width = vec![0.0_f32; cache.config.token_width() + 1];
306        assert!(cache.write_kv(0, &wrong_width, &wrong_width).is_err());
307    }
308
309    #[test]
310    fn write_kv_rejects_out_of_bounds_token() {
311        let cache = LayerKvCache::new(small_config());
312        cache.initialize(4).unwrap();
313        let width = cache.config.token_width();
314        let data = vec![0.0_f32; width];
315        assert!(cache.write_kv(999, &data, &data).is_err());
316    }
317
318    #[test]
319    fn write_kv_before_initialize_is_rejected() {
320        let cache = LayerKvCache::new(small_config());
321        let width = cache.config.token_width();
322        let data = vec![0.0_f32; width];
323        assert!(cache.write_kv(0, &data, &data).is_err());
324    }
325
326    #[test]
327    fn read_range_returns_contiguous_data() {
328        let cache = LayerKvCache::new(small_config());
329        cache.initialize(4).unwrap();
330        let width = cache.config.token_width();
331
332        for token_idx in 0..4 {
333            let keys = vec![token_idx as f32; width];
334            let values = vec![(token_idx * 10) as f32; width];
335            cache.write_kv(token_idx, &keys, &values).unwrap();
336        }
337
338        let (keys, values) = cache.read_range(1, 3).unwrap();
339        assert_eq!(keys.len(), 2 * width);
340        assert_eq!(values.len(), 2 * width);
341        // First token in the range is token_idx 1.
342        assert_eq!(keys[0], 1.0);
343        assert_eq!(values[0], 10.0);
344        // Second token in the range is token_idx 2.
345        assert_eq!(keys[width], 2.0);
346        assert_eq!(values[width], 20.0);
347    }
348
349    #[test]
350    fn read_range_rejects_end_beyond_current_len() {
351        let cache = LayerKvCache::new(small_config());
352        cache.initialize(4).unwrap();
353        assert!(cache.read_range(0, 100).is_err());
354    }
355
356    #[test]
357    fn is_empty_and_len_track_initialization() {
358        let cache = LayerKvCache::new(small_config());
359        assert!(cache.is_empty());
360        cache.initialize(5).unwrap();
361        assert!(!cache.is_empty());
362        assert_eq!(cache.len(), 5);
363    }
364
365    #[test]
366    fn concurrent_reads_do_not_corrupt_or_deadlock() {
367        let cache = LayerKvCache::new(small_config());
368        cache.initialize(8).unwrap();
369        let width = cache.config.token_width();
370        for token_idx in 0..8 {
371            let v = vec![token_idx as f32; width];
372            cache.write_kv(token_idx, &v, &v).unwrap();
373        }
374
375        let mut handles = Vec::new();
376        for _ in 0..8 {
377            let cache = cache.clone();
378            handles.push(std::thread::spawn(move || {
379                for token_idx in 0..8 {
380                    let entry = cache.read_kv(token_idx).unwrap();
381                    assert_eq!(entry.keys[0], token_idx as f32);
382                }
383            }));
384        }
385        for h in handles {
386            h.join().unwrap();
387        }
388    }
389
390    #[test]
391    fn clone_shares_the_same_underlying_cache() {
392        let cache = LayerKvCache::new(small_config());
393        cache.initialize(4).unwrap();
394        let width = cache.config.token_width();
395        let data = vec![3.0_f32; width];
396
397        let cache_clone = cache.clone();
398        cache_clone.write_kv(0, &data, &data).unwrap();
399
400        // Written through the clone, visible through the original — same
401        // underlying Arc<RwLock<..>>, not a deep copy.
402        assert_eq!(cache.read_kv(0).unwrap().keys, data);
403    }
404}