use std::collections::HashMap;
use std::time::Instant;
use mold_inference::InferenceEngine;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelResidency {
Gpu,
Unloaded,
Parked,
}
pub struct CachedEngine {
pub engine: Box<dyn InferenceEngine>,
pub model_name: String,
pub residency: ModelResidency,
pub last_used: Instant,
pub vram_bytes: u64,
}
pub struct ModelCache {
entries: HashMap<String, CachedEngine>,
lru_order: Vec<String>,
max_cached: usize,
}
impl ModelCache {
pub fn new(max_cached: usize) -> Self {
Self {
entries: HashMap::new(),
lru_order: Vec::new(),
max_cached: max_cached.max(1),
}
}
pub fn insert(
&mut self,
engine: Box<dyn InferenceEngine>,
vram_bytes: u64,
) -> Option<Box<dyn InferenceEngine>> {
let name = engine.model_name().to_string();
let mut evicted = None;
if self.entries.len() >= self.max_cached && !self.entries.contains_key(&name) {
evicted = self.evict_lru();
}
let entry = CachedEngine {
model_name: name.clone(),
residency: if engine.is_loaded() {
ModelResidency::Gpu
} else {
ModelResidency::Unloaded
},
last_used: Instant::now(),
vram_bytes,
engine,
};
self.entries.insert(name.clone(), entry);
self.touch_order(&name);
evicted
}
pub fn get_mut(&mut self, model_name: &str) -> Option<&mut CachedEngine> {
if self.entries.contains_key(model_name) {
self.touch_order(model_name);
self.entries.get_mut(model_name)
} else {
None
}
}
pub fn contains(&self, model_name: &str) -> bool {
self.entries.contains_key(model_name)
}
pub fn remove(&mut self, model_name: &str) -> Option<Box<dyn InferenceEngine>> {
self.lru_order.retain(|n| n != model_name);
self.entries.remove(model_name).map(|e| e.engine)
}
pub fn unload_all(&mut self) -> Vec<String> {
let mut unloaded = Vec::new();
for entry in self.entries.values_mut() {
if entry.residency == ModelResidency::Gpu {
entry.engine.unload();
entry.residency = ModelResidency::Parked;
entry.vram_bytes = 0;
unloaded.push(entry.model_name.clone());
}
}
unloaded
}
pub fn unload_active(&mut self) -> Option<String> {
let active_name = self
.entries
.values()
.find(|e| e.residency == ModelResidency::Gpu)
.map(|e| e.model_name.clone());
if let Some(ref name) = active_name {
if let Some(entry) = self.entries.get_mut(name) {
entry.engine.unload();
entry.residency = ModelResidency::Parked;
entry.vram_bytes = 0;
}
}
active_name
}
pub fn clear(&mut self) -> Vec<Box<dyn InferenceEngine>> {
self.lru_order.clear();
self.entries.drain().map(|(_, e)| e.engine).collect()
}
pub fn active_vram_bytes(&self) -> u64 {
self.entries
.values()
.find(|e| e.residency == ModelResidency::Gpu)
.map(|e| e.vram_bytes)
.unwrap_or(0)
}
pub fn active_model(&self) -> Option<&str> {
self.entries
.values()
.find(|e| e.residency == ModelResidency::Gpu)
.map(|e| e.model_name.as_str())
}
pub fn cached_model_names(&self) -> Vec<String> {
self.lru_order.clone()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn evict_lru(&mut self) -> Option<Box<dyn InferenceEngine>> {
if let Some(name) = self.lru_order.first().cloned() {
self.lru_order.remove(0);
return self.entries.remove(&name).map(|e| e.engine);
}
None
}
fn touch_order(&mut self, model_name: &str) {
self.lru_order.retain(|n| n != model_name);
self.lru_order.push(model_name.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use mold_core::GenerateRequest;
struct MockEngine {
name: String,
loaded: bool,
}
impl MockEngine {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
loaded: true,
}
}
}
impl InferenceEngine for MockEngine {
fn generate(&mut self, _req: &GenerateRequest) -> Result<mold_core::GenerateResponse> {
unimplemented!()
}
fn model_name(&self) -> &str {
&self.name
}
fn is_loaded(&self) -> bool {
self.loaded
}
fn load(&mut self) -> Result<()> {
self.loaded = true;
Ok(())
}
fn unload(&mut self) {
self.loaded = false;
}
}
#[test]
fn insert_and_get() {
let mut cache = ModelCache::new(3);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
assert!(cache.contains("model-a"));
assert_eq!(cache.len(), 1);
assert_eq!(cache.active_model(), Some("model-a"));
}
#[test]
fn lru_eviction() {
let mut cache = ModelCache::new(2);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
cache.insert(Box::new(MockEngine::new("model-b")), 1000);
let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
assert!(evicted.is_some());
assert!(!cache.contains("model-a"));
assert!(cache.contains("model-b"));
assert!(cache.contains("model-c"));
}
#[test]
fn touch_updates_lru_order() {
let mut cache = ModelCache::new(2);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
cache.insert(Box::new(MockEngine::new("model-b")), 1000);
cache.get_mut("model-a");
let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
assert!(evicted.is_some());
assert!(cache.contains("model-a")); assert!(!cache.contains("model-b")); assert!(cache.contains("model-c"));
}
#[test]
fn unload_active() {
let mut cache = ModelCache::new(3);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
assert_eq!(cache.active_model(), Some("model-a"));
let unloaded = cache.unload_active();
assert_eq!(unloaded.as_deref(), Some("model-a"));
assert_eq!(cache.active_model(), None);
assert!(cache.contains("model-a"));
let entry = cache.get_mut("model-a").unwrap();
assert_eq!(entry.residency, ModelResidency::Parked);
}
#[test]
fn remove_model() {
let mut cache = ModelCache::new(3);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
let removed = cache.remove("model-a");
assert!(removed.is_some());
assert!(!cache.contains("model-a"));
assert_eq!(cache.len(), 0);
}
#[test]
fn reinserting_same_model_does_not_evict() {
let mut cache = ModelCache::new(2);
cache.insert(Box::new(MockEngine::new("model-a")), 1000);
cache.insert(Box::new(MockEngine::new("model-b")), 1000);
let evicted = cache.insert(Box::new(MockEngine::new("model-a")), 2000);
assert!(evicted.is_none());
assert_eq!(cache.len(), 2);
}
#[test]
fn is_empty_and_clear() {
let mut cache = ModelCache::new(3);
assert!(cache.is_empty());
cache.insert(Box::new(MockEngine::new("model-a")), 100);
assert!(!cache.is_empty());
let cleared = cache.clear();
assert_eq!(cleared.len(), 1);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn unload_all_parks_all_models() {
let mut cache = ModelCache::new(3);
cache.insert(Box::new(MockEngine::new("model-a")), 100);
cache.insert(Box::new(MockEngine::new("model-b")), 200);
let unloaded = cache.unload_all();
assert!(!unloaded.is_empty());
assert!(cache.active_model().is_none());
assert_eq!(cache.len(), 2);
}
#[test]
fn cached_model_names_reflects_lru_order() {
let mut cache = ModelCache::new(3);
cache.insert(Box::new(MockEngine::new("model-a")), 100);
cache.insert(Box::new(MockEngine::new("model-b")), 200);
cache.insert(Box::new(MockEngine::new("model-c")), 300);
assert_eq!(
cache.cached_model_names(),
vec!["model-a", "model-b", "model-c"]
);
cache.get_mut("model-a");
assert_eq!(
cache.cached_model_names(),
vec!["model-b", "model-c", "model-a"]
);
}
#[test]
fn get_mut_nonexistent_returns_none() {
let mut cache = ModelCache::new(3);
assert!(cache.get_mut("nonexistent").is_none());
}
#[test]
fn remove_nonexistent_returns_none() {
let mut cache = ModelCache::new(3);
assert!(cache.remove("nonexistent").is_none());
}
#[test]
fn unload_active_when_empty_returns_none() {
let mut cache = ModelCache::new(3);
assert!(cache.unload_active().is_none());
}
#[test]
fn max_cached_clamped_to_at_least_one() {
let mut cache = ModelCache::new(0);
cache.insert(Box::new(MockEngine::new("model-a")), 100);
assert_eq!(cache.len(), 1);
assert!(cache.contains("model-a"));
}
}