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}