Skip to main content

mold_server/
model_cache.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use mold_inference::InferenceEngine;
5
6/// Where a cached model's weights currently reside.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ModelResidency {
9    /// Fully loaded on GPU, ready for immediate inference.
10    Gpu,
11    /// Engine exists but weights are unloaded. Can reload without recreating
12    /// the engine (retains paths, config, caches).
13    Unloaded,
14}
15
16/// A model entry in the cache.
17pub struct CachedEngine {
18    pub engine: Box<dyn InferenceEngine>,
19    pub model_name: String,
20    pub residency: ModelResidency,
21    pub last_used: Instant,
22    /// Measured VRAM footprint (bytes). Set after loading by measuring delta.
23    pub vram_bytes: u64,
24}
25
26/// Multi-model cache with LRU eviction under VRAM pressure.
27///
28/// Invariants:
29/// - At most one engine has `residency == Gpu` at a time (single-GPU inference).
30/// - `lru_order` tracks all entries from least-recently-used (front) to
31///   most-recently-used (back).
32/// - `max_cached` limits total entries (both Gpu and Unloaded).
33pub struct ModelCache {
34    entries: HashMap<String, CachedEngine>,
35    /// Ordered from least-recently-used (index 0) to most-recently-used (last).
36    lru_order: Vec<String>,
37    /// Maximum number of models to keep cached (loaded + unloaded).
38    max_cached: usize,
39}
40
41impl ModelCache {
42    pub fn new(max_cached: usize) -> Self {
43        Self {
44            entries: HashMap::new(),
45            lru_order: Vec::new(),
46            max_cached: max_cached.max(1),
47        }
48    }
49
50    /// Insert an engine into the cache. If the cache is full, the LRU entry
51    /// is dropped entirely. Returns the evicted engine (if any) for cleanup.
52    pub fn insert(
53        &mut self,
54        engine: Box<dyn InferenceEngine>,
55        vram_bytes: u64,
56    ) -> Option<Box<dyn InferenceEngine>> {
57        let name = engine.model_name().to_string();
58        let mut evicted = None;
59
60        // Evict LRU if at capacity (skip if the model is already in cache)
61        if self.entries.len() >= self.max_cached && !self.entries.contains_key(&name) {
62            evicted = self.evict_lru();
63        }
64
65        let entry = CachedEngine {
66            model_name: name.clone(),
67            residency: if engine.is_loaded() {
68                ModelResidency::Gpu
69            } else {
70                ModelResidency::Unloaded
71            },
72            last_used: Instant::now(),
73            vram_bytes,
74            engine,
75        };
76
77        self.entries.insert(name.clone(), entry);
78        self.touch_order(&name);
79        evicted
80    }
81
82    /// Get a mutable reference to the engine for a model, if cached.
83    pub fn get_mut(&mut self, model_name: &str) -> Option<&mut CachedEngine> {
84        if self.entries.contains_key(model_name) {
85            self.touch_order(model_name);
86            self.entries.get_mut(model_name)
87        } else {
88            None
89        }
90    }
91
92    /// Check if a model is in the cache.
93    pub fn contains(&self, model_name: &str) -> bool {
94        self.entries.contains_key(model_name)
95    }
96
97    /// Remove a model from the cache entirely, returning its engine.
98    pub fn remove(&mut self, model_name: &str) -> Option<Box<dyn InferenceEngine>> {
99        self.lru_order.retain(|n| n != model_name);
100        self.entries.remove(model_name).map(|e| e.engine)
101    }
102
103    /// Unload all models from GPU. Returns names of models that were unloaded.
104    pub fn unload_all(&mut self) -> Vec<String> {
105        let mut unloaded = Vec::new();
106        for entry in self.entries.values_mut() {
107            if entry.residency == ModelResidency::Gpu {
108                entry.engine.unload();
109                entry.residency = ModelResidency::Unloaded;
110                entry.vram_bytes = 0;
111                unloaded.push(entry.model_name.clone());
112            }
113        }
114        unloaded
115    }
116
117    /// Unload the current GPU-resident model (if any) to make room for a new one.
118    /// Returns the name of the unloaded model.
119    pub fn unload_active(&mut self) -> Option<String> {
120        let active_name = self
121            .entries
122            .values()
123            .find(|e| e.residency == ModelResidency::Gpu)
124            .map(|e| e.model_name.clone());
125
126        if let Some(ref name) = active_name {
127            if let Some(entry) = self.entries.get_mut(name) {
128                entry.engine.unload();
129                entry.residency = ModelResidency::Unloaded;
130                entry.vram_bytes = 0;
131            }
132        }
133        active_name
134    }
135
136    /// Drop all entries, returning all engines for cleanup.
137    pub fn clear(&mut self) -> Vec<Box<dyn InferenceEngine>> {
138        self.lru_order.clear();
139        self.entries.drain().map(|(_, e)| e.engine).collect()
140    }
141
142    /// The currently GPU-loaded model name.
143    pub fn active_model(&self) -> Option<&str> {
144        self.entries
145            .values()
146            .find(|e| e.residency == ModelResidency::Gpu)
147            .map(|e| e.model_name.as_str())
148    }
149
150    /// All cached model names (any residency).
151    pub fn cached_model_names(&self) -> Vec<String> {
152        self.lru_order.clone()
153    }
154
155    /// Number of cached entries.
156    pub fn len(&self) -> usize {
157        self.entries.len()
158    }
159
160    pub fn is_empty(&self) -> bool {
161        self.entries.is_empty()
162    }
163
164    /// Evict the least-recently-used entry, returning its engine for cleanup.
165    fn evict_lru(&mut self) -> Option<Box<dyn InferenceEngine>> {
166        // Find the first LRU entry that can be evicted
167        if let Some(name) = self.lru_order.first().cloned() {
168            self.lru_order.remove(0);
169            return self.entries.remove(&name).map(|e| e.engine);
170        }
171        None
172    }
173
174    /// Move a model name to the MRU position in the LRU order.
175    fn touch_order(&mut self, model_name: &str) {
176        self.lru_order.retain(|n| n != model_name);
177        self.lru_order.push(model_name.to_string());
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use anyhow::Result;
185    use mold_core::GenerateRequest;
186
187    struct MockEngine {
188        name: String,
189        loaded: bool,
190    }
191
192    impl MockEngine {
193        fn new(name: &str) -> Self {
194            Self {
195                name: name.to_string(),
196                loaded: true,
197            }
198        }
199    }
200
201    impl InferenceEngine for MockEngine {
202        fn generate(&mut self, _req: &GenerateRequest) -> Result<mold_core::GenerateResponse> {
203            unimplemented!()
204        }
205        fn model_name(&self) -> &str {
206            &self.name
207        }
208        fn is_loaded(&self) -> bool {
209            self.loaded
210        }
211        fn load(&mut self) -> Result<()> {
212            self.loaded = true;
213            Ok(())
214        }
215        fn unload(&mut self) {
216            self.loaded = false;
217        }
218    }
219
220    #[test]
221    fn insert_and_get() {
222        let mut cache = ModelCache::new(3);
223        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
224        assert!(cache.contains("model-a"));
225        assert_eq!(cache.len(), 1);
226        assert_eq!(cache.active_model(), Some("model-a"));
227    }
228
229    #[test]
230    fn lru_eviction() {
231        let mut cache = ModelCache::new(2);
232        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
233        cache.insert(Box::new(MockEngine::new("model-b")), 1000);
234        // Cache full (2), inserting model-c should evict model-a (LRU)
235        let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
236        assert!(evicted.is_some());
237        assert!(!cache.contains("model-a"));
238        assert!(cache.contains("model-b"));
239        assert!(cache.contains("model-c"));
240    }
241
242    #[test]
243    fn touch_updates_lru_order() {
244        let mut cache = ModelCache::new(2);
245        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
246        cache.insert(Box::new(MockEngine::new("model-b")), 1000);
247        // Touch model-a (makes model-b the LRU)
248        cache.get_mut("model-a");
249        let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
250        assert!(evicted.is_some());
251        assert!(cache.contains("model-a")); // was touched, survived
252        assert!(!cache.contains("model-b")); // LRU, evicted
253        assert!(cache.contains("model-c"));
254    }
255
256    #[test]
257    fn unload_active() {
258        let mut cache = ModelCache::new(3);
259        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
260        assert_eq!(cache.active_model(), Some("model-a"));
261
262        let unloaded = cache.unload_active();
263        assert_eq!(unloaded.as_deref(), Some("model-a"));
264        assert_eq!(cache.active_model(), None);
265        // Still in cache, just unloaded
266        assert!(cache.contains("model-a"));
267        let entry = cache.get_mut("model-a").unwrap();
268        assert_eq!(entry.residency, ModelResidency::Unloaded);
269    }
270
271    #[test]
272    fn remove_model() {
273        let mut cache = ModelCache::new(3);
274        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
275        let removed = cache.remove("model-a");
276        assert!(removed.is_some());
277        assert!(!cache.contains("model-a"));
278        assert_eq!(cache.len(), 0);
279    }
280
281    #[test]
282    fn reinserting_same_model_does_not_evict() {
283        let mut cache = ModelCache::new(2);
284        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
285        cache.insert(Box::new(MockEngine::new("model-b")), 1000);
286        // Re-insert model-a (should replace, not trigger eviction)
287        let evicted = cache.insert(Box::new(MockEngine::new("model-a")), 2000);
288        assert!(evicted.is_none());
289        assert_eq!(cache.len(), 2);
290    }
291
292    #[test]
293    fn is_empty_and_clear() {
294        let mut cache = ModelCache::new(3);
295        assert!(cache.is_empty());
296        cache.insert(Box::new(MockEngine::new("model-a")), 100);
297        assert!(!cache.is_empty());
298        let cleared = cache.clear();
299        assert_eq!(cleared.len(), 1);
300        assert!(cache.is_empty());
301        assert_eq!(cache.len(), 0);
302    }
303
304    #[test]
305    fn unload_all_parks_all_models() {
306        let mut cache = ModelCache::new(3);
307        cache.insert(Box::new(MockEngine::new("model-a")), 100);
308        cache.insert(Box::new(MockEngine::new("model-b")), 200);
309
310        let unloaded = cache.unload_all();
311        // Only model-b has Gpu residency (model-a was replaced when model-b was inserted
312        // — actually both are "loaded" since MockEngine::new starts loaded).
313        // unload_all should park everything that's on GPU.
314        assert!(!unloaded.is_empty());
315        assert!(cache.active_model().is_none());
316        // All entries still in cache
317        assert_eq!(cache.len(), 2);
318    }
319
320    #[test]
321    fn cached_model_names_reflects_lru_order() {
322        let mut cache = ModelCache::new(3);
323        cache.insert(Box::new(MockEngine::new("model-a")), 100);
324        cache.insert(Box::new(MockEngine::new("model-b")), 200);
325        cache.insert(Box::new(MockEngine::new("model-c")), 300);
326        // LRU order: a, b, c (a is oldest)
327        assert_eq!(
328            cache.cached_model_names(),
329            vec!["model-a", "model-b", "model-c"]
330        );
331        // Touch model-a, making it MRU
332        cache.get_mut("model-a");
333        assert_eq!(
334            cache.cached_model_names(),
335            vec!["model-b", "model-c", "model-a"]
336        );
337    }
338
339    #[test]
340    fn get_mut_nonexistent_returns_none() {
341        let mut cache = ModelCache::new(3);
342        assert!(cache.get_mut("nonexistent").is_none());
343    }
344
345    #[test]
346    fn remove_nonexistent_returns_none() {
347        let mut cache = ModelCache::new(3);
348        assert!(cache.remove("nonexistent").is_none());
349    }
350
351    #[test]
352    fn unload_active_when_empty_returns_none() {
353        let mut cache = ModelCache::new(3);
354        assert!(cache.unload_active().is_none());
355    }
356
357    #[test]
358    fn max_cached_clamped_to_at_least_one() {
359        let mut cache = ModelCache::new(0);
360        cache.insert(Box::new(MockEngine::new("model-a")), 100);
361        assert_eq!(cache.len(), 1);
362        // Should still allow at least 1 entry
363        assert!(cache.contains("model-a"));
364    }
365}