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 runtime's [`Tiers`], 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//! Callers that hold only an arity id — the background lowering worker, the
18//! JIT worker's publish guard — carry a [`Weak<Tiers>`](std::sync::Weak)
19//! naming the runtime that asked, so no process-wide index of caches is
20//! needed to resolve them.
21//!
22//! ## Cold-entry eviction (Phase 10.7)
23//!
24//! Cached entries carry a coarse last-access timestamp, refreshed on every
25//! [`IrCache::get`] hit. [`Tiers::sweep`] — run from the stop-the-world
26//! reclaim pass once the background lowering worker is started — evicts
27//! entries idle longer than [`ir_cache_ttl_secs`]. The IR cache is
28//! deliberately *colder* than native code: eviction happens long after the
29//! last access, and only when GC pressure triggers a collection anyway.
30//! Entries whose arity has published native code or a queued compile are
31//! never evicted (the IR is the deoptimization fallback), and `Unsupported`
32//! markers are kept forever (they are tiny and prevent retry storms).
33//!
34//! [`Tiers`]: crate::tiered::tiers::Tiers
35//! [`Tiers::sweep`]: crate::tiered::tiers::Tiers::sweep
36//! [`GlobalEnv::ir_cache`]: crate::env::env::GlobalEnv::ir_cache
37
38use std::collections::HashMap;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, LazyLock, RwLock};
41use std::time::Instant;
42
43use cljrs_ir::IrFunction;
44
45// ── Cache entries ────────────────────────────────────────────────────────────
46
47/// State of an IR cache entry for one function arity.
48pub enum IrCacheEntry {
49 /// Lowering has not been attempted yet.
50 NotAttempted,
51 /// Lowering was attempted but failed (unsupported form); don't retry.
52 Unsupported,
53 /// Successfully lowered IR function.
54 Cached {
55 ir: Arc<IrFunction>,
56 /// Coarse seconds (see [`now_secs`]) of the last [`IrCache::get`] hit.
57 last_access: AtomicU64,
58 },
59}
60
61// ── Coarse clock ─────────────────────────────────────────────────────────────
62
63static PROCESS_EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
64
65/// Seconds since the process epoch — the coarse clock for last-access
66/// tracking. Monotonic and cheap (one `Instant::now` per call).
67pub fn now_secs() -> u64 {
68 PROCESS_EPOCH.elapsed().as_secs()
69}
70
71/// Idle time after which a cached IR entry becomes eligible for eviction.
72/// `CLJRS_IR_CACHE_TTL` (seconds) overrides the default of 600.
73pub fn ir_cache_ttl_secs() -> u64 {
74 std::env::var("CLJRS_IR_CACHE_TTL")
75 .ok()
76 .and_then(|s| s.parse::<u64>().ok())
77 .unwrap_or(600)
78}
79
80// ── The cache ────────────────────────────────────────────────────────────────
81
82/// One runtime's lowered-IR cache.
83pub struct IrCache {
84 entries: RwLock<HashMap<u64, IrCacheEntry>>,
85}
86
87impl IrCache {
88 /// Create an empty cache. One runtime's [`Tiers`](crate::tiered::tiers::Tiers)
89 /// owns exactly one.
90 pub fn new() -> Self {
91 Self {
92 entries: RwLock::new(HashMap::new()),
93 }
94 }
95
96 /// Look up a cached IR function by arity ID, refreshing its last-access
97 /// time. `None` if not cached or if lowering previously failed.
98 ///
99 /// This is the hot path — uses a read lock so concurrent callers don't
100 /// block (the access timestamp is a relaxed atomic store under it).
101 pub fn get(&self, id: u64) -> Option<Arc<IrFunction>> {
102 let guard = self.entries.read().unwrap();
103 match guard.get(&id) {
104 Some(IrCacheEntry::Cached { ir, last_access }) => {
105 last_access.store(now_secs(), Ordering::Relaxed);
106 Some(ir.clone())
107 }
108 _ => None,
109 }
110 }
111
112 /// Check if lowering should be attempted for this arity.
113 /// `true` if the entry is `NotAttempted` (or absent).
114 pub fn should_attempt(&self, id: u64) -> bool {
115 !self.entries.read().unwrap().contains_key(&id)
116 }
117
118 /// Store a successful IR compilation result.
119 pub fn store(&self, id: u64, ir: Arc<IrFunction>) {
120 self.entries.write().unwrap().insert(
121 id,
122 IrCacheEntry::Cached {
123 ir,
124 last_access: AtomicU64::new(now_secs()),
125 },
126 );
127 }
128
129 /// Mark an arity as unsupported (lowering failed; don't retry).
130 pub fn store_unsupported(&self, id: u64) {
131 self.entries
132 .write()
133 .unwrap()
134 .insert(id, IrCacheEntry::Unsupported);
135 }
136
137 /// Drop the cache entry for an arity entirely (back to `NotAttempted`), so
138 /// a later [`Self::should_attempt`] returns `true` and the arity can be
139 /// re-lowered.
140 ///
141 /// Used by cross-defn invalidation: a lowering that specialized against
142 /// another defn is stale once that defn is rebound.
143 pub fn invalidate(&self, id: u64) {
144 self.entries.write().unwrap().remove(&id);
145 }
146
147 /// Evict cached entries idle longer than `ttl_secs`, skipping any arity
148 /// `pinned` reports as still needed (published native code or an
149 /// in-flight compile: the IR is the deoptimization fallback).
150 ///
151 /// Returns the evicted arity ids so the caller can drop their JIT
152 /// bookkeeping too; see [`Tiers::sweep`](crate::tiered::tiers::Tiers::sweep).
153 pub fn sweep(&self, now: u64, ttl_secs: u64, pinned: impl Fn(u64) -> bool) -> Vec<u64> {
154 let mut evicted = Vec::new();
155 let mut guard = self.entries.write().unwrap();
156 guard.retain(|&id, entry| {
157 let IrCacheEntry::Cached { last_access, .. } = entry else {
158 return true;
159 };
160 let idle = now.saturating_sub(last_access.load(Ordering::Relaxed));
161 if idle <= ttl_secs {
162 return true;
163 }
164 if pinned(id) {
165 return true;
166 }
167 evicted.push(id);
168 false
169 });
170 evicted
171 }
172}
173
174impl Default for IrCache {
175 fn default() -> Self {
176 Self::new()
177 }
178}