use crate::generate::{Completed, FinishReason, GenerationParams, Usage};
use ferrox_models::grammar::Grammar;
use ferrox_models::sampling::SamplingParams;
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub model: String,
pub prompt: String,
pub generation: GenerationKey,
pub seed: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GenerationKey {
pub max_tokens: usize,
pub sampling: SamplingKey,
pub stop: Vec<String>,
pub stop_token_ids: Vec<usize>,
pub json_object: bool,
pub grammar: Option<GrammarKey>,
pub ignore_eos: bool,
}
#[derive(Debug, Clone)]
pub struct GrammarKey(pub Arc<Grammar>);
impl PartialEq for GrammarKey {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl Eq for GrammarKey {}
impl Hash for GrammarKey {
fn hash<H: Hasher>(&self, state: &mut H) {
format!("{:?}", self.0).hash(state);
}
}
pub fn generation_key(params: &GenerationParams) -> GenerationKey {
let GenerationParams {
max_tokens,
sampling,
seed: _,
stop,
stop_token_ids,
json_object,
grammar,
cancel: _,
ignore_eos,
} = params;
GenerationKey {
max_tokens: *max_tokens,
sampling: sampling_key(sampling),
stop: stop.clone(),
stop_token_ids: stop_token_ids.clone(),
json_object: *json_object,
grammar: grammar.clone().map(GrammarKey),
ignore_eos: *ignore_eos,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SamplingKey {
pub temperature_bits: u32,
pub top_p_bits: u32,
pub min_p_bits: u32,
pub top_k: usize,
pub repetition_penalty_bits: u32,
pub penalty_last_n: usize,
pub presence_penalty_bits: u32,
pub frequency_penalty_bits: u32,
pub sampler_order: ferrox_models::sampler_order::SamplerOrder,
}
pub fn sampling_key(params: &SamplingParams) -> SamplingKey {
let SamplingParams {
temperature,
top_p,
min_p,
top_k,
repetition_penalty,
penalty_last_n,
presence_penalty,
frequency_penalty,
sampler_order,
} = params;
SamplingKey {
temperature_bits: temperature.to_bits(),
top_p_bits: top_p.to_bits(),
min_p_bits: min_p.to_bits(),
top_k: *top_k,
repetition_penalty_bits: repetition_penalty.to_bits(),
penalty_last_n: *penalty_last_n,
presence_penalty_bits: presence_penalty.to_bits(),
frequency_penalty_bits: frequency_penalty.to_bits(),
sampler_order: *sampler_order,
}
}
impl CacheKey {
pub fn digest(&self) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CachedCompletion {
pub content: String,
pub finish: FinishReason,
pub usage: Usage,
}
impl CachedCompletion {
pub fn cacheable(self) -> Option<CacheableCompletion> {
let _proof: Completed = self.finish.completed()?;
Some(CacheableCompletion { completion: self })
}
}
#[derive(Debug)]
pub struct CacheableCompletion {
completion: CachedCompletion,
}
struct Entry {
completion: CachedCompletion,
inserted_at: Instant,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub entries: usize,
}
pub struct ResponseCache {
entries: HashMap<CacheKey, Entry>,
order: VecDeque<CacheKey>,
max_entries: usize,
ttl: Duration,
hits: u64,
misses: u64,
}
impl ResponseCache {
pub fn new(max_entries: usize, ttl: Duration) -> Self {
ResponseCache {
entries: HashMap::new(),
order: VecDeque::new(),
max_entries,
ttl,
hits: 0,
misses: 0,
}
}
pub fn get(&mut self, key: &CacheKey) -> Option<CachedCompletion> {
let is_expired = self
.entries
.get(key)
.map(|e| e.inserted_at.elapsed() > self.ttl)
.unwrap_or(false);
if is_expired {
self.entries.remove(key);
self.order.retain(|k| k != key);
}
match self.entries.get(key) {
Some(entry) => {
self.hits += 1;
self.order.retain(|k| k != key);
self.order.push_back(key.clone());
Some(entry.completion.clone())
}
None => {
self.misses += 1;
None
}
}
}
pub fn put(&mut self, key: CacheKey, completion: CacheableCompletion) {
let CacheableCompletion { completion } = completion;
if !self.entries.contains_key(&key) && self.entries.len() >= self.max_entries {
if let Some(oldest) = self.order.pop_front() {
self.entries.remove(&oldest);
}
}
self.order.retain(|k| k != &key);
self.order.push_back(key.clone());
self.entries.insert(
key,
Entry {
completion,
inserted_at: Instant::now(),
},
);
}
pub fn stats(&self) -> CacheStats {
CacheStats {
hits: self.hits,
misses: self.misses,
entries: self.entries.len(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(prompt: &str) -> CacheKey {
key_under(prompt, |_| {})
}
fn key_under(prompt: &str, tweak: impl FnOnce(&mut GenerationParams)) -> CacheKey {
let mut params = params();
tweak(&mut params);
CacheKey {
model: "test-model".to_string(),
prompt: prompt.to_string(),
generation: generation_key(¶ms),
seed: None,
}
}
fn params() -> GenerationParams {
GenerationParams {
max_tokens: 16,
sampling: SamplingParams::default(),
seed: 0,
stop: Vec::new(),
stop_token_ids: Vec::new(),
json_object: false,
grammar: None,
cancel: None,
ignore_eos: false,
}
}
fn grammar(src: &str) -> Arc<Grammar> {
Arc::new(Grammar::from_str_with_root(src, "root").expect("grammar"))
}
fn cc(text: &str) -> CachedCompletion {
CachedCompletion {
content: text.to_string(),
finish: FinishReason::Stop,
usage: Usage::new(3, 5),
}
}
fn cacheable(text: &str) -> CacheableCompletion {
cc(text)
.cacheable()
.expect("a completion that stopped on its own is cacheable")
}
fn serve(
cache: &mut ResponseCache,
key: &CacheKey,
generated: CachedCompletion,
) -> CachedCompletion {
if let Some(hit) = cache.get(key) {
return hit;
}
if let Some(cacheable) = generated.clone().cacheable() {
cache.put(key.clone(), cacheable);
}
generated
}
#[test]
fn a_cancelled_partial_is_not_served_to_a_later_identical_request() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let k = key("What is the capital of France?");
let partial = CachedCompletion {
content: "The capital of".to_string(),
finish: FinishReason::Cancelled,
usage: Usage::new(7, 3),
};
let client_a = serve(&mut cache, &k, partial.clone());
assert_eq!(
client_a, partial,
"the client that cancelled still gets the tokens it paid for"
);
let whole = CachedCompletion {
content: "The capital of France is Paris.".to_string(),
finish: FinishReason::Stop,
usage: Usage::new(7, 9),
};
let client_b = serve(&mut cache, &k, whole.clone());
assert_eq!(
client_b, whole,
"a request nobody cancelled must be answered by its own \
generation, never replayed from a cancelled one"
);
assert_eq!(
cache.stats().hits,
0,
"there was nothing to hit: the partial never became an entry"
);
}
#[test]
fn a_completed_answer_is_still_served_to_a_later_identical_request() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let k = key("What is the capital of France?");
let whole = cc("The capital of France is Paris.");
assert_eq!(serve(&mut cache, &k, whole.clone()), whole);
assert_eq!(
serve(&mut cache, &k, cc("something else entirely")),
whole,
"a completed answer must still be cached and replayed"
);
assert_eq!(cache.stats().hits, 1);
}
#[test]
fn miss_then_hit_for_the_same_key() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
assert_eq!(cache.get(&key("hello")), None);
cache.put(key("hello"), cacheable("world"));
assert_eq!(cache.get(&key("hello")), Some(cc("world")));
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.entries, 1);
}
#[test]
fn different_keys_do_not_collide() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
cache.put(key("prompt a"), cacheable("response a"));
cache.put(key("prompt b"), cacheable("response b"));
assert_eq!(cache.get(&key("prompt a")), Some(cc("response a")));
assert_eq!(cache.get(&key("prompt b")), Some(cc("response b")));
}
#[test]
fn different_max_tokens_is_a_different_key_even_for_the_same_prompt() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let k1 = key_under("same prompt", |p| p.max_tokens = 16);
let k2 = key_under("same prompt", |p| p.max_tokens = 32);
cache.put(k1.clone(), cacheable("short response"));
assert_eq!(
cache.get(&k2),
None,
"different max_tokens must be a cache miss even with identical prompt text"
);
assert_eq!(cache.get(&k1), Some(cc("short response")));
}
#[test]
fn a_grammar_request_is_not_served_the_unconstrained_answer() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let plain = key("same prompt");
cache.put(plain.clone(), cacheable("Sure! Here are a few options..."));
let constrained = key_under("same prompt", |p| {
p.grammar = Some(grammar("root ::= \"yes\" | \"no\""))
});
assert_eq!(
cache.get(&constrained),
None,
"a grammar-constrained request must not be answered with prose \
generated under no grammar"
);
assert_eq!(
cache.get(&plain),
Some(cc("Sure! Here are a few options...")),
"the unconstrained entry is still there: the miss above is the \
grammar, not an unstable key"
);
}
#[test]
fn two_different_grammars_do_not_share_an_answer() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let keyed =
|src: &'static str| key_under("same prompt", move |p| p.grammar = Some(grammar(src)));
let yes_no = keyed("root ::= \"yes\" | \"no\"");
let digits = keyed("root ::= [0-9]+");
cache.put(yes_no.clone(), cacheable("yes"));
assert_eq!(
cache.get(&digits),
None,
"a request constrained to digits must not be served an answer \
produced under a yes/no grammar"
);
assert_eq!(cache.get(&yes_no), Some(cc("yes")));
}
#[test]
fn a_json_object_request_is_not_served_the_unconstrained_answer() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let plain = key("same prompt");
cache.put(plain.clone(), cacheable("Sure! Here are a few options..."));
let json = key_under("same prompt", |p| p.json_object = true);
assert_eq!(
cache.get(&json),
None,
"a json_object request must not be answered with prose the JSON \
mask never saw"
);
assert_eq!(
cache.get(&plain),
Some(cc("Sure! Here are a few options..."))
);
}
#[test]
fn an_ignore_eos_request_is_not_served_the_eos_terminated_answer() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let stops_at_eos = key("same prompt");
cache.put(stops_at_eos.clone(), cacheable("short"));
let runs_on = key_under("same prompt", |p| p.ignore_eos = true);
assert_eq!(
cache.get(&runs_on),
None,
"ignore_eos must not be answered with a completion that stopped \
at the model's EOS"
);
assert_eq!(cache.get(&stops_at_eos), Some(cc("short")));
}
#[test]
fn expired_entry_is_a_miss_and_is_evicted() {
let mut cache = ResponseCache::new(10, Duration::from_millis(10));
cache.put(key("hello"), cacheable("world"));
std::thread::sleep(Duration::from_millis(30));
assert_eq!(
cache.get(&key("hello")),
None,
"entry older than the TTL must be treated as a miss"
);
assert_eq!(
cache.stats().entries,
0,
"expired entry must actually be evicted, not just skipped"
);
}
#[test]
fn evicts_least_recently_used_entry_when_full() {
let mut cache = ResponseCache::new(2, Duration::from_secs(60));
cache.put(key("a"), cacheable("1"));
cache.put(key("b"), cacheable("2"));
assert_eq!(cache.get(&key("a")), Some(cc("1")));
cache.put(key("c"), cacheable("3"));
assert_eq!(
cache.get(&key("b")),
None,
"least-recently-used entry ('b') must have been evicted"
);
assert_eq!(
cache.get(&key("a")),
Some(cc("1")),
"recently-touched entry ('a') must survive eviction"
);
assert_eq!(
cache.get(&key("c")),
Some(cc("3")),
"newly inserted entry ('c') must be present"
);
}
#[test]
fn putting_an_existing_key_again_does_not_grow_past_capacity() {
let mut cache = ResponseCache::new(2, Duration::from_secs(60));
cache.put(key("a"), cacheable("1"));
cache.put(key("b"), cacheable("2"));
cache.put(key("a"), cacheable("1-updated")); assert_eq!(cache.stats().entries, 2);
assert_eq!(cache.get(&key("a")), Some(cc("1-updated")));
assert_eq!(
cache.get(&key("b")),
Some(cc("2")),
"unrelated entry must survive a re-insert of another key"
);
}
#[test]
fn digest_is_stable_for_identical_keys_and_differs_for_different_keys() {
let a1 = key("hello");
let a2 = key("hello");
let b = key("goodbye");
assert_eq!(a1.digest(), a2.digest());
assert_ne!(a1.digest(), b.digest());
}
}