cljrs_runtime/tiered/ir_cache.rs
1//! Thread-safe IR cache for compiled function arities.
2//!
3//! Each `CljxFnArity` is assigned a unique `ir_arity_id` at creation time.
4//! When a function is called, the cache is consulted:
5//! - `NotAttempted` → try lowering via the Clojure compiler
6//! - `Cached(ir)` → execute via IR interpreter
7//! - `Unsupported` → fall back to tree-walking (don't retry)
8//!
9//! The hot path (`get_cached`) uses `RwLock` so concurrent reads don't
10//! contend. Writes (store) are infrequent (only during lowering).
11//!
12//! ## Cold-entry eviction (Phase 10.7)
13//!
14//! Cached entries carry a coarse last-access timestamp, refreshed on every
15//! `get_cached` hit. [`sweep_idle`] — run from the stop-the-world reclaim
16//! pass once the background lowering worker is started — evicts entries idle
17//! longer than [`ir_cache_ttl_secs`]. The IR cache is deliberately *colder*
18//! than native code: eviction happens long after the last access, and only
19//! when GC pressure triggers a collection anyway. Entries whose arity has
20//! published native code or a queued compile are never evicted (the IR is the
21//! deoptimization fallback), and `Unsupported` markers are kept forever (they
22//! are tiny and prevent retry storms).
23
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, LazyLock, RwLock};
27use std::time::Instant;
28
29use cljrs_ir::IrFunction;
30
31// ── Cache entries ────────────────────────────────────────────────────────────
32
33/// State of an IR cache entry for one function arity.
34pub enum IrCacheEntry {
35 /// Lowering has not been attempted yet.
36 NotAttempted,
37 /// Lowering was attempted but failed (unsupported form); don't retry.
38 Unsupported,
39 /// Successfully lowered IR function.
40 Cached {
41 ir: Arc<IrFunction>,
42 /// Coarse seconds (see [`now_secs`]) of the last `get_cached` hit.
43 last_access: AtomicU64,
44 },
45}
46
47// ── Coarse clock ─────────────────────────────────────────────────────────────
48
49static PROCESS_EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
50
51/// Seconds since the process epoch — the coarse clock for last-access
52/// tracking. Monotonic and cheap (one `Instant::now` per call).
53pub fn now_secs() -> u64 {
54 PROCESS_EPOCH.elapsed().as_secs()
55}
56
57/// Idle time after which a cached IR entry becomes eligible for eviction.
58/// `CLJRS_IR_CACHE_TTL` (seconds) overrides the default of 600.
59pub fn ir_cache_ttl_secs() -> u64 {
60 std::env::var("CLJRS_IR_CACHE_TTL")
61 .ok()
62 .and_then(|s| s.parse::<u64>().ok())
63 .unwrap_or(600)
64}
65
66// ── Global cache ─────────────────────────────────────────────────────────────
67
68static IR_CACHE: RwLock<Option<HashMap<u64, IrCacheEntry>>> = RwLock::new(None);
69
70/// Serializes tests that publish cache entries with tests that run a
71/// synthetic far-future sweep over the process-global cache.
72#[cfg(test)]
73pub(crate) static SWEEP_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
74
75/// Look up a cached IR function by arity ID, refreshing its last-access time.
76/// Returns `None` if not cached or if lowering previously failed.
77/// Returns `Some(ir)` if cached.
78///
79/// This is the hot path — uses a read lock so concurrent callers don't block
80/// (the access timestamp is a relaxed atomic store under the read lock).
81pub fn get_cached(id: u64) -> Option<Arc<IrFunction>> {
82 let guard = IR_CACHE.read().unwrap();
83 let cache = guard.as_ref()?;
84 match cache.get(&id) {
85 Some(IrCacheEntry::Cached { ir, last_access }) => {
86 last_access.store(now_secs(), Ordering::Relaxed);
87 Some(ir.clone())
88 }
89 _ => None,
90 }
91}
92
93/// Check if lowering should be attempted for this arity.
94/// Returns `true` if the entry is `NotAttempted` (or absent).
95pub fn should_attempt(id: u64) -> bool {
96 let guard = IR_CACHE.read().unwrap();
97 match guard.as_ref() {
98 Some(cache) => !cache.contains_key(&id),
99 None => true,
100 }
101}
102
103/// Store a successful IR compilation result.
104pub fn store_cached(id: u64, ir: Arc<IrFunction>) {
105 let mut guard = IR_CACHE.write().unwrap();
106 let cache = guard.get_or_insert_with(HashMap::new);
107 cache.insert(
108 id,
109 IrCacheEntry::Cached {
110 ir,
111 last_access: AtomicU64::new(now_secs()),
112 },
113 );
114}
115
116/// Mark an arity as unsupported (lowering failed; don't retry).
117pub fn store_unsupported(id: u64) {
118 let mut guard = IR_CACHE.write().unwrap();
119 let cache = guard.get_or_insert_with(HashMap::new);
120 cache.insert(id, IrCacheEntry::Unsupported);
121}
122
123/// Drop the cache entry for an arity entirely (back to `NotAttempted`), so a
124/// later [`should_attempt`] returns `true` and the arity can be re-lowered.
125///
126/// Used by cross-defn invalidation: a lowering that specialized against
127/// another defn is stale once that defn is rebound.
128pub fn invalidate(id: u64) {
129 let mut guard = IR_CACHE.write().unwrap();
130 if let Some(cache) = guard.as_mut() {
131 cache.remove(&id);
132 }
133}
134
135// ── Cold-entry sweep (Phase 10.7) ────────────────────────────────────────────
136
137/// Evict cached IR entries idle longer than `ttl_secs`, returning the evicted
138/// arity ids. Intended to run at a stop-the-world safepoint (registered by
139/// the lowering worker), but safe at any time: in-flight Tier-1 frames hold
140/// their own `Arc<IrFunction>`, and OSR native frames are protected by the
141/// code cache's live-epoch scan.
142///
143/// Skips entries whose arity has published native code or a queued compile —
144/// their IR is the deoptimization fallback — and never touches `Unsupported`
145/// markers. For each evicted id the per-arity `JitEntry` is dropped (so the
146/// function can re-warm from zero) and any published OSR-entry code is staled
147/// for reclamation: it is only reachable from Tier-1 interpretation of the
148/// evicted IR, so it is equally cold.
149///
150/// Takes `now` as a parameter for testability; production callers pass
151/// [`now_secs`]`()`.
152///
153/// Note: `defn_registry` deliberately retains its own `Arc<IrFunction>`s —
154/// cross-defn inlining of an unchanged defn stays valid. The sweep targets
155/// only this dispatch cache.
156pub fn sweep_idle(now: u64, ttl_secs: u64) -> Vec<u64> {
157 let mut evicted = Vec::new();
158 let mut guard = IR_CACHE.write().unwrap();
159 let Some(cache) = guard.as_mut() else {
160 return evicted;
161 };
162 cache.retain(|&id, entry| {
163 let IrCacheEntry::Cached { last_access, .. } = entry else {
164 return true;
165 };
166 let idle = now.saturating_sub(last_access.load(Ordering::Relaxed));
167 if idle <= ttl_secs {
168 return true;
169 }
170 if crate::tiered::jit_state::get_native_fn(id).is_some()
171 || crate::tiered::jit_state::compile_queued(id)
172 {
173 return true;
174 }
175 evicted.push(id);
176 false
177 });
178 drop(guard);
179 for &id in &evicted {
180 crate::tiered::jit_state::evict_entry_if_cold(id);
181 crate::tiered::jit_state::stale_osr_code(id);
182 cljrs_logging::feat_debug!("ir", "evicted idle IR arity_id={}", id);
183 }
184 evicted
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn dummy_ir() -> Arc<IrFunction> {
192 Arc::new(IrFunction::new(None, None))
193 }
194
195 // Sentinel arity ids (0xE5xx_xxxx range) so parallel tests sharing the
196 // global cache never collide; mirrors the jit_state test convention.
197 //
198 // The sweep itself is global, though: a far-future `sweep_idle` from one
199 // test would evict another test's entry mid-setup. Serialize every test
200 // that sweeps (or whose entries a sweep could evict) on this lock.
201 fn sweep_guard() -> std::sync::MutexGuard<'static, ()> {
202 SWEEP_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner())
203 }
204
205 #[test]
206 fn sweep_evicts_idle_entry_and_drops_jit_entry() {
207 let _g = sweep_guard();
208 let id = 0xE500_0001;
209 store_cached(id, dummy_ir());
210 crate::tiered::jit_state::mark_lower_queued(id);
211
212 // Recent entry survives a sweep.
213 let stored_at = now_secs();
214 assert!(sweep_idle(stored_at, 600).is_empty() || !should_attempt(id));
215 assert!(get_cached(id).is_some());
216
217 // Far in the future the entry is idle past the TTL and is evicted,
218 // along with its JitEntry (lower_queued resets so it can re-warm).
219 let evicted = sweep_idle(stored_at + 601, 600);
220 assert!(evicted.contains(&id));
221 assert!(get_cached(id).is_none());
222 assert!(should_attempt(id));
223 assert!(!crate::tiered::jit_state::lower_queued(id));
224 }
225
226 #[test]
227 fn sweep_skips_native_published_arity() {
228 let _g = sweep_guard();
229 let id = 0xE500_0002;
230 store_cached(id, dummy_ir());
231 crate::tiered::jit_state::store_native_fn(id, 0x1234usize as *const (), 31337);
232
233 let evicted = sweep_idle(now_secs() + 10_000, 600);
234 assert!(!evicted.contains(&id));
235 assert!(get_cached(id).is_some());
236
237 // Cleanup: unpublish so other tests' sweeps behave.
238 crate::tiered::jit_state::take_native_epoch(id);
239 invalidate(id);
240 }
241
242 #[test]
243 fn sweep_skips_queued_compile() {
244 let _g = sweep_guard();
245 let id = 0xE500_0003;
246 let ir = dummy_ir();
247 store_cached(id, ir.clone());
248 // Cross the JIT threshold; with no enqueue hook installed this just
249 // pins compile_queued, exactly the state of an in-flight compile.
250 for _ in 0..crate::tiered::jit_state::jit_threshold() {
251 crate::tiered::jit_state::record_call(id, ir.clone(), &[]);
252 }
253 assert!(crate::tiered::jit_state::compile_queued(id));
254
255 let evicted = sweep_idle(now_secs() + 10_000, 600);
256 assert!(!evicted.contains(&id));
257 assert!(get_cached(id).is_some());
258 invalidate(id);
259 }
260
261 #[test]
262 fn sweep_never_touches_unsupported() {
263 let _g = sweep_guard();
264 let id = 0xE500_0004;
265 store_unsupported(id);
266 let evicted = sweep_idle(now_secs() + 10_000, 600);
267 assert!(!evicted.contains(&id));
268 // Still terminal: no re-lowering attempts.
269 assert!(!should_attempt(id));
270 }
271
272 #[test]
273 fn get_cached_refreshes_last_access() {
274 let _g = sweep_guard();
275 let id = 0xE500_0005;
276 store_cached(id, dummy_ir());
277 // Touch, then sweep with a now that is idle relative to the store
278 // time but not the touch time recorded by get_cached: by refreshing
279 // on access the entry must survive a sweep whose `now` is within the
280 // TTL of the touch.
281 let _ = get_cached(id);
282 let touched_at = now_secs();
283 let evicted = sweep_idle(touched_at + 599, 600);
284 assert!(!evicted.contains(&id));
285 assert!(get_cached(id).is_some());
286 invalidate(id);
287 }
288}