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