Skip to main content

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}