Skip to main content

cljrs_runtime/tiered/
jit_state.rs

1//! JIT invocation counters and native function pointer cache.
2//!
3//! Bridges the Tier-1 IR interpreter and the background JIT compiler
4//! ([`JitBackend`]). Each JIT-eligible arity has one [`JitEntry`] keyed by
5//! `ir_arity_id`. The flow is:
6//!
7//! 1. [`JitState::record_call`] bumps the invocation counter; crossing the
8//!    threshold enqueues a compile on the runtime's backend.
9//! 2. The background worker compiles the function and calls
10//!    [`JitState::store_native_fn`] on the runtime that asked.
11//! 3. [`JitState::get_native_fn`] returns the pointer; `call_cljrs_fn` calls it.
12//! 4. [`dispatch_jit_call`] transmutes the raw pointer to the correct arity
13//!    and invokes the native code.
14//!
15//! ## What is per-runtime and what is not
16//!
17//! Every *table* here belongs to one runtime, reached through
18//! [`GlobalEnv::jit`](crate::env::env::GlobalEnv::jit): counters, argument
19//! profiles, published pointers, OSR entries, the specialization ban list,
20//! and the bootstrap watermark.  Two runtimes in one process never promote,
21//! deoptimize, or invalidate each other's code.
22//!
23//! Three things are deliberately process-wide, because they describe the
24//! process rather than a runtime:
25//!
26//! - **Thresholds** — configuration, set once from CLI flags or the
27//!   environment before any runtime is built.
28//! - **Active native frames** ([`push_jit_frame`], [`live_epochs`]) — a
29//!   *thread* can have frames from several runtimes on its stack, and code
30//!   reclamation asks "is any thread executing this module?".
31//! - **[`dispatch_jit_call`]** — a pure transmute-and-call.
32
33use std::collections::{HashMap, HashSet};
34use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, Ordering};
35use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
36
37use cljrs_ir::IrFunction;
38use cljrs_value::Value;
39
40use crate::tiered::backend::JitBackend;
41use crate::tiered::tiers::Tiers;
42
43// ── Thresholds ────────────────────────────────────────────────────────────────
44
45pub const DEFAULT_JIT_THRESHOLD: u32 = 1_000;
46
47/// Tier-0 invocation count at which a function is lowered to IR in the
48/// background (Phase 10.7).  Deliberately far below [`DEFAULT_JIT_THRESHOLD`]:
49/// tree-walking is slow, so warm functions should reach the IR interpreter
50/// quickly, while one-shot top-level code never pays for lowering.
51pub const DEFAULT_IR_THRESHOLD: u32 = 50;
52
53/// Per-process override set by the CLI or programmatic callers.
54/// 0 means "read from CLJRS_IR_THRESHOLD env var or use the default".
55/// `u32::MAX` disables background lowering entirely.
56static IR_THRESHOLD_OVERRIDE: AtomicU32 = AtomicU32::new(0);
57
58pub fn set_ir_threshold(t: u32) {
59    IR_THRESHOLD_OVERRIDE.store(t, Ordering::Relaxed);
60}
61
62pub fn ir_threshold() -> u32 {
63    let v = IR_THRESHOLD_OVERRIDE.load(Ordering::Relaxed);
64    if v != 0 {
65        return v;
66    }
67    match std::env::var("CLJRS_IR_THRESHOLD")
68        .ok()
69        .and_then(|s| s.parse::<u32>().ok())
70    {
71        // Like `--ir-threshold 0`: disable background lowering entirely.
72        Some(0) => u32::MAX,
73        Some(t) => t,
74        None => DEFAULT_IR_THRESHOLD,
75    }
76}
77
78/// Per-process override set by the CLI or programmatic callers.
79/// 0 means "read from CLJRS_JIT_THRESHOLD env var or use the default".
80static JIT_THRESHOLD_OVERRIDE: AtomicU32 = AtomicU32::new(0);
81
82pub fn set_jit_threshold(t: u32) {
83    JIT_THRESHOLD_OVERRIDE.store(t, Ordering::Relaxed);
84}
85
86pub fn jit_threshold() -> u32 {
87    let v = JIT_THRESHOLD_OVERRIDE.load(Ordering::Relaxed);
88    if v != 0 {
89        return v;
90    }
91    std::env::var("CLJRS_JIT_THRESHOLD")
92        .ok()
93        .and_then(|s| s.parse::<u32>().ok())
94        .unwrap_or(DEFAULT_JIT_THRESHOLD)
95}
96
97/// Per-process override; 0 means "env var or default".
98static OSR_THRESHOLD_OVERRIDE: AtomicU32 = AtomicU32::new(0);
99
100pub fn set_osr_threshold(t: u32) {
101    OSR_THRESHOLD_OVERRIDE.store(t, Ordering::Relaxed);
102}
103
104/// Back-edge count at which a loop header is considered hot.  Defaults to the
105/// invocation threshold ([`jit_threshold`]); override with
106/// `CLJRS_OSR_THRESHOLD` or [`set_osr_threshold`].
107pub fn osr_threshold() -> u32 {
108    let v = OSR_THRESHOLD_OVERRIDE.load(Ordering::Relaxed);
109    if v != 0 {
110        return v;
111    }
112    std::env::var("CLJRS_OSR_THRESHOLD")
113        .ok()
114        .and_then(|s| s.parse::<u32>().ok())
115        .unwrap_or_else(jit_threshold)
116}
117
118/// Entry-guard failures tolerated before a specialization is discarded.
119pub fn deopt_limit() -> u32 {
120    std::env::var("CLJRS_JIT_DEOPT_LIMIT")
121        .ok()
122        .and_then(|s| s.parse::<u32>().ok())
123        .unwrap_or(10)
124}
125
126// ── Per-arity state ───────────────────────────────────────────────────────────
127
128pub struct JitEntry {
129    pub invocation_count: AtomicU32,
130    /// A background IR-lowering request has been enqueued for this arity
131    /// (Phase 10.7).  Set once when the warm threshold is crossed and the
132    /// request is accepted; keeps Tier-0 dispatch from re-enqueueing.
133    pub lower_queued: AtomicBool,
134    pub compile_queued: AtomicBool,
135    /// Finalized native function pointer, or null if not yet compiled.
136    /// Calling convention: SystemV/C ABI, N ptr-sized params, one ptr-sized return.
137    pub native_fn_ptr: AtomicPtr<()>,
138    /// Reclamation epoch of the published native code (0 = none).  Assigned by
139    /// the JIT worker when it registers the compiled module; used by code
140    /// unloading to identify which `JITModule` backs this pointer and to track
141    /// whether a frame executing this code is live at a safepoint.
142    pub epoch: AtomicU64,
143    /// Observed argument-type bitmasks, one byte per IR parameter (Phase
144    /// 10.6).  OR-accumulated by [`JitState::record_call`] until the compile
145    /// is queued; the JIT worker reads it to decide per-parameter
146    /// specializations ([`JitState::arg_type_profile`]).
147    pub arg_profile: Mutex<Vec<u8>>,
148    /// Entry-guard failures of the published specialized code.  Crossing
149    /// [`deopt_limit`] discards the specialization (see
150    /// [`JitState::record_deopt`]).
151    pub deopt_count: AtomicU32,
152}
153
154impl JitEntry {
155    fn new() -> Self {
156        Self {
157            invocation_count: AtomicU32::new(0),
158            lower_queued: AtomicBool::new(false),
159            compile_queued: AtomicBool::new(false),
160            native_fn_ptr: AtomicPtr::new(std::ptr::null_mut()),
161            epoch: AtomicU64::new(0),
162            arg_profile: Mutex::new(Vec::new()),
163            deopt_count: AtomicU32::new(0),
164        }
165    }
166}
167
168// SAFETY: all fields are atomics, inherently Send+Sync.
169unsafe impl Send for JitEntry {}
170unsafe impl Sync for JitEntry {}
171
172// ── Argument type profiles (Phase 10.6) ──────────────────────────────────────
173
174/// Profile bitmask bits: the observed type classes of one argument position.
175pub const PROFILE_LONG: u8 = 1;
176pub const PROFILE_DOUBLE: u8 = 2;
177pub const PROFILE_OTHER: u8 = 0x80;
178
179/// Classify a value for the argument-type profile.
180#[inline]
181fn profile_tag(v: &Value) -> u8 {
182    match v {
183        Value::Long(_) => PROFILE_LONG,
184        Value::Double(_) => PROFILE_DOUBLE,
185        _ => PROFILE_OTHER,
186    }
187}
188
189// ── OSR (on-stack replacement) state — Phase 10.4 ────────────────────────────
190//
191// A single hot call containing a `loop*`/`recur` never returns to re-dispatch,
192// so the invocation counter above can never promote it.  The IR interpreter
193// instead counts loop back-edges per execution; when a header crosses
194// [`osr_threshold`] it requests compilation of an OSR-entry variant (built by
195// `cljrs_ir::osr::build_osr_function` on the JIT worker).  Once the worker
196// publishes the compiled entry here, the interpreter transfers its register
197// file into the native frame at the next loop-header entry.
198
199/// A published, compiled OSR entry for one `(arity_id, loop header)` pair.
200#[derive(Clone)]
201pub struct OsrSlot {
202    /// Native OSR-entry code: C ABI, `live_ins.len()` `*const Value` params,
203    /// one `*const Value` return.
204    pub fn_ptr: *const (),
205    /// Reclamation epoch of the backing module (see [`push_jit_frame`]).
206    pub epoch: u64,
207    /// Interpreter registers to pass, in parameter order
208    /// (`cljrs_ir::osr::OsrFunction::live_ins`).
209    pub live_ins: Arc<[cljrs_ir::VarId]>,
210}
211
212// SAFETY: `fn_ptr` is executable code owned by the JIT code cache; it carries
213// no thread affinity.  All other fields are plain data.
214unsafe impl Send for OsrSlot {}
215unsafe impl Sync for OsrSlot {}
216
217enum OsrState {
218    /// Compilation requested, worker has not finished yet.
219    Queued,
220    /// Native entry published.
221    Ready(OsrSlot),
222    /// Compilation declined or failed — stop polling, stay at Tier 1.
223    Failed,
224}
225
226/// Result of polling for a compiled OSR entry.
227pub enum OsrPoll {
228    NotRequested,
229    Pending,
230    Ready(OsrSlot),
231    Failed,
232}
233
234// ── One runtime's JIT tables ─────────────────────────────────────────────────
235
236/// The Tier-2 state of one runtime.
237///
238/// Owned by that runtime's [`Tiers`]; see the module docs for the split
239/// between this and the handful of genuinely process-wide items below.
240pub struct JitState {
241    entries: RwLock<HashMap<u64, Arc<JitEntry>>>,
242    osr: RwLock<HashMap<(u64, u32), OsrState>>,
243    /// Arities whose specialization repeatedly deoptimized; the JIT worker
244    /// compiles these generically (all parameters boxed).
245    spec_banned: RwLock<HashSet<u64>>,
246    /// Every arity id below this was defined before the compiler became ready
247    /// (the `clojure.core` bootstrap); see [`JitState::is_bootstrap_arity`].
248    bootstrap_watermark: AtomicU64,
249    /// The compiler attached to this runtime, if any.
250    backend: OnceLock<Arc<dyn JitBackend>>,
251    /// Handle to the enclosing tier state, so a compile request can name the
252    /// runtime the worker must publish into.
253    tiers: Weak<Tiers>,
254}
255
256impl JitState {
257    pub(crate) fn new(tiers: Weak<Tiers>) -> Self {
258        Self {
259            entries: RwLock::new(HashMap::new()),
260            osr: RwLock::new(HashMap::new()),
261            spec_banned: RwLock::new(HashSet::new()),
262            bootstrap_watermark: AtomicU64::new(0),
263            backend: OnceLock::new(),
264            tiers,
265        }
266    }
267
268    // ── Backend ──────────────────────────────────────────────────────────────
269
270    /// Attach a JIT compiler to this runtime.  Idempotent: the first backend
271    /// installed wins, later attempts are ignored.
272    pub fn install_backend(&self, backend: Arc<dyn JitBackend>) {
273        let _ = self.backend.set(backend);
274    }
275
276    /// This runtime's JIT compiler, or `None` when no JIT is linked or
277    /// installed (dispatch then stops at Tier 1).
278    pub fn backend(&self) -> Option<&Arc<dyn JitBackend>> {
279        self.backend.get()
280    }
281
282    // ── Entry table ──────────────────────────────────────────────────────────
283
284    fn entry(&self, arity_id: u64) -> Arc<JitEntry> {
285        {
286            let guard = self.entries.read().unwrap();
287            if let Some(e) = guard.get(&arity_id) {
288                return e.clone();
289            }
290        }
291        self.entries
292            .write()
293            .unwrap()
294            .entry(arity_id)
295            .or_insert_with(|| Arc::new(JitEntry::new()))
296            .clone()
297    }
298
299    /// Return the compiled native function pointer and its reclamation epoch
300    /// for `arity_id`, if native code is currently published.
301    ///
302    /// The caller **must** keep the returned `epoch` live (via
303    /// [`push_jit_frame`]) for the entire native call, so code unloading at a
304    /// stop-the-world safepoint does not free the backing module while it
305    /// executes.
306    pub fn get_native_fn(&self, arity_id: u64) -> Option<(*const (), u64)> {
307        let guard = self.entries.read().unwrap();
308        let entry = guard.get(&arity_id)?;
309        let ptr = entry.native_fn_ptr.load(Ordering::Acquire);
310        if ptr.is_null() {
311            None
312        } else {
313            let epoch = entry.epoch.load(Ordering::Acquire);
314            Some((ptr as *const (), epoch))
315        }
316    }
317
318    /// Publish a compiled native function pointer and its reclamation epoch
319    /// for `arity_id`.  Called by the JIT worker thread after successful
320    /// compilation.
321    ///
322    /// Stores the epoch before the pointer (release ordering) so that any
323    /// reader that observes a non-null pointer also observes the matching
324    /// epoch.
325    pub fn store_native_fn(&self, arity_id: u64, ptr: *const (), epoch: u64) {
326        let entry = self.entry(arity_id);
327        entry.epoch.store(epoch, Ordering::Release);
328        entry.native_fn_ptr.store(ptr as *mut (), Ordering::Release);
329    }
330
331    /// Clear the published native pointer for `arity_id` and return the epoch
332    /// that was backing it, if any.
333    ///
334    /// Called when a var holding this function is redefined: future dispatches
335    /// fall back to the interpreter immediately (the pointer is nulled), and
336    /// the returned epoch is handed to the code cache so the now-superseded
337    /// module is reclaimed at the next safepoint once no frame is executing
338    /// it.  Also drops the per-arity table entry, keeping the table bounded
339    /// across a long REPL session of redefinitions.
340    pub fn take_native_epoch(&self, arity_id: u64) -> Option<u64> {
341        let entry = self.entries.write().unwrap().remove(&arity_id)?;
342        let ptr = entry
343            .native_fn_ptr
344            .swap(std::ptr::null_mut(), Ordering::AcqRel);
345        if ptr.is_null() {
346            None
347        } else {
348            Some(entry.epoch.load(Ordering::Acquire))
349        }
350    }
351
352    /// Null any published native code for `arity_id` (whole-function and OSR
353    /// entries) and hand the backing epochs to the code cache for reclamation.
354    ///
355    /// Used by cross-defn invalidation and by the var-rebind hook; a no-op
356    /// when nothing was compiled or no JIT is installed.
357    pub fn stale_native_code(&self, arity_id: u64) {
358        let mut epochs = Vec::new();
359        if let Some(epoch) = self.take_native_epoch(arity_id) {
360            epochs.push(epoch);
361        }
362        epochs.extend(self.take_osr_epochs(arity_id));
363        if let Some(backend) = self.backend() {
364            for epoch in epochs {
365                backend.mark_stale(epoch);
366            }
367        }
368    }
369
370    /// Snapshot the accumulated argument-type profile for `arity_id` (one
371    /// bitmask byte per IR parameter), if any calls were profiled.
372    pub fn arg_type_profile(&self, arity_id: u64) -> Option<Vec<u8>> {
373        let entry = self.entries.read().unwrap().get(&arity_id)?.clone();
374        let prof = entry.arg_profile.lock().unwrap();
375        if prof.is_empty() {
376            None
377        } else {
378            Some(prof.clone())
379        }
380    }
381
382    /// Record a call to `arity_id`.
383    ///
384    /// Bumps the invocation counter and folds the call's argument types into
385    /// the arity's type profile (`profile_args` are the positional call
386    /// arguments matching the IR parameters; for a variadic arity the caller
387    /// passes only the fixed prefix, leaving the rest-list parameter
388    /// unprofiled — it is padded with [`PROFILE_OTHER`] so it can never be
389    /// specialized).  When the counter crosses [`jit_threshold`] for the first
390    /// time, submits a compilation request to this runtime's backend.
391    ///
392    /// Called on every Tier-1 IR dispatch; must be cheap.  Profiling stops
393    /// once the compile is queued, so the steady-state cost is one atomic
394    /// increment, one compare, and one relaxed load.
395    pub fn record_call(&self, arity_id: u64, ir_func: Arc<IrFunction>, profile_args: &[Value]) {
396        let entry = self.entry(arity_id);
397        let count = entry.invocation_count.fetch_add(1, Ordering::Relaxed) + 1;
398
399        if !entry.compile_queued.load(Ordering::Relaxed) {
400            let n_params = ir_func.params.len();
401            let mut prof = entry.arg_profile.lock().unwrap();
402            if prof.len() < n_params {
403                prof.resize(n_params, 0);
404            }
405            for (i, slot) in prof.iter_mut().enumerate().take(n_params) {
406                *slot |= profile_args
407                    .get(i)
408                    .map(profile_tag)
409                    .unwrap_or(PROFILE_OTHER);
410            }
411        }
412
413        if count < jit_threshold() {
414            return;
415        }
416        // Threshold crossed — enqueue exactly once.
417        if entry.compile_queued.swap(true, Ordering::AcqRel) {
418            return;
419        }
420        if let Some(backend) = self.backend() {
421            tracing::debug!(target: "jit", "enqueue arity_id={} (count={})", arity_id, count);
422            backend.enqueue_function(self.tiers.clone(), arity_id, ir_func);
423        }
424    }
425
426    /// Record a Tier-0 (tree-walk) call to `arity_id`.
427    ///
428    /// Returns `true` exactly when the warm threshold is crossed and no
429    /// lowering request has been enqueued yet — the caller should snapshot the
430    /// function and enqueue, then call [`Self::mark_lower_queued`] on success.
431    /// Uses `>=` so a failed enqueue (full queue) retries on the next call.
432    pub fn record_interp_call(&self, arity_id: u64) -> bool {
433        let threshold = ir_threshold();
434        if threshold == u32::MAX {
435            return false;
436        }
437        let entry = self.entry(arity_id);
438        let count = entry.invocation_count.fetch_add(1, Ordering::Relaxed) + 1;
439        count >= threshold && !entry.lower_queued.load(Ordering::Relaxed)
440    }
441
442    /// Whether a JIT compile has been queued (or published) for `arity_id`.
443    /// Used by the cold-IR sweep: an arity with an in-flight compile still
444    /// needs its IR as the deoptimization fallback.
445    pub fn compile_queued(&self, arity_id: u64) -> bool {
446        self.entries
447            .read()
448            .unwrap()
449            .get(&arity_id)
450            .is_some_and(|e| e.compile_queued.load(Ordering::Relaxed))
451    }
452
453    /// Whether the cold-IR sweep must keep `arity_id`'s IR: native code is
454    /// published (the IR is its deoptimization fallback) or a compile is in
455    /// flight (the worker will need it).
456    pub fn pins_ir(&self, arity_id: u64) -> bool {
457        self.get_native_fn(arity_id).is_some() || self.compile_queued(arity_id)
458    }
459
460    /// Whether a background lowering request is already queued for `arity_id`.
461    /// Fast skip for the Tier-0 dispatch path.
462    pub fn lower_queued(&self, arity_id: u64) -> bool {
463        self.entries
464            .read()
465            .unwrap()
466            .get(&arity_id)
467            .is_some_and(|e| e.lower_queued.load(Ordering::Relaxed))
468    }
469
470    /// Mark that a background lowering request was accepted for `arity_id`.
471    pub fn mark_lower_queued(&self, arity_id: u64) {
472        self.entry(arity_id)
473            .lower_queued
474            .store(true, Ordering::Relaxed);
475    }
476
477    /// Clear the lowering-queued flag for `arity_id`, re-arming the dispatch
478    /// seam's enqueue.  Used by the lowering worker when it abandons an arity
479    /// after exhausting its rebind-retry budget.
480    pub fn clear_lower_queued(&self, arity_id: u64) {
481        if let Some(entry) = self.entries.read().unwrap().get(&arity_id) {
482            entry.lower_queued.store(false, Ordering::Relaxed);
483        }
484    }
485
486    /// Called by the lowering worker when it publishes IR for `arity_id`.
487    ///
488    /// Restarts the invocation counter so the JIT threshold counts pure Tier-1
489    /// calls (and the argument-type profile gets a full window).  Deliberately
490    /// does *not* drop the `JitEntry`: `lower_queued` must stay set, or the
491    /// Tier-0 path could re-enqueue between publish and the next IR dispatch.
492    pub fn on_ir_published(&self, arity_id: u64) {
493        self.entry(arity_id)
494            .invocation_count
495            .store(0, Ordering::Relaxed);
496    }
497
498    /// Drop the `JitEntry` for `arity_id` iff it has no published native code
499    /// and no queued compile.  Returns whether it was dropped.
500    ///
501    /// Used by the cold-IR TTL sweep: dropping the entry clears the counters
502    /// and `lower_queued`, so an evicted function can re-warm from zero.
503    pub fn evict_entry_if_cold(&self, arity_id: u64) -> bool {
504        let mut guard = self.entries.write().unwrap();
505        let Some(entry) = guard.get(&arity_id) else {
506            return false;
507        };
508        if !entry.native_fn_ptr.load(Ordering::Acquire).is_null()
509            || entry.compile_queued.load(Ordering::Relaxed)
510        {
511            return false;
512        }
513        guard.remove(&arity_id);
514        true
515    }
516
517    // ── Bootstrap watermark (Phase 10.7) ─────────────────────────────────────
518    //
519    // Arity ids are minted from a monotonic counter, so a single snapshot
520    // taken when the compiler becomes ready separates bootstrap-era
521    // definitions (the clojure.core bootstrap) from everything defined
522    // afterwards (user code).  Background lowering excludes bootstrap
523    // arities: they were never lowered under eager lowering either (the
524    // compiler was not ready when they were defined), and some bootstrap
525    // patterns are known to miscompile under the JIT (see TODO.md Phase 10.7
526    // notes).  User code gets the warm tier; the bootstrap stays at
527    // tree-walk, exactly as before.
528
529    /// Record the bootstrap/user boundary: every arity id strictly below `w`
530    /// was defined before this runtime's compiler became ready.  Called by the
531    /// runtime builder with `crate::interp::arity::next_arity_id()`.
532    pub fn set_bootstrap_watermark(&self, w: u64) {
533        self.bootstrap_watermark.store(w, Ordering::Relaxed);
534    }
535
536    /// Whether `arity_id` belongs to a bootstrap-era definition (excluded from
537    /// background lowering).
538    pub fn is_bootstrap_arity(&self, arity_id: u64) -> bool {
539        arity_id < self.bootstrap_watermark.load(Ordering::Relaxed)
540    }
541
542    // ── Deoptimization (Phase 10.6) ──────────────────────────────────────────
543    //
544    // A specialized compilation guards its parameter types at entry; on a
545    // guard failure the native code returns a unique sentinel pointer owned by
546    // the compiler.  The dispatch seam detects the sentinel, re-executes the
547    // call at Tier 1 (sound: guards precede all side effects), and counts the
548    // failure.  Crossing the deopt limit discards the specialized code and
549    // bans the arity from further specialization, so the next compile is
550    // generic.
551
552    /// Whether `result` is the deopt sentinel returned by a failed entry
553    /// guard.  Always false without a backend: nothing produced native code.
554    #[inline]
555    pub fn is_deopt_result(&self, result: *const Value) -> bool {
556        self.backend()
557            .is_some_and(|b| b.deopt_sentinel() == result as usize)
558    }
559
560    /// Take (and clear) the thread's pending exception, if any.
561    ///
562    /// Called by the JIT-native and OSR dispatch seams immediately after
563    /// native code returns.  Returns `None` when no JIT is installed.
564    pub fn take_pending_exception(&self) -> Option<Value> {
565        self.backend().and_then(|b| b.take_pending_exception())
566    }
567
568    /// Whether `arity_id` may be compiled with type specializations.
569    pub fn specialization_allowed(&self, arity_id: u64) -> bool {
570        !self.spec_banned.read().unwrap().contains(&arity_id)
571    }
572
573    /// Record an entry-guard deopt for `arity_id`.
574    ///
575    /// Once the failure count crosses [`deopt_limit`], the specialized code is
576    /// unpublished (dispatch falls back to Tier 1 immediately), its module is
577    /// handed to the code cache for reclamation, the arity is banned from
578    /// re-specialization, and its invocation counter restarts so the generic
579    /// recompile triggers through the ordinary hot path.
580    pub fn record_deopt(&self, arity_id: u64) {
581        let entry = self.entry(arity_id);
582        let failures = entry.deopt_count.fetch_add(1, Ordering::Relaxed) + 1;
583        tracing::debug!(
584            target: "jit",
585            "deopt arity_id={} (failure #{} of {})",
586            arity_id,
587            failures,
588            deopt_limit()
589        );
590        if failures < deopt_limit() {
591            return;
592        }
593        self.spec_banned.write().unwrap().insert(arity_id);
594        // Unpublish + reclaim the specialized code.  `take_native_epoch` also
595        // drops the JitEntry, so the arity re-counts from zero and re-enqueues
596        // a (now generic) compile when hot again.
597        if let Some(epoch) = self.take_native_epoch(arity_id)
598            && let Some(backend) = self.backend()
599        {
600            tracing::debug!(
601                target: "jit",
602                "specialization discarded arity_id={} epoch={}",
603                arity_id,
604                epoch
605            );
606            backend.mark_stale(epoch);
607        }
608    }
609
610    // ── OSR table ────────────────────────────────────────────────────────────
611
612    /// Poll for a compiled OSR entry for the loop at `header` in `arity_id`.
613    pub fn osr_poll(&self, arity_id: u64, header: u32) -> OsrPoll {
614        match self.osr.read().unwrap().get(&(arity_id, header)) {
615            None => OsrPoll::NotRequested,
616            Some(OsrState::Queued) => OsrPoll::Pending,
617            Some(OsrState::Ready(slot)) => OsrPoll::Ready(slot.clone()),
618            Some(OsrState::Failed) => OsrPoll::Failed,
619        }
620    }
621
622    /// Request OSR compilation for the loop at `header` in `arity_id`.
623    /// Idempotent: only the first request per `(arity_id, header)` enqueues
624    /// (and pays for one `IrFunction` clone); with no JIT installed the entry
625    /// is marked failed so callers stop polling.
626    pub fn osr_request(&self, arity_id: u64, header: u32, ir_func: &IrFunction) {
627        let has_backend = self.backend().is_some();
628        {
629            let mut guard = self.osr.write().unwrap();
630            match guard.entry((arity_id, header)) {
631                std::collections::hash_map::Entry::Occupied(_) => return,
632                std::collections::hash_map::Entry::Vacant(slot) => {
633                    slot.insert(if has_backend {
634                        OsrState::Queued
635                    } else {
636                        OsrState::Failed
637                    });
638                }
639            }
640        }
641        if let Some(backend) = self.backend() {
642            tracing::debug!(
643                target: "jit",
644                "osr enqueue arity_id={} header=bb{}",
645                arity_id,
646                header
647            );
648            backend.enqueue_osr(
649                self.tiers.clone(),
650                arity_id,
651                header,
652                Arc::new(ir_func.clone()),
653            );
654        }
655    }
656
657    /// Publish a compiled OSR entry.  Called by the JIT worker thread.
658    pub fn store_osr_fn(
659        &self,
660        arity_id: u64,
661        header: u32,
662        ptr: *const (),
663        epoch: u64,
664        live_ins: Vec<cljrs_ir::VarId>,
665    ) {
666        self.osr.write().unwrap().insert(
667            (arity_id, header),
668            OsrState::Ready(OsrSlot {
669                fn_ptr: ptr,
670                epoch,
671                live_ins: live_ins.into(),
672            }),
673        );
674    }
675
676    /// Record that OSR compilation for `(arity_id, header)` declined or
677    /// failed, so interpreters stop polling and the loop stays at Tier 1.
678    pub fn mark_osr_failed(&self, arity_id: u64, header: u32) {
679        self.osr
680            .write()
681            .unwrap()
682            .insert((arity_id, header), OsrState::Failed);
683    }
684
685    /// Drop all OSR entries for `arity_id` (the owning var was rebound),
686    /// returning the epochs of published code so the caller can hand them to
687    /// the code cache for reclamation once no frame executes them.
688    pub fn take_osr_epochs(&self, arity_id: u64) -> Vec<u64> {
689        let mut guard = self.osr.write().unwrap();
690        let keys: Vec<(u64, u32)> = guard
691            .keys()
692            .filter(|(a, _)| *a == arity_id)
693            .copied()
694            .collect();
695        let mut epochs = Vec::new();
696        for key in keys {
697            if let Some(OsrState::Ready(slot)) = guard.remove(&key) {
698                epochs.push(slot.epoch);
699            }
700        }
701        epochs
702    }
703
704    /// Drop all OSR entries for `arity_id` and hand their published epochs to
705    /// the code cache for reclamation.  Used by the cold-IR TTL sweep:
706    /// OSR-entry code is only reachable from Tier-1 interpretation of the
707    /// evicted IR, so it is equally cold.  A no-op when nothing was compiled
708    /// or no JIT is installed.
709    pub fn stale_osr_code(&self, arity_id: u64) {
710        let epochs = self.take_osr_epochs(arity_id);
711        if let Some(backend) = self.backend() {
712            for epoch in epochs {
713                backend.mark_stale(epoch);
714            }
715        }
716    }
717
718    /// Every epoch this runtime still has published, whole-function and OSR.
719    ///
720    /// Only the [`Drop`] impl needs this: everywhere else, code is unpublished
721    /// one arity at a time as it is superseded.
722    fn published_epochs(&self) -> Vec<u64> {
723        let mut epochs = Vec::new();
724        for entry in self.entries.read().unwrap().values() {
725            // An entry keeps its epoch after the pointer is nulled, so the
726            // pointer — not the epoch — is what says the code is still live.
727            if !entry.native_fn_ptr.load(Ordering::Acquire).is_null() {
728                epochs.push(entry.epoch.load(Ordering::Acquire));
729            }
730        }
731        for state in self.osr.read().unwrap().values() {
732            if let OsrState::Ready(slot) = state {
733                epochs.push(slot.epoch);
734            }
735        }
736        epochs
737    }
738}
739
740/// Release this runtime's compiled code when the runtime goes away.
741///
742/// The code cache is process-global (one executable-memory budget, one
743/// worker), so nothing else would ever look at these modules again: their only
744/// referents were the tables being dropped right here.  Staling them hands
745/// them to the cache's reclaim path, which frees each one at the next
746/// stop-the-world safepoint at which no thread is executing it — the same
747/// live-frame scan that guards redefinition, so a native frame still on some
748/// thread's stack is as safe here as it is there.
749///
750/// Without this, a host that creates and drops runtimes — the case
751/// per-runtime tier state exists to support — would leak every module it ever
752/// compiled.  The CLI, with one runtime for the life of the process, never
753/// reaches it.
754impl Drop for JitState {
755    fn drop(&mut self) {
756        let Some(backend) = self.backend.get() else {
757            return;
758        };
759        for epoch in self.published_epochs() {
760            backend.mark_stale(epoch);
761        }
762    }
763}
764
765// ── Active JIT frame tracking (for code unloading) ──────────────────────────────
766//
767// Code unloading (Phase 10.2) frees a superseded `JITModule` only once no frame
768// is executing its code.  We track that precisely: each in-flight native call
769// pushes its code epoch onto a per-thread stack for the duration of the call.
770//
771// This is per *thread*, not per runtime: one thread can have native frames from
772// several runtimes on its stack, and the question reclamation asks is "is any
773// thread executing this module?".
774//
775// At a stop-the-world safepoint every mutator thread is parked, so the reclaimer
776// can read all threads' stacks to compute the set of epochs that must not be
777// freed.  Cross-thread reads are sound because:
778//   - Each thread mutates only its own stack, and only while running (never
779//     while parked: push/pop bracket the native call and release before any
780//     safepoint poll inside it).
781//   - At STW all other threads are frozen, so their stacks are stable.
782//
783// The per-thread stack lives behind a `Mutex` that is essentially uncontended
784// on the hot path (only the owning thread locks it, briefly); the reclaimer
785// contends for it only at the rare STW safepoint.
786
787struct ThreadFrames {
788    stack: Mutex<Vec<u64>>,
789}
790
791static FRAME_REGISTRY: RwLock<Vec<Weak<ThreadFrames>>> = RwLock::new(Vec::new());
792
793thread_local! {
794    static MY_FRAMES: Arc<ThreadFrames> = {
795        let frames = Arc::new(ThreadFrames { stack: Mutex::new(Vec::new()) });
796        let mut reg = FRAME_REGISTRY.write().unwrap();
797        // Opportunistically drop registrations for threads that have exited.
798        reg.retain(|w| w.strong_count() > 0);
799        reg.push(Arc::downgrade(&frames));
800        frames
801    };
802}
803
804/// RAII guard that pops one active-frame epoch on drop (including on unwind,
805/// e.g. when native code throws back through the dispatch boundary).
806pub struct JitFrameGuard {
807    epoch: u64,
808}
809
810impl Drop for JitFrameGuard {
811    fn drop(&mut self) {
812        MY_FRAMES.with(|f| {
813            let mut stack = f.stack.lock().unwrap();
814            // Pop the matching epoch.  Frames are strictly LIFO, so the epoch
815            // is almost always the top; search defensively in case of nesting.
816            if let Some(pos) = stack.iter().rposition(|&e| e == self.epoch) {
817                stack.remove(pos);
818            }
819        });
820    }
821}
822
823/// Register that this thread is about to enter native code backed by `epoch`.
824///
825/// Returns a guard that unregisters the frame on drop.  Must wrap the native
826/// call so code unloading never frees a module that is executing.
827pub fn push_jit_frame(epoch: u64) -> JitFrameGuard {
828    MY_FRAMES.with(|f| f.stack.lock().unwrap().push(epoch));
829    JitFrameGuard { epoch }
830}
831
832/// The epoch of the innermost native frame on this thread, if any.
833///
834/// Used by the closure-escape path: a closure value materialized by
835/// `rt_make_fn*` captures a raw pointer into the module of the currently
836/// executing native code, so that module (this epoch) must be pinned against
837/// reclamation.
838pub fn current_jit_epoch() -> Option<u64> {
839    MY_FRAMES.with(|f| f.stack.lock().unwrap().last().copied())
840}
841
842/// Collect the set of epochs with at least one active native frame across all
843/// mutator threads.
844///
845/// **Must be called at a stop-the-world safepoint** (all other mutator threads
846/// parked), so each thread's frame stack is stable while it is read.  Used by
847/// the JIT code cache to decide which stale modules are safe to free.
848pub fn live_epochs() -> HashSet<u64> {
849    let mut live = HashSet::new();
850    let reg = FRAME_REGISTRY.read().unwrap();
851    for weak in reg.iter() {
852        if let Some(frames) = weak.upgrade() {
853            for &epoch in frames.stack.lock().unwrap().iter() {
854                live.insert(epoch);
855            }
856        }
857    }
858    live
859}
860
861// ── JIT function dispatch ─────────────────────────────────────────────────────
862
863/// Transmute `fn_ptr` to the correct arity and invoke native JIT code.
864///
865/// # Safety
866/// - `fn_ptr` must be a valid JIT-compiled function using the default
867///   platform C calling convention (SystemV on x86-64 Linux/macOS).
868/// - The function must accept exactly `args.len()` parameters of type
869///   `*const Value` and return `*const Value`.
870/// - The returned pointer is valid until the GC collects the backing object;
871///   callers must clone the `Value` before yielding a safepoint.
872#[allow(clippy::too_many_arguments)]
873pub unsafe fn dispatch_jit_call(fn_ptr: *const (), args: &[*const Value]) -> *const Value {
874    unsafe {
875        match args.len() {
876            0 => {
877                let f: unsafe extern "C" fn() -> *const Value = std::mem::transmute(fn_ptr);
878                f()
879            }
880            1 => {
881                let f: unsafe extern "C" fn(*const Value) -> *const Value =
882                    std::mem::transmute(fn_ptr);
883                f(args[0])
884            }
885            2 => {
886                let f: unsafe extern "C" fn(*const Value, *const Value) -> *const Value =
887                    std::mem::transmute(fn_ptr);
888                f(args[0], args[1])
889            }
890            3 => {
891                let f: unsafe extern "C" fn(
892                    *const Value,
893                    *const Value,
894                    *const Value,
895                ) -> *const Value = std::mem::transmute(fn_ptr);
896                f(args[0], args[1], args[2])
897            }
898            4 => {
899                let f: unsafe extern "C" fn(
900                    *const Value,
901                    *const Value,
902                    *const Value,
903                    *const Value,
904                ) -> *const Value = std::mem::transmute(fn_ptr);
905                f(args[0], args[1], args[2], args[3])
906            }
907            5 => {
908                let f: unsafe extern "C" fn(
909                    *const Value,
910                    *const Value,
911                    *const Value,
912                    *const Value,
913                    *const Value,
914                ) -> *const Value = std::mem::transmute(fn_ptr);
915                f(args[0], args[1], args[2], args[3], args[4])
916            }
917            6 => {
918                let f: unsafe extern "C" fn(
919                    *const Value,
920                    *const Value,
921                    *const Value,
922                    *const Value,
923                    *const Value,
924                    *const Value,
925                ) -> *const Value = std::mem::transmute(fn_ptr);
926                f(args[0], args[1], args[2], args[3], args[4], args[5])
927            }
928            7 => {
929                let f: unsafe extern "C" fn(
930                    *const Value,
931                    *const Value,
932                    *const Value,
933                    *const Value,
934                    *const Value,
935                    *const Value,
936                    *const Value,
937                ) -> *const Value = std::mem::transmute(fn_ptr);
938                f(
939                    args[0], args[1], args[2], args[3], args[4], args[5], args[6],
940                )
941            }
942            8 => {
943                let f: unsafe extern "C" fn(
944                    *const Value,
945                    *const Value,
946                    *const Value,
947                    *const Value,
948                    *const Value,
949                    *const Value,
950                    *const Value,
951                    *const Value,
952                ) -> *const Value = std::mem::transmute(fn_ptr);
953                f(
954                    args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7],
955                )
956            }
957            n => panic!("JIT dispatch: unsupported arity {n} (max 8 in Phase 10.1)"),
958        }
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::tiered::tiers::Tiers;
966
967    /// A backend that records the epochs handed to it for reclamation, so a
968    /// test can assert on what the runtime released and when.
969    #[derive(Default)]
970    struct RecordingBackend {
971        staled: Mutex<Vec<u64>>,
972    }
973
974    impl RecordingBackend {
975        fn staled(&self) -> Vec<u64> {
976            let mut v = self.staled.lock().unwrap().clone();
977            v.sort_unstable();
978            v
979        }
980    }
981
982    impl JitBackend for RecordingBackend {
983        fn enqueue_function(&self, _: Weak<Tiers>, _: u64, _: Arc<cljrs_ir::IrFunction>) {}
984        fn enqueue_osr(&self, _: Weak<Tiers>, _: u64, _: u32, _: Arc<cljrs_ir::IrFunction>) {}
985        fn mark_stale(&self, epoch: u64) {
986            self.staled.lock().unwrap().push(epoch);
987        }
988        fn take_pending_exception(&self) -> Option<Value> {
989            None
990        }
991        fn deopt_sentinel(&self) -> usize {
992            0
993        }
994        fn compile_async_arity(&self, _: &Value, _: usize, _: &mut crate::env::env::Env) {}
995    }
996
997    fn jit() -> Arc<Tiers> {
998        Tiers::new(0xF000_0000)
999    }
1000
1001    #[test]
1002    fn epoch_round_trips_through_store_get_take() {
1003        let t = jit();
1004        let id = 0xF100_0001;
1005        let ptr = 0x1234usize as *const ();
1006        t.jit().store_native_fn(id, ptr, 777);
1007        assert_eq!(t.jit().get_native_fn(id), Some((ptr, 777)));
1008        // take returns the epoch and removes the entry.
1009        assert_eq!(t.jit().take_native_epoch(id), Some(777));
1010        assert_eq!(t.jit().get_native_fn(id), None);
1011        assert_eq!(t.jit().take_native_epoch(id), None);
1012    }
1013
1014    /// Published native code belongs to the runtime that compiled it: a
1015    /// second runtime asked about the same arity id sees nothing.
1016    #[test]
1017    fn native_code_is_per_runtime() {
1018        let a = jit();
1019        let b = jit();
1020        let id = 0xF100_0002;
1021        a.jit().store_native_fn(id, 0x1234usize as *const (), 778);
1022        assert!(a.jit().get_native_fn(id).is_some());
1023        assert!(b.jit().get_native_fn(id).is_none());
1024        assert!(!b.jit().compile_queued(id));
1025    }
1026
1027    /// Dropping a runtime releases the code it compiled.  Nothing else ever
1028    /// looks at those modules again — the tables that referenced them go away
1029    /// with the runtime — so without this the process-global code cache would
1030    /// hold them forever.
1031    #[test]
1032    fn dropping_a_runtime_stales_its_published_code() {
1033        use cljrs_ir::VarId;
1034        let backend = Arc::new(RecordingBackend::default());
1035        let t = jit();
1036        t.jit().install_backend(backend.clone());
1037
1038        // Two published whole-function epochs, one published OSR entry, one
1039        // header that failed to compile (nothing to release), and one epoch
1040        // already superseded by a redefinition before the drop.
1041        t.jit()
1042            .store_native_fn(0xF300_0001, 0x1000usize as *const (), 1001);
1043        t.jit()
1044            .store_native_fn(0xF300_0002, 0x2000usize as *const (), 1002);
1045        t.jit().store_osr_fn(
1046            0xF300_0001,
1047            4,
1048            0x3000usize as *const (),
1049            1003,
1050            vec![VarId(1)],
1051        );
1052        t.jit().mark_osr_failed(0xF300_0002, 9);
1053        t.jit()
1054            .store_native_fn(0xF300_0003, 0x4000usize as *const (), 1004);
1055        t.jit().stale_native_code(0xF300_0003);
1056        assert_eq!(backend.staled(), vec![1004], "redefinition released 1004");
1057
1058        drop(t);
1059        assert_eq!(
1060            backend.staled(),
1061            vec![1001, 1002, 1003, 1004],
1062            "the drop must release every epoch still published"
1063        );
1064    }
1065
1066    #[test]
1067    fn frame_guard_marks_epoch_live_then_clears() {
1068        let e = 0xBEEF_0001;
1069        assert!(!live_epochs().contains(&e));
1070        let guard = push_jit_frame(e);
1071        assert!(live_epochs().contains(&e));
1072        drop(guard);
1073        assert!(!live_epochs().contains(&e));
1074    }
1075
1076    #[test]
1077    fn osr_slot_round_trips_and_rebind_takes_epochs() {
1078        use cljrs_ir::VarId;
1079        let t = jit();
1080        let id = 0xF200_0001;
1081        // Unpublished header polls as NotRequested.
1082        assert!(matches!(t.jit().osr_poll(id, 1), OsrPoll::NotRequested));
1083
1084        let ptr = 0x5678usize as *const ();
1085        t.jit()
1086            .store_osr_fn(id, 1, ptr, 901, vec![VarId(3), VarId(4)]);
1087        match t.jit().osr_poll(id, 1) {
1088            OsrPoll::Ready(slot) => {
1089                assert_eq!(slot.fn_ptr, ptr);
1090                assert_eq!(slot.epoch, 901);
1091                assert_eq!(&*slot.live_ins, &[VarId(3), VarId(4)]);
1092            }
1093            _ => panic!("expected Ready"),
1094        }
1095
1096        // A second loop in the same arity that failed to compile.
1097        t.jit().mark_osr_failed(id, 7);
1098        assert!(matches!(t.jit().osr_poll(id, 7), OsrPoll::Failed));
1099
1100        // Rebinding the var takes only the published epochs and clears all
1101        // entries for the arity.
1102        let epochs = t.jit().take_osr_epochs(id);
1103        assert_eq!(epochs, vec![901]);
1104        assert!(matches!(t.jit().osr_poll(id, 1), OsrPoll::NotRequested));
1105        assert!(matches!(t.jit().osr_poll(id, 7), OsrPoll::NotRequested));
1106    }
1107
1108    #[test]
1109    fn osr_request_without_backend_marks_failed() {
1110        // No backend is installed on a bare `Tiers` (that is
1111        // `cljrs_compiler::jit::install`'s job), so a request must
1112        // immediately fail closed rather than leave interpreters polling
1113        // forever.
1114        let t = jit();
1115        let id = 0xF200_0002;
1116        let ir = IrFunction::new(None, None);
1117        t.jit().osr_request(id, 2, &ir);
1118        assert!(matches!(t.jit().osr_poll(id, 2), OsrPoll::Failed));
1119    }
1120
1121    #[test]
1122    fn record_interp_call_warm_lifecycle() {
1123        // Single test for the whole lifecycle: set_ir_threshold is a
1124        // process-global override, so splitting this across tests would race.
1125        set_ir_threshold(3);
1126        let t = jit();
1127        let id = 0xF300_0001;
1128
1129        // Below the threshold: no trigger.
1130        assert!(!t.jit().record_interp_call(id));
1131        assert!(!t.jit().record_interp_call(id));
1132        // Crossing (and staying past) the threshold triggers until a request
1133        // is accepted — a full queue must be able to retry.
1134        assert!(t.jit().record_interp_call(id));
1135        assert!(t.jit().record_interp_call(id));
1136
1137        // Once queued, the trigger disarms.
1138        t.jit().mark_lower_queued(id);
1139        assert!(t.jit().lower_queued(id));
1140        assert!(!t.jit().record_interp_call(id));
1141
1142        // Worker abandons the request: re-armed.
1143        t.jit().clear_lower_queued(id);
1144        assert!(t.jit().record_interp_call(id));
1145
1146        // Worker publishes: counter restarts so jit_threshold measures pure
1147        // Tier-1 calls; the (re-armed) trigger stays quiet below threshold.
1148        t.jit().on_ir_published(id);
1149        assert!(!t.jit().record_interp_call(id));
1150
1151        // u32::MAX disables background lowering entirely.
1152        set_ir_threshold(u32::MAX);
1153        for _ in 0..10 {
1154            assert!(!t.jit().record_interp_call(id));
1155        }
1156        set_ir_threshold(0); // restore env/default for other tests
1157    }
1158
1159    #[test]
1160    fn evict_entry_if_cold_respects_native_and_queued() {
1161        let t = jit();
1162        let id = 0xF300_0002;
1163        t.jit().mark_lower_queued(id); // creates the entry
1164        // Published native code pins the entry.
1165        t.jit().store_native_fn(id, 0x4242usize as *const (), 555);
1166        assert!(!t.jit().evict_entry_if_cold(id));
1167        assert_eq!(t.jit().take_native_epoch(id), Some(555)); // also drops the entry
1168
1169        // A fresh cold entry is evictable.
1170        t.jit().mark_lower_queued(id);
1171        assert!(t.jit().evict_entry_if_cold(id));
1172        assert!(!t.jit().lower_queued(id));
1173        assert!(!t.jit().evict_entry_if_cold(id)); // already gone
1174    }
1175
1176    #[test]
1177    fn nested_frames_pop_in_lifo_order() {
1178        let a = 0xBEEF_1001;
1179        let b = 0xBEEF_1002;
1180        let ga = push_jit_frame(a);
1181        let gb = push_jit_frame(b);
1182        let live = live_epochs();
1183        assert!(live.contains(&a) && live.contains(&b));
1184        drop(gb);
1185        assert!(live_epochs().contains(&a));
1186        assert!(!live_epochs().contains(&b));
1187        drop(ga);
1188        assert!(!live_epochs().contains(&a));
1189    }
1190}