cljrs_runtime/tiered/ir_cache.rs
1//! Per-runtime cache of lowered IR, keyed by arity ID.
2//!
3//! Each `CljxFnArity` is assigned a unique `ir_arity_id` at creation time.
4//! When a function is called, its runtime's cache is consulted:
5//! - `NotAttempted` → try lowering
6//! - `Cached(ir)` → execute via the IR interpreter
7//! - `Unsupported` → fall back to tree-walking (don't retry)
8//!
9//! The hot path ([`IrCache::get`]) uses `RwLock` so concurrent reads don't
10//! contend. Writes (store) are infrequent (only during lowering).
11//!
12//! ## Ownership
13//!
14//! An [`IrCache`] belongs to one [`GlobalEnv`], reached through
15//! [`GlobalEnv::ir_cache`]: two runtimes in one process never read or evict
16//! each other's entries, and a runtime's IR is freed when the runtime is.
17//!
18//! Some callers only hold an arity id, with no route back to the runtime that
19//! minted it — the background lowering worker, the JIT worker's publish
20//! guard, and the process-global var-rebind hook. For those, this module
21//! keeps a weak index of live caches ([`LIVE`]) and exposes free functions
22//! that resolve through it. Arity ids come from one process-wide counter, so
23//! at most one live cache can hold a given id and the lookup is unambiguous.
24//! Stage 4 replaces the JIT half of this with compiler state owned by the
25//! runtime; the index goes away with it.
26//!
27//! ## Cold-entry eviction (Phase 10.7)
28//!
29//! Cached entries carry a coarse last-access timestamp, refreshed on every
30//! [`IrCache::get`] hit. [`sweep_idle`] — run from the stop-the-world reclaim
31//! pass once the background lowering worker is started — evicts entries idle
32//! longer than [`ir_cache_ttl_secs`]. The IR cache is deliberately *colder*
33//! than native code: eviction happens long after the last access, and only
34//! when GC pressure triggers a collection anyway. Entries whose arity has
35//! published native code or a queued compile are never evicted (the IR is the
36//! deoptimization fallback), and `Unsupported` markers are kept forever (they
37//! are tiny and prevent retry storms).
38//!
39//! [`GlobalEnv`]: crate::env::env::GlobalEnv
40//! [`GlobalEnv::ir_cache`]: crate::env::env::GlobalEnv::ir_cache
41
42use std::collections::HashMap;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::{Arc, LazyLock, RwLock, Weak};
45use std::time::Instant;
46
47use cljrs_ir::IrFunction;
48
49// ── Cache entries ────────────────────────────────────────────────────────────
50
51/// State of an IR cache entry for one function arity.
52pub enum IrCacheEntry {
53 /// Lowering has not been attempted yet.
54 NotAttempted,
55 /// Lowering was attempted but failed (unsupported form); don't retry.
56 Unsupported,
57 /// Successfully lowered IR function.
58 Cached {
59 ir: Arc<IrFunction>,
60 /// Coarse seconds (see [`now_secs`]) of the last [`IrCache::get`] hit.
61 last_access: AtomicU64,
62 },
63}
64
65// ── Coarse clock ─────────────────────────────────────────────────────────────
66
67static PROCESS_EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
68
69/// Seconds since the process epoch — the coarse clock for last-access
70/// tracking. Monotonic and cheap (one `Instant::now` per call).
71pub fn now_secs() -> u64 {
72 PROCESS_EPOCH.elapsed().as_secs()
73}
74
75/// Idle time after which a cached IR entry becomes eligible for eviction.
76/// `CLJRS_IR_CACHE_TTL` (seconds) overrides the default of 600.
77pub fn ir_cache_ttl_secs() -> u64 {
78 std::env::var("CLJRS_IR_CACHE_TTL")
79 .ok()
80 .and_then(|s| s.parse::<u64>().ok())
81 .unwrap_or(600)
82}
83
84// ── The cache ────────────────────────────────────────────────────────────────
85
86/// One runtime's lowered-IR cache.
87pub struct IrCache {
88 entries: RwLock<HashMap<u64, IrCacheEntry>>,
89}
90
91impl IrCache {
92 /// Create a cache owned by the runtime with identity `globals_id`, and
93 /// index it so arity-id-only callers can find it.
94 pub fn new(globals_id: u64) -> Arc<Self> {
95 let cache = Arc::new(Self {
96 entries: RwLock::new(HashMap::new()),
97 });
98 let mut live = LIVE.write().unwrap();
99 live.retain(|(_, weak)| weak.strong_count() > 0);
100 live.push((globals_id, Arc::downgrade(&cache)));
101 cache
102 }
103
104 /// Look up a cached IR function by arity ID, refreshing its last-access
105 /// time. `None` if not cached or if lowering previously failed.
106 ///
107 /// This is the hot path — uses a read lock so concurrent callers don't
108 /// block (the access timestamp is a relaxed atomic store under it).
109 pub fn get(&self, id: u64) -> Option<Arc<IrFunction>> {
110 let guard = self.entries.read().unwrap();
111 match guard.get(&id) {
112 Some(IrCacheEntry::Cached { ir, last_access }) => {
113 last_access.store(now_secs(), Ordering::Relaxed);
114 Some(ir.clone())
115 }
116 _ => None,
117 }
118 }
119
120 /// Check if lowering should be attempted for this arity.
121 /// `true` if the entry is `NotAttempted` (or absent).
122 pub fn should_attempt(&self, id: u64) -> bool {
123 !self.entries.read().unwrap().contains_key(&id)
124 }
125
126 /// Store a successful IR compilation result.
127 pub fn store(&self, id: u64, ir: Arc<IrFunction>) {
128 self.entries.write().unwrap().insert(
129 id,
130 IrCacheEntry::Cached {
131 ir,
132 last_access: AtomicU64::new(now_secs()),
133 },
134 );
135 }
136
137 /// Mark an arity as unsupported (lowering failed; don't retry).
138 pub fn store_unsupported(&self, id: u64) {
139 self.entries
140 .write()
141 .unwrap()
142 .insert(id, IrCacheEntry::Unsupported);
143 }
144
145 /// Drop the cache entry for an arity entirely (back to `NotAttempted`), so
146 /// a later [`Self::should_attempt`] returns `true` and the arity can be
147 /// re-lowered.
148 ///
149 /// Used by cross-defn invalidation: a lowering that specialized against
150 /// another defn is stale once that defn is rebound.
151 pub fn invalidate(&self, id: u64) {
152 self.entries.write().unwrap().remove(&id);
153 }
154
155 /// Evict cached entries idle longer than `ttl_secs`; see [`sweep_idle`].
156 pub fn sweep(&self, now: u64, ttl_secs: u64) -> Vec<u64> {
157 let mut evicted = Vec::new();
158 let mut guard = self.entries.write().unwrap();
159 guard.retain(|&id, entry| {
160 let IrCacheEntry::Cached { last_access, .. } = entry else {
161 return true;
162 };
163 let idle = now.saturating_sub(last_access.load(Ordering::Relaxed));
164 if idle <= ttl_secs {
165 return true;
166 }
167 if crate::tiered::jit_state::get_native_fn(id).is_some()
168 || crate::tiered::jit_state::compile_queued(id)
169 {
170 return true;
171 }
172 evicted.push(id);
173 false
174 });
175 drop(guard);
176 for &id in &evicted {
177 crate::tiered::jit_state::evict_entry_if_cold(id);
178 crate::tiered::jit_state::stale_osr_code(id);
179 cljrs_logging::feat_debug!("ir", "evicted idle IR arity_id={}", id);
180 }
181 evicted
182 }
183}
184
185// ── Index of live caches ─────────────────────────────────────────────────────
186
187/// Weak index of every live [`IrCache`], with the id of the runtime that owns
188/// it. See the module docs for why the arity-id-only callers need it.
189#[allow(clippy::type_complexity)]
190static LIVE: LazyLock<RwLock<Vec<(u64, Weak<IrCache>)>>> =
191 LazyLock::new(|| RwLock::new(Vec::new()));
192
193/// Every cache that is still alive.
194fn live_caches() -> Vec<Arc<IrCache>> {
195 LIVE.read()
196 .unwrap()
197 .iter()
198 .filter_map(|(_, weak)| weak.upgrade())
199 .collect()
200}
201
202/// The cache belonging to the runtime with this identity, if it is still
203/// alive. The background lowering worker uses it: a request carries the id of
204/// the runtime that enqueued it, and the runtime may have been dropped since.
205pub fn by_globals_id(globals_id: u64) -> Option<Arc<IrCache>> {
206 LIVE.read()
207 .unwrap()
208 .iter()
209 .find(|(id, _)| *id == globals_id)
210 .and_then(|(_, weak)| weak.upgrade())
211}
212
213/// Look up cached IR by arity id across every live runtime.
214///
215/// For callers holding only an arity id (the JIT worker's publish guard).
216/// Prefer [`IrCache::get`] whenever the runtime is in hand.
217pub fn get_cached(id: u64) -> Option<Arc<IrFunction>> {
218 live_caches().into_iter().find_map(|cache| cache.get(id))
219}
220
221/// Whether lowering should be attempted for `id` in whichever live runtime
222/// owns it. `true` when no live cache has an entry.
223pub fn should_attempt(id: u64) -> bool {
224 live_caches().iter().all(|cache| cache.should_attempt(id))
225}
226
227/// Drop `id`'s entry wherever it lives.
228///
229/// Used by the var-rebind hook, which `cljrs-value` invokes process-globally
230/// with no runtime handle.
231pub fn invalidate(id: u64) {
232 for cache in live_caches() {
233 cache.invalidate(id);
234 }
235}
236
237/// Evict entries idle longer than `ttl_secs` from every live runtime.
238///
239/// Intended to run at a stop-the-world safepoint (registered by the lowering
240/// worker), but safe at any time: in-flight Tier-1 frames hold their own
241/// `Arc<IrFunction>`, and OSR native frames are protected by the code cache's
242/// live-epoch scan.
243///
244/// Takes `now` as a parameter for testability; production callers pass
245/// [`now_secs`]`()`.
246///
247/// Note: `defn_registry` deliberately retains its own `Arc<IrFunction>`s —
248/// cross-defn inlining of an unchanged defn stays valid. The sweep targets
249/// only this dispatch cache.
250pub fn sweep_idle(now: u64, ttl_secs: u64) -> Vec<u64> {
251 let mut evicted = Vec::new();
252 for cache in live_caches() {
253 evicted.extend(cache.sweep(now, ttl_secs));
254 }
255 evicted
256}
257
258/// Serializes tests that publish cache entries with tests that run a
259/// synthetic far-future sweep over every live cache.
260#[cfg(test)]
261pub(crate) static SWEEP_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn dummy_ir() -> Arc<IrFunction> {
268 Arc::new(IrFunction::new(None, None))
269 }
270
271 /// A cache held for the test's duration, so the weak index keeps it.
272 fn test_cache() -> Arc<IrCache> {
273 IrCache::new(u64::MAX)
274 }
275
276 // Sentinel arity ids (0xE5xx_xxxx range) so parallel tests sharing the
277 // live-cache index never collide; mirrors the jit_state test convention.
278 //
279 // The sweep is index-wide, though: a far-future `sweep_idle` from one
280 // test would evict another test's entry mid-setup. Serialize every test
281 // that sweeps (or whose entries a sweep could evict) on this lock.
282 fn sweep_guard() -> std::sync::MutexGuard<'static, ()> {
283 SWEEP_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner())
284 }
285
286 #[test]
287 fn sweep_evicts_idle_entry_and_drops_jit_entry() {
288 let _g = sweep_guard();
289 let cache = test_cache();
290 let id = 0xE500_0001;
291 cache.store(id, dummy_ir());
292 crate::tiered::jit_state::mark_lower_queued(id);
293
294 // Recent entry survives a sweep.
295 let stored_at = now_secs();
296 assert!(sweep_idle(stored_at, 600).is_empty() || !cache.should_attempt(id));
297 assert!(cache.get(id).is_some());
298
299 // Far in the future the entry is idle past the TTL and is evicted,
300 // along with its JitEntry (lower_queued resets so it can re-warm).
301 let evicted = sweep_idle(stored_at + 601, 600);
302 assert!(evicted.contains(&id));
303 assert!(cache.get(id).is_none());
304 assert!(cache.should_attempt(id));
305 assert!(!crate::tiered::jit_state::lower_queued(id));
306 }
307
308 #[test]
309 fn sweep_skips_native_published_arity() {
310 let _g = sweep_guard();
311 let cache = test_cache();
312 let id = 0xE500_0002;
313 cache.store(id, dummy_ir());
314 crate::tiered::jit_state::store_native_fn(id, 0x1234usize as *const (), 31337);
315
316 let evicted = sweep_idle(now_secs() + 10_000, 600);
317 assert!(!evicted.contains(&id));
318 assert!(cache.get(id).is_some());
319
320 // Cleanup: unpublish so other tests' sweeps behave.
321 crate::tiered::jit_state::take_native_epoch(id);
322 cache.invalidate(id);
323 }
324
325 #[test]
326 fn sweep_skips_queued_compile() {
327 let _g = sweep_guard();
328 let cache = test_cache();
329 let id = 0xE500_0003;
330 let ir = dummy_ir();
331 cache.store(id, ir.clone());
332 // Cross the JIT threshold; with no enqueue hook installed this just
333 // pins compile_queued, exactly the state of an in-flight compile.
334 for _ in 0..crate::tiered::jit_state::jit_threshold() {
335 crate::tiered::jit_state::record_call(id, ir.clone(), &[]);
336 }
337 assert!(crate::tiered::jit_state::compile_queued(id));
338
339 let evicted = sweep_idle(now_secs() + 10_000, 600);
340 assert!(!evicted.contains(&id));
341 assert!(cache.get(id).is_some());
342 cache.invalidate(id);
343 }
344
345 #[test]
346 fn sweep_never_touches_unsupported() {
347 let _g = sweep_guard();
348 let cache = test_cache();
349 let id = 0xE500_0004;
350 cache.store_unsupported(id);
351 let evicted = sweep_idle(now_secs() + 10_000, 600);
352 assert!(!evicted.contains(&id));
353 // Still terminal: no re-lowering attempts.
354 assert!(!cache.should_attempt(id));
355 }
356
357 #[test]
358 fn get_refreshes_last_access() {
359 let _g = sweep_guard();
360 let cache = test_cache();
361 let id = 0xE500_0005;
362 cache.store(id, dummy_ir());
363 // Touch, then sweep with a now that is idle relative to the store
364 // time but not the touch time recorded by `get`: by refreshing on
365 // access the entry must survive a sweep whose `now` is within the
366 // TTL of the touch.
367 let _ = cache.get(id);
368 let touched_at = now_secs();
369 let evicted = sweep_idle(touched_at + 599, 600);
370 assert!(!evicted.contains(&id));
371 assert!(cache.get(id).is_some());
372 cache.invalidate(id);
373 }
374
375 /// Two runtimes' caches are independent: neither sees the other's entries,
376 /// and dropping one leaves the other intact.
377 #[test]
378 fn caches_are_per_runtime() {
379 let _g = sweep_guard();
380 let a = IrCache::new(0xE5AA_0001);
381 let b = IrCache::new(0xE5AA_0002);
382 let id = 0xE500_0006;
383
384 a.store(id, dummy_ir());
385 assert!(a.get(id).is_some());
386 assert!(b.get(id).is_none(), "b must not see a's entry");
387 assert!(b.should_attempt(id));
388
389 // Resolving by runtime identity picks the right one.
390 assert!(by_globals_id(0xE5AA_0001).unwrap().get(id).is_some());
391 assert!(by_globals_id(0xE5AA_0002).unwrap().get(id).is_none());
392
393 // The whole-process lookup finds it while `a` lives, and stops
394 // finding it once `a` is dropped.
395 assert!(get_cached(id).is_some());
396 drop(a);
397 assert!(get_cached(id).is_none());
398 assert!(by_globals_id(0xE5AA_0001).is_none());
399 drop(b);
400 }
401}