use std::time::{Duration, Instant};
use crate::engine::models::{cache::LayerCache, mtp::MtpState};
pub(crate) const DEFAULT_MAX_ENTRIES: usize = 4;
pub(crate) const DEFAULT_MAX_TOTAL_TOKENS: usize = 16_384;
#[derive(Clone)]
pub struct CacheEntry {
pub ids: Vec<u32>,
pub caches: Vec<LayerCache>,
pub fed_images: usize,
pub fed_audios: usize,
last_used: Instant,
pub pinned: bool,
pub mtp: Option<MtpState>,
}
#[derive(Clone, Copy, Debug)]
pub struct PromptCacheConfig {
pub max_entries: usize,
pub max_total_tokens: usize,
pub ttl: Duration,
pub min_cacheable_tokens: usize,
}
impl Default for PromptCacheConfig {
fn default() -> Self {
PromptCacheConfig {
max_entries: DEFAULT_MAX_ENTRIES,
max_total_tokens: DEFAULT_MAX_TOTAL_TOKENS,
ttl: Duration::from_secs(5 * 60),
min_cacheable_tokens: 8,
}
}
}
pub struct PromptCachePool {
entries: Vec<CacheEntry>,
max_entries: usize,
max_total_tokens: usize,
ttl: Duration,
min_cacheable_tokens: usize,
}
impl PromptCachePool {
pub fn new(max_entries: usize, ttl: Duration, min_cacheable_tokens: usize) -> Self {
Self::with_token_budget(
max_entries,
DEFAULT_MAX_TOTAL_TOKENS,
ttl,
min_cacheable_tokens,
)
}
pub fn with_token_budget(
max_entries: usize,
max_total_tokens: usize,
ttl: Duration,
min_cacheable_tokens: usize,
) -> Self {
PromptCachePool {
entries: Vec::new(),
max_entries,
max_total_tokens,
ttl,
min_cacheable_tokens,
}
}
pub fn with_defaults() -> Self {
Self::from_config(PromptCacheConfig::default())
}
pub fn from_config(config: PromptCacheConfig) -> Self {
Self::with_token_budget(
config.max_entries,
config.max_total_tokens,
config.ttl,
config.min_cacheable_tokens,
)
}
pub fn find_longest_prefix(&mut self, ids: &[u32]) -> Option<(CacheEntry, usize)> {
self.find_longest_compatible_prefix(ids, false)
}
pub fn find_longest_compatible_prefix(
&mut self,
ids: &[u32],
require_mtp: bool,
) -> Option<(CacheEntry, usize)> {
self.evict_expired();
let mut best: Option<usize> = None; for (i, entry) in self.entries.iter().enumerate() {
if !entry.ids.is_empty()
&& is_prefix(&entry.ids, ids)
&& (!require_mtp || entry.mtp.is_some())
&& best
.map(|b| entry.ids.len() > self.entries[b].ids.len())
.unwrap_or(true)
{
best = Some(i);
}
}
let idx = best?;
self.entries[idx].last_used = Instant::now();
let shared = self.entries[idx].ids.len();
Some((self.entries[idx].clone(), shared))
}
pub fn insert_or_update(
&mut self,
ids: Vec<u32>,
caches: Vec<LayerCache>,
fed_images: usize,
fed_audios: usize,
pinned: bool,
mtp: Option<MtpState>,
) {
debug_assert!(
mtp.as_ref()
.is_none_or(|state| state.pairs_fed + 1 == ids.len()),
"pooled MtpState misaligned: pairs_fed {} for {} ids",
mtp.as_ref().map(|state| state.pairs_fed).unwrap_or(0),
ids.len(),
);
let mtp = aligned_mtp(ids.len(), mtp);
self.evict_expired();
let now = Instant::now();
if ids.len() < self.min_cacheable_tokens
&& !self.entries.iter().any(|e| is_prefix(&e.ids, &ids))
{
return;
}
if let Some(existing) = self.entries.iter_mut().find(|e| is_prefix(&e.ids, &ids)) {
existing.ids = ids;
existing.caches = caches;
existing.fed_images = fed_images;
existing.fed_audios = fed_audios;
existing.last_used = now;
existing.pinned = existing.pinned || pinned;
existing.mtp = mtp;
self.evict_lru_if_over_capacity();
return;
}
self.entries.push(CacheEntry {
ids,
caches,
fed_images,
fed_audios,
last_used: now,
pinned,
mtp,
});
self.evict_lru_if_over_capacity();
}
fn evict_expired(&mut self) {
let ttl = self.ttl;
let now = Instant::now();
self.entries
.retain(|e| e.pinned || now.duration_since(e.last_used) < ttl);
}
fn evict_lru_if_over_capacity(&mut self) {
while self.entries.len() > self.max_entries || self.total_tokens() > self.max_total_tokens {
let unpinned = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| !e.pinned)
.min_by_key(|(_, e)| e.last_used)
.map(|(i, _)| i);
let victim = unpinned.or_else(|| {
self.entries
.iter()
.enumerate()
.min_by_key(|(_, entry)| entry.last_used)
.map(|(index, _)| index)
});
match victim {
Some(i) => {
self.entries.remove(i);
}
None => break,
}
}
}
fn total_tokens(&self) -> usize {
self.entries.iter().fold(0_usize, |total, entry| {
total.saturating_add(entry.ids.len())
})
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub(crate) fn is_prefix(shorter: &[u32], longer: &[u32]) -> bool {
shorter.len() <= longer.len() && shorter == &longer[..shorter.len()]
}
fn aligned_mtp(ids_len: usize, mtp: Option<MtpState>) -> Option<MtpState> {
match mtp {
Some(state) if state.pairs_fed + 1 != ids_len => {
tracing::warn!(
pairs_fed = state.pairs_fed,
ids_len,
"dropping misaligned pooled MtpState (pairs_fed must equal ids_len - \
1)"
);
None
}
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_caches() -> Vec<LayerCache> {
Vec::new()
}
#[test]
fn find_longest_prefix_picks_the_best_match() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![1, 2, 3, 4, 5], empty_caches(), 0, 0, false, None);
let (entry, shared) = pool.find_longest_prefix(&[1, 2, 3, 4, 5, 6]).unwrap();
assert_eq!(entry.ids, vec![1, 2, 3, 4, 5]);
assert_eq!(shared, 5);
}
#[test]
fn find_longest_prefix_returns_none_when_no_overlap() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
assert!(pool.find_longest_prefix(&[9, 9, 9]).is_none());
}
#[test]
fn insert_or_update_extends_existing_lineage_in_place() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![1, 2, 3, 4, 5], empty_caches(), 1, 0, false, None);
assert_eq!(
pool.len(),
1,
"extending a lineage should not grow the pool"
);
}
#[test]
fn insert_or_update_evicts_lru_over_capacity() {
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![2], empty_caches(), 0, 0, false, None);
pool.find_longest_prefix(&[1]);
pool.insert_or_update(vec![3], empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 2);
assert!(
pool.find_longest_prefix(&[2]).is_none(),
"LRU entry should have been evicted"
);
assert!(pool.find_longest_prefix(&[1]).is_some());
assert!(pool.find_longest_prefix(&[3]).is_some());
}
#[test]
fn weighted_lru_never_exceeds_aggregate_token_budget() {
let mut pool = PromptCachePool::with_token_budget(8, 6, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 1, 1, 1], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![2, 2, 2, 2], empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 1);
assert!(pool.total_tokens() <= 6);
assert!(pool.find_longest_prefix(&[1, 1, 1, 1]).is_none());
assert!(pool.find_longest_prefix(&[2, 2, 2, 2]).is_some());
}
#[test]
fn one_entry_larger_than_hard_budget_is_not_retained() {
let mut pool = PromptCachePool::with_token_budget(8, 3, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3, 4], empty_caches(), 0, 0, true, None);
assert!(pool.is_empty());
}
#[test]
fn pinned_entries_survive_lru_pressure() {
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1], empty_caches(), 0, 0, true, None);
pool.insert_or_update(vec![2], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![3], empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 2);
assert!(
pool.find_longest_prefix(&[1]).is_some(),
"pinned entry must survive eviction"
);
assert!(
pool.find_longest_prefix(&[2]).is_none(),
"unpinned entry should have been evicted"
);
assert!(pool.find_longest_prefix(&[3]).is_some());
}
#[test]
fn entries_shorter_than_the_minimum_are_not_cached() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 8);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
assert!(
pool.is_empty(),
"a 3-token entry should be rejected by an 8-token minimum"
);
assert!(pool.find_longest_prefix(&[1, 2, 3]).is_none());
}
#[test]
fn a_lineage_becomes_cacheable_once_it_crosses_the_minimum() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 8);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
assert!(pool.is_empty());
let turn_two: Vec<u32> = (1..=10).collect();
pool.insert_or_update(turn_two.clone(), empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 1);
assert!(pool.find_longest_prefix(&turn_two).is_some());
}
#[test]
fn expired_unpinned_entries_are_evicted() {
let mut pool = PromptCachePool::new(16, Duration::from_millis(1), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
std::thread::sleep(Duration::from_millis(5));
assert!(pool.find_longest_prefix(&[1, 2, 3]).is_none());
}
#[test]
fn insert_evicts_expired_entries_eagerly() {
let mut pool = PromptCachePool::new(8, Duration::ZERO, 1);
pool.insert_or_update(vec![1, 2, 3], Vec::new(), 0, 0, false, None);
assert_eq!(pool.len(), 1);
pool.insert_or_update(vec![9, 9, 9, 9], Vec::new(), 0, 0, false, None);
assert_eq!(pool.len(), 1, "expired first entry should be gone");
}
use crate::engine::{
array::Array,
models::mtp::{MtpCaches, MtpState},
};
fn mtp_state(pairs_fed: usize) -> MtpState {
MtpState {
caches: MtpCaches(Vec::new()),
pairs_fed,
frontier: Array::from_slice(&[0.0f32], &[1, 1, 1]).unwrap(),
}
}
#[test]
fn aligned_mtp_insert_is_stored_and_served() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(
vec![1, 2, 3],
empty_caches(),
0,
0,
false,
Some(mtp_state(2)),
);
let (entry, shared) = pool
.find_longest_compatible_prefix(&[1, 2, 3, 4], true)
.unwrap();
assert_eq!(shared, 3);
let state = entry.mtp.expect("aligned MtpState must be stored");
assert_eq!(state.pairs_fed, entry.ids.len() - 1);
}
#[test]
fn aligned_mtp_drops_misaligned_state_in_release() {
assert!(aligned_mtp(3, Some(mtp_state(3))).is_none());
assert!(aligned_mtp(3, Some(mtp_state(1))).is_none());
assert!(aligned_mtp(3, Some(mtp_state(2))).is_some());
assert!(aligned_mtp(3, None).is_none());
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "pooled MtpState misaligned")]
fn misaligned_mtp_insert_debug_asserts() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(
vec![1, 2, 3],
empty_caches(),
0,
0,
false,
Some(mtp_state(7)),
);
}
#[test]
fn spec_lookup_misses_mtp_less_entry_without_evicting() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
assert!(
pool.find_longest_compatible_prefix(&[1, 2, 3, 4], true)
.is_none(),
"spec-enabled lookup must not use an mtp-less entry"
);
assert_eq!(pool.len(), 1);
assert!(
pool.find_longest_compatible_prefix(&[1, 2, 3, 4], false)
.is_some(),
"the entry must keep serving non-speculating callers"
);
}
#[test]
fn spec_lookup_prefers_a_compatible_shorter_prefix() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![1, 2], empty_caches(), 0, 0, false, Some(mtp_state(1)));
assert_eq!(pool.len(), 2);
let (entry, shared) = pool
.find_longest_compatible_prefix(&[1, 2, 3, 4], true)
.unwrap();
assert_eq!(shared, 2);
assert!(entry.mtp.is_some());
let (_, shared) = pool
.find_longest_compatible_prefix(&[1, 2, 3, 4], false)
.unwrap();
assert_eq!(shared, 3);
}
#[test]
fn incompatible_lookup_skips_last_used_refresh() {
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![8, 9], empty_caches(), 0, 0, false, None);
assert!(
pool.find_longest_compatible_prefix(&[1, 2, 3], true)
.is_none()
);
pool.insert_or_update(vec![5, 6], empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 2);
assert!(
pool.find_longest_prefix(&[1, 2]).is_none(),
"the incompatible lookup must not have refreshed [1, 2] - it stays LRU \
and is evicted"
);
assert!(pool.find_longest_prefix(&[8, 9]).is_some());
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2], empty_caches(), 0, 0, false, None);
pool.insert_or_update(vec![8, 9], empty_caches(), 0, 0, false, None);
assert!(
pool.find_longest_compatible_prefix(&[1, 2, 3], false)
.is_some()
);
pool.insert_or_update(vec![5, 6], empty_caches(), 0, 0, false, None);
assert!(
pool.find_longest_prefix(&[1, 2]).is_some(),
"the compatible lookup refreshed [1, 2], demoting [8, 9] to LRU"
);
assert!(pool.find_longest_prefix(&[8, 9]).is_none());
}
#[test]
fn insert_overwrites_mtp_wholesale_on_lineage_updates() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2], empty_caches(), 0, 0, false, Some(mtp_state(1)));
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false, None);
assert_eq!(pool.len(), 1);
let (entry, _) = pool.find_longest_prefix(&[1, 2, 3]).unwrap();
assert!(
entry.mtp.is_none(),
"a None extension must clear the stored MtpState wholesale"
);
pool.insert_or_update(
vec![1, 2, 3, 4],
empty_caches(),
0,
0,
false,
Some(mtp_state(3)),
);
assert_eq!(pool.len(), 1);
let (entry, _) = pool.find_longest_prefix(&[1, 2, 3, 4]).unwrap();
assert!(entry.mtp.is_some());
}
}