abinazebinoy commited on
Commit
4bb6afa
·
1 Parent(s): 80fb861

Add model caching infrastructure (#35)

Browse files

Implemented singleton model cache with LRU eviction:

1. backend/core/model_cache.py:
- Singleton pattern for global cache
- Thread-safe operations
- LRU eviction policy
- Memory limit enforcement
- Cache statistics tracking

2. backend/config/cache_config.py:
- Configurable cache limits
- Model size estimates
- Enable/disable toggle

3. backend/tests/test_model_cache.py:
- 7 comprehensive tests
- Tests singleton, LRU, memory limits
- Statistics validation

Features:
- Max 10 models cached
- Max 8GB memory usage
- Automatic LRU eviction
- Thread-safe access
- Real-time statistics

Part 1 of 2 - Next: Integrate with detectors

Part of #35

backend/config/__init__.py ADDED
File without changes
backend/config/cache_config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cache configuration for VeriFile-X model caching.
3
+ """
4
+ from typing import Dict, Any
5
+
6
+
7
+ class CacheConfig:
8
+ """Model cache configuration."""
9
+
10
+ # Maximum number of models to cache
11
+ MAX_MODELS = 10
12
+
13
+ # Maximum memory usage in MB
14
+ MAX_MEMORY_MB = 8000 # 8GB
15
+
16
+ # Enable/disable caching
17
+ ENABLE_CACHE = True
18
+
19
+ # Cache eviction policy
20
+ EVICTION_POLICY = "lru" # least-recently-used
21
+
22
+ # Model size estimates (in MB)
23
+ MODEL_SIZES = {
24
+ 'stable-diffusion-2-1': 4000,
25
+ 'clip-vit-b-32': 350,
26
+ }
27
+
28
+ @classmethod
29
+ def to_dict(cls) -> Dict[str, Any]:
30
+ """Convert config to dictionary."""
31
+ return {
32
+ 'max_models': cls.MAX_MODELS,
33
+ 'max_memory_mb': cls.MAX_MEMORY_MB,
34
+ 'enable_cache': cls.ENABLE_CACHE,
35
+ 'eviction_policy': cls.EVICTION_POLICY,
36
+ }
backend/core/model_cache.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Singleton model cache manager for VeriFile-X.
3
+
4
+ Provides thread-safe model caching with LRU eviction to improve
5
+ performance by avoiding repeated model loading.
6
+ """
7
+ import threading
8
+ from typing import Any, Dict, Optional, OrderedDict
9
+ from collections import OrderedDict as OrderedDictType
10
+ import time
11
+ from backend.core.logger import setup_logger
12
+
13
+ logger = setup_logger(__name__)
14
+
15
+
16
+ class ModelCache:
17
+ """Singleton model cache with LRU eviction and memory limits."""
18
+
19
+ _instance = None
20
+ _lock = threading.Lock()
21
+
22
+ def __new__(cls):
23
+ """Ensure only one instance exists (singleton pattern)."""
24
+ if cls._instance is None:
25
+ with cls._lock:
26
+ if cls._instance is None:
27
+ cls._instance = super().__new__(cls)
28
+ cls._instance._initialized = False
29
+ return cls._instance
30
+
31
+ def __init__(self):
32
+ """Initialize cache on first creation."""
33
+ if self._initialized:
34
+ return
35
+
36
+ self._cache: OrderedDictType[str, Dict[str, Any]] = OrderedDict()
37
+ self._lock = threading.Lock()
38
+ self._stats = {
39
+ 'hits': 0,
40
+ 'misses': 0,
41
+ 'evictions': 0,
42
+ 'total_memory_mb': 0.0
43
+ }
44
+
45
+ # Configuration (can be overridden)
46
+ self.max_models = 10
47
+ self.max_memory_mb = 8000 # 8GB
48
+ self.enable_cache = True
49
+
50
+ self._initialized = True
51
+ logger.info("ModelCache initialized")
52
+
53
+ def get(self, key: str) -> Optional[Any]:
54
+ """
55
+ Get model from cache.
56
+
57
+ Args:
58
+ key: Model identifier (e.g., 'stable-diffusion-2-1')
59
+
60
+ Returns:
61
+ Cached model or None if not found
62
+ """
63
+ if not self.enable_cache:
64
+ return None
65
+
66
+ with self._lock:
67
+ if key in self._cache:
68
+ # Move to end (most recently used)
69
+ self._cache.move_to_end(key)
70
+ self._stats['hits'] += 1
71
+
72
+ model_info = self._cache[key]
73
+ logger.debug(
74
+ f"Cache HIT: {key} "
75
+ f"(size: {model_info['size_mb']:.1f}MB, "
76
+ f"age: {time.time() - model_info['timestamp']:.1f}s)"
77
+ )
78
+ return model_info['model']
79
+ else:
80
+ self._stats['misses'] += 1
81
+ logger.debug(f"Cache MISS: {key}")
82
+ return None
83
+
84
+ def set(self, key: str, model: Any, size_mb: float):
85
+ """
86
+ Store model in cache.
87
+
88
+ Args:
89
+ key: Model identifier
90
+ model: Model object to cache
91
+ size_mb: Estimated memory size in MB
92
+ """
93
+ if not self.enable_cache:
94
+ return
95
+
96
+ with self._lock:
97
+ # Check if we need to evict
98
+ while (len(self._cache) >= self.max_models or
99
+ self._stats['total_memory_mb'] + size_mb > self.max_memory_mb):
100
+ if not self._cache:
101
+ logger.warning("Cannot cache: size exceeds max_memory_mb")
102
+ return
103
+ self._evict_lru()
104
+
105
+ # Store model
106
+ self._cache[key] = {
107
+ 'model': model,
108
+ 'size_mb': size_mb,
109
+ 'timestamp': time.time()
110
+ }
111
+ self._cache.move_to_end(key)
112
+ self._stats['total_memory_mb'] += size_mb
113
+
114
+ logger.info(
115
+ f"Cached model: {key} "
116
+ f"(size: {size_mb:.1f}MB, "
117
+ f"total: {self._stats['total_memory_mb']:.1f}MB)"
118
+ )
119
+
120
+ def _evict_lru(self):
121
+ """Evict least recently used model."""
122
+ if not self._cache:
123
+ return
124
+
125
+ # Remove first item (least recently used)
126
+ key, model_info = self._cache.popitem(last=False)
127
+ self._stats['total_memory_mb'] -= model_info['size_mb']
128
+ self._stats['evictions'] += 1
129
+
130
+ logger.info(
131
+ f"Evicted LRU model: {key} "
132
+ f"(freed: {model_info['size_mb']:.1f}MB)"
133
+ )
134
+
135
+ # Clean up model if it has cleanup method
136
+ if hasattr(model_info['model'], 'cleanup'):
137
+ try:
138
+ model_info['model'].cleanup()
139
+ except Exception as e:
140
+ logger.warning(f"Error cleaning up {key}: {e}")
141
+
142
+ def clear(self):
143
+ """Clear all cached models."""
144
+ with self._lock:
145
+ count = len(self._cache)
146
+ memory_freed = self._stats['total_memory_mb']
147
+
148
+ # Clean up all models
149
+ for key, model_info in self._cache.items():
150
+ if hasattr(model_info['model'], 'cleanup'):
151
+ try:
152
+ model_info['model'].cleanup()
153
+ except Exception as e:
154
+ logger.warning(f"Error cleaning up {key}: {e}")
155
+
156
+ self._cache.clear()
157
+ self._stats['total_memory_mb'] = 0.0
158
+
159
+ logger.info(
160
+ f"Cleared cache: {count} models, "
161
+ f"{memory_freed:.1f}MB freed"
162
+ )
163
+
164
+ def stats(self) -> Dict[str, Any]:
165
+ """
166
+ Get cache statistics.
167
+
168
+ Returns:
169
+ Dictionary with cache metrics
170
+ """
171
+ with self._lock:
172
+ total_requests = self._stats['hits'] + self._stats['misses']
173
+ hit_rate = (self._stats['hits'] / total_requests
174
+ if total_requests > 0 else 0.0)
175
+
176
+ return {
177
+ 'total_models': len(self._cache),
178
+ 'memory_mb': self._stats['total_memory_mb'],
179
+ 'max_memory_mb': self.max_memory_mb,
180
+ 'cache_hits': self._stats['hits'],
181
+ 'cache_misses': self._stats['misses'],
182
+ 'hit_rate': hit_rate,
183
+ 'evictions': self._stats['evictions'],
184
+ 'enabled': self.enable_cache,
185
+ 'models': list(self._cache.keys())
186
+ }
187
+
188
+ def reset_stats(self):
189
+ """Reset statistics counters."""
190
+ with self._lock:
191
+ self._stats['hits'] = 0
192
+ self._stats['misses'] = 0
193
+ self._stats['evictions'] = 0
194
+ logger.info("Cache statistics reset")
195
+
196
+
197
+ # Global cache instance
198
+ _cache = ModelCache()
199
+
200
+
201
+ def get_model_cache() -> ModelCache:
202
+ """Get the global model cache instance."""
203
+ return _cache
backend/tests/test_model_cache.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for model cache system.
3
+ """
4
+ import pytest
5
+ import time
6
+ from backend.core.model_cache import ModelCache, get_model_cache
7
+
8
+
9
+ def test_cache_singleton():
10
+ """Test that ModelCache is a singleton."""
11
+ cache1 = ModelCache()
12
+ cache2 = ModelCache()
13
+ cache3 = get_model_cache()
14
+
15
+ assert cache1 is cache2
16
+ assert cache2 is cache3
17
+
18
+ # All should reference the same instance
19
+ cache1.set('test', 'value', 1.0)
20
+ assert cache2.get('test') == 'value'
21
+ assert cache3.get('test') == 'value'
22
+
23
+
24
+ def test_cache_basic_operations():
25
+ """Test basic cache get/set operations."""
26
+ cache = ModelCache()
27
+ cache.clear()
28
+
29
+ # Cache miss
30
+ assert cache.get('model1') is None
31
+
32
+ # Cache set
33
+ cache.set('model1', {'data': 'test'}, 100.0)
34
+
35
+ # Cache hit
36
+ result = cache.get('model1')
37
+ assert result == {'data': 'test'}
38
+
39
+ # Statistics
40
+ stats = cache.stats()
41
+ assert stats['total_models'] == 1
42
+ assert stats['memory_mb'] == 100.0
43
+ assert stats['cache_hits'] >= 1
44
+ assert stats['cache_misses'] >= 1
45
+
46
+
47
+ def test_cache_lru_eviction():
48
+ """Test LRU eviction when cache is full."""
49
+ cache = ModelCache()
50
+ cache.clear()
51
+ cache.max_models = 3 # Small limit for testing
52
+
53
+ # Add 3 models
54
+ cache.set('model1', 'data1', 100.0)
55
+ cache.set('model2', 'data2', 100.0)
56
+ cache.set('model3', 'data3', 100.0)
57
+
58
+ assert cache.stats()['total_models'] == 3
59
+
60
+ # Access model1 to make it recently used
61
+ cache.get('model1')
62
+
63
+ # Add 4th model - should evict model2 (least recently used)
64
+ cache.set('model4', 'data4', 100.0)
65
+
66
+ assert cache.stats()['total_models'] == 3
67
+ assert cache.get('model1') is not None # Still there (recently used)
68
+ assert cache.get('model2') is None # Evicted
69
+ assert cache.get('model3') is not None # Still there
70
+ assert cache.get('model4') is not None # Newly added
71
+
72
+
73
+ def test_cache_memory_limit():
74
+ """Test memory limit enforcement."""
75
+ cache = ModelCache()
76
+ cache.clear()
77
+ cache.max_memory_mb = 500 # 500MB limit
78
+ cache.max_models = 10
79
+
80
+ # Add models totaling 300MB
81
+ cache.set('model1', 'data1', 100.0)
82
+ cache.set('model2', 'data2', 100.0)
83
+ cache.set('model3', 'data3', 100.0)
84
+
85
+ assert cache.stats()['memory_mb'] == 300.0
86
+
87
+ # Try to add 300MB more (total would be 600MB > 500MB limit)
88
+ cache.set('model4', 'data4', 300.0)
89
+
90
+ # Should have evicted models to stay under limit
91
+ stats = cache.stats()
92
+ assert stats['memory_mb'] <= 500.0
93
+ assert stats['evictions'] >= 1
94
+
95
+
96
+ def test_cache_clear():
97
+ """Test cache clearing."""
98
+ cache = ModelCache()
99
+
100
+ cache.set('model1', 'data1', 100.0)
101
+ cache.set('model2', 'data2', 100.0)
102
+
103
+ assert cache.stats()['total_models'] == 2
104
+
105
+ cache.clear()
106
+
107
+ stats = cache.stats()
108
+ assert stats['total_models'] == 0
109
+ assert stats['memory_mb'] == 0.0
110
+ assert cache.get('model1') is None
111
+ assert cache.get('model2') is None
112
+
113
+
114
+ def test_cache_statistics():
115
+ """Test cache statistics tracking."""
116
+ cache = ModelCache()
117
+ cache.clear()
118
+ cache.reset_stats()
119
+
120
+ # Generate some cache activity
121
+ cache.get('missing') # miss
122
+ cache.set('model1', 'data', 100.0)
123
+ cache.get('model1') # hit
124
+ cache.get('model1') # hit
125
+ cache.get('missing2') # miss
126
+
127
+ stats = cache.stats()
128
+
129
+ assert stats['cache_hits'] >= 2
130
+ assert stats['cache_misses'] >= 2
131
+ assert stats['hit_rate'] > 0
132
+ assert 'models' in stats
133
+ assert 'model1' in stats['models']
134
+
135
+
136
+ def test_cache_disabled():
137
+ """Test cache when disabled."""
138
+ cache = ModelCache()
139
+ cache.enable_cache = False
140
+
141
+ # Set should not cache
142
+ cache.set('model1', 'data1', 100.0)
143
+
144
+ # Get should return None
145
+ assert cache.get('model1') is None
146
+
147
+ # Re-enable
148
+ cache.enable_cache = True
149
+ cache.set('model2', 'data2', 100.0)
150
+ assert cache.get('model2') == 'data2'