Skip to main content

skippy_cache/
config.rs

1use skippy_protocol::{StageConfig, StageKvCacheConfig};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub struct ResidentCacheConfig {
5    pub max_entries: usize,
6    pub max_bytes: u64,
7    pub min_tokens: u64,
8    pub reserved_seq_count: i32,
9    /// Maximum number of native KV cell positions the cache may hold
10    /// at one time, in tokens. Under `kv_unified = true` (skippy patch
11    /// 0034) the resident prefix cache shares one `n_ctx` cell pool
12    /// with the active execution lanes. Without this cap the cache
13    /// budget is bounded only by `max_entries` and `max_bytes`, both
14    /// of which can easily allow more pinned tokens than the cell
15    /// pool has cells — the lanes then can't find a free slot and
16    /// the embedded runtime surfaces HTTP 502
17    /// `RuntimeError: llama_decode failed`
18    /// (`decode: failed to find a memory slot`).
19    ///
20    /// Set this to a fraction of the model's `n_ctx` (typically
21    /// `n_ctx / 2` or similar). A value of 0 disables the cap and
22    /// behaves like the legacy unbounded-by-tokens cache. The cap is
23    /// only useful when `n_ctx` is comfortably larger than
24    /// `min_tokens`; see [`derive_max_resident_tokens`] for the floor.
25    pub max_resident_tokens: u64,
26}
27
28impl ResidentCacheConfig {
29    pub fn from_stage(config: &StageConfig, cache: &StageKvCacheConfig) -> Self {
30        let reserved_seq_count = i32::try_from(config.lane_count.saturating_mul(2))
31            .unwrap_or(i32::MAX)
32            .max(2);
33        let max_resident_tokens = derive_max_resident_tokens(u64::from(config.ctx_size));
34        Self {
35            max_entries: cache.max_entries.clamp(1, 512),
36            max_bytes: cache.max_bytes,
37            min_tokens: cache.min_tokens,
38            reserved_seq_count,
39            max_resident_tokens,
40        }
41    }
42}
43
44/// Derive `max_resident_tokens` from the model's `n_ctx` cell pool.
45///
46/// The cache shares the `n_ctx` cell pool with the active lanes under
47/// `kv_unified = true`. The cap reserves half of the pool for in-flight
48/// lane prefills and lets the cache use at most the other half.
49///
50/// For small contexts (smoke-test / tiny-model configs) the half-pool
51/// can be smaller than a single typical prompt; applying the cap then
52/// rejects the very first record and degrades the cache without
53/// preventing any real wedge. The cap is therefore disabled when the
54/// model's `n_ctx` is below `MIN_CTX_FOR_CELL_CAP` cells. The original
55/// failure mode this cap fixes is large-context unified-KV serving
56/// (e.g. `n_ctx = 131072`), which comfortably clears this floor.
57///
58/// Picking `min_tokens` as the floor would be tempting but does not
59/// match the actual wedge: callers can configure `min_tokens` as low
60/// as 64 while still using a small `n_ctx`, and the cap would still
61/// be smaller than typical prompts. A hard cell-count floor is easier
62/// to reason about and matches the real-world contexts the cap is
63/// designed for (long-context unified-KV serving).
64const MIN_CTX_FOR_CELL_CAP: u64 = 8192;
65
66fn derive_max_resident_tokens(ctx_size: u64) -> u64 {
67    if ctx_size < MIN_CTX_FOR_CELL_CAP {
68        return 0;
69    }
70    ctx_size.saturating_div(2)
71}
72
73#[cfg(test)]
74mod resident_cache_config_tests {
75    use super::*;
76
77    #[test]
78    fn cap_disabled_for_smoke_test_ctx_size() {
79        // Smoke-test / SmolLM2 scenario: ctx_size=768. Half=384 would
80        // be smaller than a typical 533-token smoke prompt; cap stays
81        // disabled.
82        assert_eq!(derive_max_resident_tokens(768), 0);
83    }
84
85    #[test]
86    fn cap_enabled_for_production_ctx_size() {
87        // Production failure mode the cap is designed for.
88        assert_eq!(derive_max_resident_tokens(131072), 65536);
89        // Exactly at the floor.
90        assert_eq!(derive_max_resident_tokens(8192), 4096);
91        // Just above the floor.
92        assert_eq!(derive_max_resident_tokens(16384), 8192);
93    }
94
95    #[test]
96    fn cap_disabled_just_below_floor() {
97        // Below the hard floor.
98        assert_eq!(derive_max_resident_tokens(8191), 0);
99        assert_eq!(derive_max_resident_tokens(4096), 0);
100    }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct PrefixCandidatePolicy {
105    pub min_tokens: u64,
106    pub stride_tokens: u64,
107    pub record_limit: u64,
108    pub page_size_tokens: u64,
109}
110
111impl PrefixCandidatePolicy {
112    pub fn from_cache(cache: &StageKvCacheConfig) -> Self {
113        Self {
114            min_tokens: cache.min_tokens,
115            stride_tokens: cache.shared_prefix_stride_tokens,
116            record_limit: cache.shared_prefix_record_limit,
117            page_size_tokens: cache.min_tokens.max(1),
118        }
119    }
120
121    pub fn candidate_token_counts(self, token_count: u64) -> Vec<u64> {
122        if token_count == 0 {
123            return Vec::new();
124        }
125        let mut counts = vec![token_count];
126        if self.min_tokens == 0 || token_count <= self.min_tokens {
127            return counts;
128        }
129        let stride = self.stride_tokens.max(1).min(self.page_size_tokens.max(1));
130        let mut candidate = stable_grid_floor(token_count, stride);
131        if candidate == token_count {
132            candidate = candidate.saturating_sub(stride);
133        }
134        if candidate < self.min_tokens {
135            candidate = self.min_tokens;
136        }
137        while candidate >= self.min_tokens {
138            counts.push(candidate);
139            if candidate == self.min_tokens {
140                break;
141            }
142            let next = candidate.saturating_sub(stride);
143            candidate = next.max(self.min_tokens);
144        }
145        counts.sort_unstable_by(|a, b| b.cmp(a));
146        counts.dedup();
147        counts
148    }
149
150    pub fn record_candidate_token_counts(self, token_count: u64) -> Vec<u64> {
151        let candidates = self.candidate_token_counts(token_count);
152        let limit = self.record_limit as usize;
153        if limit == 0 || candidates.len() <= limit {
154            return candidates;
155        }
156
157        let mut selected = Vec::with_capacity(limit);
158        selected.push(token_count);
159
160        let shared_slots = limit.saturating_sub(1);
161        if shared_slots == 0 {
162            return selected;
163        }
164        for candidate in candidates.iter().copied() {
165            if selected.len() >= limit {
166                break;
167            }
168            if candidate != token_count {
169                selected.push(candidate);
170            }
171        }
172        if selected.len() < limit {
173            for candidate in candidates.into_iter().rev() {
174                if selected.len() >= limit {
175                    break;
176                }
177                if !selected.contains(&candidate) {
178                    selected.push(candidate);
179                }
180            }
181        }
182        selected.sort_unstable_by(|a, b| b.cmp(a));
183        selected.dedup();
184        selected
185    }
186}
187
188fn stable_grid_floor(token_count: u64, stride: u64) -> u64 {
189    token_count.saturating_sub(token_count % stride.max(1))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn lookup_candidates_prefer_longest_prefix_first() {
198        let policy = PrefixCandidatePolicy {
199            min_tokens: 64,
200            stride_tokens: 32,
201            record_limit: 2,
202            page_size_tokens: 64,
203        };
204
205        assert_eq!(policy.candidate_token_counts(160), vec![160, 128, 96, 64]);
206    }
207
208    #[test]
209    fn record_candidates_are_limited_but_keep_current_and_shared_prefix() {
210        let policy = PrefixCandidatePolicy {
211            min_tokens: 64,
212            stride_tokens: 32,
213            record_limit: 2,
214            page_size_tokens: 64,
215        };
216
217        assert_eq!(policy.record_candidate_token_counts(160), vec![160, 128]);
218    }
219
220    #[test]
221    fn candidates_below_min_only_use_exact_request() {
222        let policy = PrefixCandidatePolicy {
223            min_tokens: 64,
224            stride_tokens: 32,
225            record_limit: 2,
226            page_size_tokens: 64,
227        };
228
229        assert_eq!(policy.candidate_token_counts(63), vec![63]);
230        assert_eq!(policy.record_candidate_token_counts(63), vec![63]);
231    }
232
233    #[test]
234    fn unlimited_record_candidates_keep_shared_prefix_grid() {
235        let policy = PrefixCandidatePolicy {
236            min_tokens: 64,
237            stride_tokens: 32,
238            record_limit: 0,
239            page_size_tokens: 64,
240        };
241
242        assert_eq!(
243            policy.record_candidate_token_counts(160),
244            vec![160, 128, 96, 64]
245        );
246    }
247
248    #[test]
249    fn same_prefix_different_tail_prompts_share_near_tail_candidate() {
250        let policy = PrefixCandidatePolicy {
251            min_tokens: 256,
252            stride_tokens: 128,
253            record_limit: 2,
254            page_size_tokens: 256,
255        };
256
257        let recorded = policy.record_candidate_token_counts(2214);
258        let lookup = policy.candidate_token_counts(2231);
259        let shared = recorded
260            .iter()
261            .copied()
262            .find(|candidate| lookup.contains(candidate));
263
264        assert_eq!(recorded, vec![2214, 2176]);
265        assert_eq!(shared, Some(2176));
266    }
267
268    #[test]
269    fn non_aligned_min_tokens_still_provides_shared_floor_candidate() {
270        let policy = PrefixCandidatePolicy {
271            min_tokens: 300,
272            stride_tokens: 128,
273            record_limit: 2,
274            page_size_tokens: 300,
275        };
276
277        assert_eq!(policy.candidate_token_counts(350), vec![350, 300]);
278        assert_eq!(policy.record_candidate_token_counts(350), vec![350, 300]);
279    }
280}