/home/noah/src/ruchy/src/runtime/cache.rs
Line | Count | Source |
1 | | //! Bytecode and compilation caching for improved REPL performance |
2 | | //! |
3 | | //! This module provides caching mechanisms to avoid re-parsing and |
4 | | //! re-compiling expressions that have been seen before. |
5 | | |
6 | | use std::cell::RefCell; |
7 | | use std::collections::HashMap; |
8 | | use std::hash::{Hash, Hasher}; |
9 | | use std::rc::Rc; |
10 | | |
11 | | use crate::frontend::ast::Expr; |
12 | | |
13 | | /// A cache key that represents source code |
14 | | #[derive(Clone, Debug, Eq)] |
15 | | pub struct CacheKey { |
16 | | /// The source code |
17 | | source: String, |
18 | | /// Hash of the source for fast comparison |
19 | | hash: u64, |
20 | | } |
21 | | |
22 | | impl CacheKey { |
23 | | /// Create a new cache key from source code |
24 | 25 | pub fn new(source: String) -> Self { |
25 | 25 | let hash = { |
26 | 25 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); |
27 | 25 | source.hash(&mut hasher); |
28 | 25 | hasher.finish() |
29 | | }; |
30 | 25 | CacheKey { source, hash } |
31 | 25 | } |
32 | | } |
33 | | |
34 | | impl PartialEq for CacheKey { |
35 | 23 | fn eq(&self, other: &Self) -> bool { |
36 | 23 | self.hash == other.hash && self.source == other.source18 |
37 | 23 | } |
38 | | } |
39 | | |
40 | | impl Hash for CacheKey { |
41 | 22 | fn hash<H: Hasher>(&self, state: &mut H) { |
42 | 22 | self.hash.hash(state); |
43 | 22 | } |
44 | | } |
45 | | |
46 | | /// Cached compilation result |
47 | | #[derive(Clone)] |
48 | | pub struct CachedResult { |
49 | | /// The parsed AST |
50 | | pub ast: Rc<Expr>, |
51 | | /// The transpiled Rust code (if applicable) |
52 | | pub rust_code: Option<String>, |
53 | | /// Timestamp when cached |
54 | | pub timestamp: std::time::Instant, |
55 | | } |
56 | | |
57 | | /// Bytecode cache for REPL expressions |
58 | | pub struct BytecodeCache { |
59 | | /// Cache storage |
60 | | cache: RefCell<HashMap<CacheKey, CachedResult>>, |
61 | | /// Maximum cache size (number of entries) |
62 | | max_size: usize, |
63 | | /// Track access order for LRU eviction |
64 | | access_order: RefCell<Vec<CacheKey>>, |
65 | | /// Statistics |
66 | | hits: RefCell<usize>, |
67 | | misses: RefCell<usize>, |
68 | | } |
69 | | |
70 | | impl BytecodeCache { |
71 | | /// Create a new bytecode cache with specified max size |
72 | 4 | pub fn with_capacity(max_size: usize) -> Self { |
73 | 4 | BytecodeCache { |
74 | 4 | cache: RefCell::new(HashMap::with_capacity(max_size)), |
75 | 4 | max_size, |
76 | 4 | access_order: RefCell::new(Vec::with_capacity(max_size)), |
77 | 4 | hits: RefCell::new(0), |
78 | 4 | misses: RefCell::new(0), |
79 | 4 | } |
80 | 4 | } |
81 | | |
82 | | /// Create a default cache with 1000 entry capacity |
83 | 1 | pub fn new() -> Self { |
84 | 1 | Self::with_capacity(1000) |
85 | 1 | } |
86 | | |
87 | | /// Get a cached result if available |
88 | 11 | pub fn get(&self, source: &str) -> Option<CachedResult> { |
89 | 11 | let key = CacheKey::new(source.to_string()); |
90 | | |
91 | 11 | if let Some(result8 ) = self.cache.borrow().get(&key) { |
92 | 8 | *self.hits.borrow_mut() += 1; |
93 | | |
94 | | // Update access order for LRU |
95 | 8 | let mut access = self.access_order.borrow_mut(); |
96 | 11 | if let Some(pos8 ) = access.iter()8 .position8 (|k| k == &key) { |
97 | 8 | access.remove(pos); |
98 | 8 | }0 |
99 | 8 | access.push(key.clone()); |
100 | | |
101 | 8 | Some(result.clone()) |
102 | | } else { |
103 | 3 | *self.misses.borrow_mut() += 1; |
104 | 3 | None |
105 | | } |
106 | 11 | } |
107 | | |
108 | | /// Store a compilation result in the cache |
109 | 11 | pub fn insert(&self, source: String, ast: Rc<Expr>, rust_code: Option<String>) { |
110 | 11 | let key = CacheKey::new(source); |
111 | | |
112 | | // Check if we need to evict |
113 | 11 | if self.cache.borrow().len() >= self.max_size { |
114 | 1 | self.evict_lru(); |
115 | 10 | } |
116 | | |
117 | 11 | let result = CachedResult { |
118 | 11 | ast, |
119 | 11 | rust_code, |
120 | 11 | timestamp: std::time::Instant::now(), |
121 | 11 | }; |
122 | | |
123 | 11 | self.cache.borrow_mut().insert(key.clone(), result); |
124 | 11 | self.access_order.borrow_mut().push(key); |
125 | 11 | } |
126 | | |
127 | | /// Evict least recently used entry |
128 | 1 | fn evict_lru(&self) { |
129 | 1 | let mut access = self.access_order.borrow_mut(); |
130 | 1 | if !access.is_empty() { |
131 | 1 | let lru_key = access.remove(0); |
132 | 1 | self.cache.borrow_mut().remove(&lru_key); |
133 | 1 | }0 |
134 | 1 | } |
135 | | |
136 | | /// Clear the entire cache |
137 | 0 | pub fn clear(&self) { |
138 | 0 | self.cache.borrow_mut().clear(); |
139 | 0 | self.access_order.borrow_mut().clear(); |
140 | 0 | *self.hits.borrow_mut() = 0; |
141 | 0 | *self.misses.borrow_mut() = 0; |
142 | 0 | } |
143 | | |
144 | | /// Get cache statistics |
145 | 3 | pub fn stats(&self) -> CacheStats { |
146 | 3 | CacheStats { |
147 | 3 | size: self.cache.borrow().len(), |
148 | 3 | capacity: self.max_size, |
149 | 3 | hits: *self.hits.borrow(), |
150 | 3 | misses: *self.misses.borrow(), |
151 | 3 | hit_rate: self.calculate_hit_rate(), |
152 | 3 | } |
153 | 3 | } |
154 | | |
155 | | /// Calculate hit rate as a percentage |
156 | | #[allow(clippy::cast_precision_loss)] |
157 | 3 | fn calculate_hit_rate(&self) -> f64 { |
158 | 3 | let hits = *self.hits.borrow() as f64; |
159 | 3 | let total = hits + *self.misses.borrow() as f64; |
160 | 3 | if total > 0.0 { |
161 | 3 | (hits / total) * 100.0 |
162 | | } else { |
163 | 0 | 0.0 |
164 | | } |
165 | 3 | } |
166 | | |
167 | | /// Remove entries older than specified duration |
168 | 0 | pub fn evict_older_than(&self, age: std::time::Duration) { |
169 | 0 | let now = std::time::Instant::now(); |
170 | 0 | let mut cache = self.cache.borrow_mut(); |
171 | 0 | let mut access = self.access_order.borrow_mut(); |
172 | | |
173 | | // Find keys to remove |
174 | 0 | let keys_to_remove: Vec<CacheKey> = cache |
175 | 0 | .iter() |
176 | 0 | .filter(|(_, result)| now.duration_since(result.timestamp) > age) |
177 | 0 | .map(|(key, _)| key.clone()) |
178 | 0 | .collect(); |
179 | | |
180 | | // Remove from cache and access order |
181 | 0 | for key in keys_to_remove { |
182 | 0 | cache.remove(&key); |
183 | 0 | if let Some(pos) = access.iter().position(|k| k == &key) { |
184 | 0 | access.remove(pos); |
185 | 0 | } |
186 | | } |
187 | 0 | } |
188 | | } |
189 | | |
190 | | impl Default for BytecodeCache { |
191 | 0 | fn default() -> Self { |
192 | 0 | Self::new() |
193 | 0 | } |
194 | | } |
195 | | |
196 | | /// Cache statistics |
197 | | #[derive(Debug, Clone)] |
198 | | pub struct CacheStats { |
199 | | /// Current number of cached entries |
200 | | pub size: usize, |
201 | | /// Maximum capacity |
202 | | pub capacity: usize, |
203 | | /// Number of cache hits |
204 | | pub hits: usize, |
205 | | /// Number of cache misses |
206 | | pub misses: usize, |
207 | | /// Hit rate percentage |
208 | | pub hit_rate: f64, |
209 | | } |
210 | | |
211 | | impl std::fmt::Display for CacheStats { |
212 | 0 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
213 | 0 | write!( |
214 | 0 | f, |
215 | 0 | "Cache: {}/{} entries, {} hits, {} misses ({:.1}% hit rate)", |
216 | | self.size, self.capacity, self.hits, self.misses, self.hit_rate |
217 | | ) |
218 | 0 | } |
219 | | } |
220 | | |
221 | | /// Expression cache for parsed ASTs |
222 | | pub struct ExpressionCache { |
223 | | inner: BytecodeCache, |
224 | | } |
225 | | |
226 | | impl ExpressionCache { |
227 | | /// Create a new expression cache |
228 | 1 | pub fn new() -> Self { |
229 | 1 | ExpressionCache { |
230 | 1 | inner: BytecodeCache::new(), |
231 | 1 | } |
232 | 1 | } |
233 | | |
234 | | /// Try to get a parsed expression from cache |
235 | 1 | pub fn get_parsed(&self, source: &str) -> Option<Rc<Expr>> { |
236 | 1 | self.inner.get(source).map(|result| result.ast) |
237 | 1 | } |
238 | | |
239 | | /// Cache a parsed expression |
240 | 1 | pub fn cache_parsed(&self, source: String, ast: Rc<Expr>) { |
241 | 1 | self.inner.insert(source, ast, None); |
242 | 1 | } |
243 | | |
244 | | /// Try to get transpiled code from cache |
245 | 1 | pub fn get_transpiled(&self, source: &str) -> Option<String> { |
246 | 1 | self.inner.get(source).and_then(|result| result.rust_code) |
247 | 1 | } |
248 | | |
249 | | /// Cache transpiled code |
250 | 1 | pub fn cache_transpiled(&self, source: String, ast: Rc<Expr>, rust_code: String) { |
251 | 1 | self.inner.insert(source, ast, Some(rust_code)); |
252 | 1 | } |
253 | | |
254 | | /// Get cache statistics |
255 | 0 | pub fn stats(&self) -> CacheStats { |
256 | 0 | self.inner.stats() |
257 | 0 | } |
258 | | |
259 | | /// Clear the cache |
260 | 0 | pub fn clear(&self) { |
261 | 0 | self.inner.clear(); |
262 | 0 | } |
263 | | } |
264 | | |
265 | | impl Default for ExpressionCache { |
266 | 0 | fn default() -> Self { |
267 | 0 | Self::new() |
268 | 0 | } |
269 | | } |
270 | | |
271 | | #[cfg(test)] |
272 | | #[allow(clippy::unwrap_used)] |
273 | | mod tests { |
274 | | use super::*; |
275 | | use crate::frontend::ast::{ExprKind, Literal, Span}; |
276 | | |
277 | 11 | fn make_test_expr(value: i64) -> Rc<Expr> { |
278 | 11 | Rc::new(Expr { |
279 | 11 | kind: ExprKind::Literal(Literal::Integer(value)), |
280 | 11 | span: Span { start: 0, end: 0 }, |
281 | 11 | attributes: Vec::new(), |
282 | 11 | }) |
283 | 11 | } |
284 | | |
285 | | #[test] |
286 | 1 | fn test_cache_key() { |
287 | 1 | let key1 = CacheKey::new("let x = 42".to_string()); |
288 | 1 | let key2 = CacheKey::new("let x = 42".to_string()); |
289 | 1 | let key3 = CacheKey::new("let y = 42".to_string()); |
290 | | |
291 | 1 | assert_eq!(key1, key2); |
292 | 1 | assert_ne!(key1, key3); |
293 | 1 | } |
294 | | |
295 | | #[test] |
296 | 1 | fn test_bytecode_cache_basic() { |
297 | 1 | let cache = BytecodeCache::with_capacity(3); |
298 | | |
299 | | // Cache miss |
300 | 1 | assert!(cache.get("let x = 1").is_none()); |
301 | 1 | assert_eq!(cache.stats().misses, 1); |
302 | | |
303 | | // Insert and hit |
304 | 1 | cache.insert("let x = 1".to_string(), make_test_expr(1), None); |
305 | 1 | assert!(cache.get("let x = 1").is_some()); |
306 | 1 | assert_eq!(cache.stats().hits, 1); |
307 | 1 | } |
308 | | |
309 | | #[test] |
310 | 1 | fn test_cache_lru_eviction() { |
311 | 1 | let cache = BytecodeCache::with_capacity(2); |
312 | | |
313 | 1 | cache.insert("expr1".to_string(), make_test_expr(1), None); |
314 | 1 | cache.insert("expr2".to_string(), make_test_expr(2), None); |
315 | | |
316 | | // Access expr1 to make it more recent |
317 | 1 | let _ = cache.get("expr1"); |
318 | | |
319 | | // This should evict expr2 (least recently used) |
320 | 1 | cache.insert("expr3".to_string(), make_test_expr(3), None); |
321 | | |
322 | 1 | assert!(cache.get("expr1").is_some()); |
323 | 1 | assert!(cache.get("expr2").is_none()); // Evicted |
324 | 1 | assert!(cache.get("expr3").is_some()); |
325 | 1 | } |
326 | | |
327 | | #[test] |
328 | 1 | fn test_expression_cache() { |
329 | 1 | let cache = ExpressionCache::new(); |
330 | | |
331 | 1 | let expr = make_test_expr(42); |
332 | 1 | cache.cache_parsed("let x = 42".to_string(), Rc::clone(&expr)); |
333 | | |
334 | 1 | let cached = cache.get_parsed("let x = 42").unwrap(); |
335 | 1 | assert!(Rc::ptr_eq(&expr, &cached)); |
336 | | |
337 | | // Test transpiled code caching |
338 | 1 | cache.cache_transpiled( |
339 | 1 | "let y = 10".to_string(), |
340 | 1 | make_test_expr(10), |
341 | 1 | "let y = 10;".to_string(), |
342 | | ); |
343 | | |
344 | 1 | assert_eq!( |
345 | 1 | cache.get_transpiled("let y = 10"), |
346 | 1 | Some("let y = 10;".to_string()) |
347 | | ); |
348 | 1 | } |
349 | | |
350 | | #[test] |
351 | 1 | fn test_cache_stats() { |
352 | 1 | let cache = BytecodeCache::with_capacity(10); |
353 | | |
354 | 6 | for i5 in 0..5 { |
355 | 5 | cache.insert(format!("expr{i}"), make_test_expr(i), None); |
356 | 5 | } |
357 | | |
358 | | // Generate some hits and misses |
359 | 1 | let _ = cache.get("expr1"); |
360 | 1 | let _ = cache.get("expr2"); |
361 | 1 | let _ = cache.get("expr_missing"); |
362 | | |
363 | 1 | let stats = cache.stats(); |
364 | 1 | assert_eq!(stats.size, 5); |
365 | 1 | assert_eq!(stats.capacity, 10); |
366 | 1 | assert_eq!(stats.hits, 2); |
367 | 1 | assert_eq!(stats.misses, 1); |
368 | 1 | assert!(stats.hit_rate > 60.0 && stats.hit_rate < 70.0); |
369 | 1 | } |
370 | | } |