cljrs-runtime 0.1.247

clojurust runtime: environment, builtins, tree-walking interpreter, and tiered evaluation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Extended apply routines, tries IR evaluation and falls back to tree-walking.
use std::sync::Arc;

use crate::env::env::Env;
use crate::env::error::EvalResult;
use crate::interp::apply::select_arity;
use cljrs_gc::GcPtr;
use cljrs_value::{CljxFn, PersistentList, Value};

static EAGER_LOWER_FORCED: std::sync::atomic::AtomicBool =
    std::sync::atomic::AtomicBool::new(false);

/// Force eager IR lowering to be enabled for all threads, regardless of
/// the `CLJRS_EAGER_LOWER` environment variable.
pub fn force_eager_lowering() {
    EAGER_LOWER_FORCED.store(true, std::sync::atomic::Ordering::Relaxed);
}

/// Whether eager IR lowering at function definition time is enabled.
///
/// Eager lowering calls the Clojure compiler for every `fn*` definition,
/// which is expensive.  Disabled by default; set `CLJRS_EAGER_LOWER=1` to
/// enable.  (Or set `CLJRS_NO_IR=1` to disable all IR functionality.)
pub(crate) fn eager_lower_enabled() -> bool {
    if std::env::var("CLJRS_NO_IR").is_ok() {
        return false;
    }
    if EAGER_LOWER_FORCED.load(std::sync::atomic::Ordering::Relaxed) {
        return true;
    }
    std::env::var("CLJRS_EAGER_LOWER").is_ok()
}

pub fn call_cljrs_fn(f: &CljxFn, args: &[Value], caller_env: &mut Env) -> EvalResult {
    let arity = select_arity(f, args.len())?;

    if !f.is_macro {
        let arity_id = arity.ir_arity_id;

        // 1. JIT-native: fastest path — skip interpreter entirely.  Only
        //    `ExecutionMode::Tiered` reaches native code; `TieredNoJit` stops
        //    at Tier 1 even when a JIT backend is linked in.
        if caller_env.globals.tier_state().jit_enabled()
            && let Some((fn_ptr, epoch)) = caller_env.globals.jit().get_native_fn(arity_id)
        {
            return call_jit_native(f, fn_ptr, epoch, arity, args, caller_env);
        }

        // 2. IR interpreter (also bumps invocation counter).
        if let Some(result) = try_ir_path(f, arity, args, caller_env) {
            return result;
        }

        // 3. No IR yet — count the tree-walked call; crossing the warm
        //    threshold requests background lowering (Phase 10.7).
        maybe_request_lowering(f, arity_id, caller_env);
    }

    // 4. Tree-walking interpreter.
    crate::interp::apply::call_cljrs_fn(f, args, caller_env)
}

// Thread-local guard to prevent recursive IR lowering.
// When we're inside a lowering call (which invokes the Clojure compiler),
// we must not try to lower functions called by the compiler itself.
thread_local! {
    pub static IR_LOWERING_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Attempt to execute via the IR interpreter.
/// Returns `None` if IR is not available (not cached, lowering failed, etc.).
/// Returns `Some(result)` if IR execution was attempted.
///
/// Only executes IR that is already cached — published by the background
/// lowering worker once the function crossed the warm threshold (Phase 10.7),
/// eagerly lowered at definition time (`CLJRS_EAGER_LOWER`), or loaded from a
/// pre-built bundle.
fn try_ir_path(
    f: &CljxFn,
    arity: &cljrs_value::CljxFnArity,
    args: &[Value],
    caller_env: &mut Env,
) -> Option<EvalResult> {
    let arity_id = arity.ir_arity_id;

    // Cross-defn invalidation: if a defn this arity's lowering specialized
    // against was rebound, the cached IR was dropped and the arity marked
    // for re-lowering — request a background re-lower (one relaxed atomic
    // load on the fast path).  The mark is only *peeked* here; the lowering
    // worker consumes it, so a mark can never be lost between the worker
    // publishing IR and validating it against concurrent rebinds.
    // `lower_queued` (cleared when the rebind dropped the JitEntry)
    // deduplicates the request across calls.
    if crate::tiered::defn_registry::relower_pending()
        && crate::tiered::defn_registry::relower_marked(arity_id)
        && !IR_LOWERING_ACTIVE.get()
        && !caller_env.globals.jit().lower_queued(arity_id)
    {
        request_background_lower(f, caller_env);
    }

    // Only use IR if already cached.
    let ir_func = caller_env.globals.ir_cache().get(arity_id)?;

    // Async IR functions fall back to tree-walking.
    if ir_func.is_async {
        return None;
    }

    // Bump invocation counter and the argument-type profile (drives
    // specialized compilation, Phase 10.6); enqueue JIT compilation when
    // hot.  Variadic arities profile only the fixed prefix — the rest-list
    // parameter is synthesized and must never be specialized.
    let profile_args = if arity.rest_param.is_some() {
        &args[..arity.params.len().min(args.len())]
    } else {
        args
    };
    caller_env
        .globals
        .jit()
        .record_call(arity_id, Arc::clone(&ir_func), profile_args);

    Some(execute_ir(f, arity, &ir_func, args, caller_env))
}

/// Whether all IR functionality is disabled (`CLJRS_NO_IR`).  Cached: the
/// warm path checks this on every tree-walked call.
fn no_ir() -> bool {
    static NO_IR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    *NO_IR.get_or_init(|| std::env::var("CLJRS_NO_IR").is_ok())
}

/// Tier-0 warm-up accounting (Phase 10.7).
///
/// Counts a tree-walked call to `arity_id`; when the function gets warm
/// (`jit_state::ir_threshold`, default 50) its arity bodies are macro-expanded
/// here on the calling thread (macros need the interpreter) and shipped to
/// the background lowering worker.  Once the worker publishes the IR, step 2
/// of `call_cljrs_fn` takes over and the JIT pipeline proceeds as before.
fn maybe_request_lowering(f: &CljxFn, arity_id: u64, caller_env: &mut Env) {
    // Cheap gates first; all of these make the arity permanently
    // un-lowerable (or expansion unsafe), so skip before touching counters.
    //
    // - Inside a lowering/expansion already: don't recurse.
    // - Async fns: the IR interpreter refuses them (`try_ir_path`), so
    //   lowering would be wasted work.
    // - Capturing closures: lowering cannot see captures (every reference
    //   would mis-resolve as a global), same restriction as eager lowering.
    // - Bootstrap-era fns (defined before the compiler was ready): never
    //   lowered under eager lowering either; they stay at tree-walk.
    // Named anonymous functions (`(fn g [] g)`) rely on self_ptr for pointer
    // identity.  Background lowering would resolve the self-name via
    // LoadGlobal which fails when no global var exists for that name.  Only
    // allow lowering if the function either has no name or its name is
    // already a top-level var in the defining namespace (i.e. it came from
    // `defn`).
    let named_without_global = f.name.as_deref().is_some_and(|n| {
        caller_env
            .globals
            .lookup_var_in_ns(&f.defining_ns, n)
            .is_none()
    });

    if IR_LOWERING_ACTIVE.get()
        || f.is_async
        || !f.closed_over_names.is_empty()
        || named_without_global
        || caller_env.globals.jit().is_bootstrap_arity(arity_id)
        || no_ir()
        || !caller_env.globals.ir_enabled()
    {
        return;
    }

    if !caller_env.globals.jit().record_interp_call(arity_id) {
        return;
    }

    // Threshold crossed.  Shipped (builtin-source) namespaces — clojure.test,
    // clojure.string, the compiler namespaces, … — keep their historical
    // behavior and are never background-lowered: like the bootstrap, they
    // only ever reached the IR tiers under opt-in eager lowering, and some
    // of their patterns are known to miscompile (TODO.md Phase 10.7 notes).
    // Pin the queued flag so this is a one-time check per arity.
    if caller_env.globals.builtin_source(&f.defining_ns).is_some() {
        caller_env.globals.jit().mark_lower_queued(arity_id);
        return;
    }

    // A previous lowering attempt may have marked this arity Unsupported
    // (e.g. an eager-lowering failure); pin the queued flag so the per-call
    // gates above stay the steady-state cost.
    if !caller_env.globals.ir_cache().should_attempt(arity_id) {
        caller_env.globals.jit().mark_lower_queued(arity_id);
        return;
    }

    request_background_lower(f, caller_env);
}

/// Snapshot `f` (macro-expanding every arity body on this thread) and enqueue
/// it for background lowering.  On acceptance, marks every arity of `f` as
/// queued; a full queue leaves the flags unset so a later call retries.
fn request_background_lower(f: &CljxFn, caller_env: &mut Env) {
    // Macro-expand using the defining namespace so that ::kw auto-keywords
    // inside the function body resolve relative to where the function was
    // defined, not where it happens to be called from.  Clojure resolves ::kw
    // at read/compile time using the definer's namespace; we do it here at the
    // first macro-expansion triggered by background lowering.
    let saved_ns = caller_env.current_ns.clone();
    caller_env.current_ns = f.defining_ns.clone();

    let arities: Vec<crate::tiered::lower_worker::LowerArityRequest> = f
        .arities
        .iter()
        .map(|a| crate::tiered::lower_worker::LowerArityRequest {
            arity_id: a.ir_arity_id,
            params: a.params.clone(),
            rest_param: a.rest_param.clone(),
            destructure_params: a.destructure_params.clone(),
            destructure_rest: a.destructure_rest.clone(),
            expanded_body: crate::tiered::lower::macroexpand_body(&a.body, caller_env),
            param_hints: a.param_hints.clone(),
        })
        .collect();

    caller_env.current_ns = saved_ns;
    let arity_ids: Vec<u64> = arities.iter().map(|a| a.arity_id).collect();

    let accepted =
        crate::tiered::lower_worker::enqueue(crate::tiered::lower_worker::LowerRequest {
            tiers: caller_env.globals.tiers().handle(),
            name: f.name.clone(),
            ns: f.defining_ns.clone(),
            is_async: f.is_async,
            arities,
        });
    if accepted {
        for id in arity_ids {
            caller_env.globals.jit().mark_lower_queued(id);
        }
    }
}

/// Execute an IR function with the given arguments.
fn execute_ir(
    f: &CljxFn,
    arity: &cljrs_value::CljxFnArity,
    ir_func: &cljrs_ir::IrFunction,
    args: &[Value],
    caller_env: &mut Env,
) -> EvalResult {
    let _caller_root = crate::env::gc_roots::push_env_root(caller_env);
    let mut env = Env::with_closure(caller_env.globals.clone(), &f.defining_ns, f);

    // Bind params (including destructuring) into the env so LoadLocal can find them.
    env.push_frame();
    crate::interp::apply::bind_fn_params(arity, args, &mut env)?;

    // Self-reference for named functions: use self_ptr when available so
    // the binding is pointer-equal to the outer Value::Fn holding this fn.
    if let Some(ref name) = f.name {
        let self_val = if let Some(ref p) = f.self_ptr {
            Value::Fn(p.clone())
        } else {
            Value::Fn(GcPtr::new(f.clone()))
        };
        env.bind(name.clone(), self_val);
    }

    // Push eval context so IR closures (which use with_eval_context) can
    // call back into the interpreter.
    crate::env::callback::push_eval_context(&env);

    // Build IR args: positional params map 1:1, but the rest param (if any)
    // must receive a list of the remaining args, not individual values.
    let ir_args = if arity.rest_param.is_some() {
        let n = arity.params.len();
        let mut ir_args = args[..n.min(args.len())].to_vec();
        let rest_items: Vec<Value> = args[n.min(args.len())..].to_vec();
        let rest_val = if rest_items.is_empty() {
            Value::Nil
        } else {
            Value::List(GcPtr::new(PersistentList::from_iter(rest_items)))
        };
        ir_args.push(rest_val);
        ir_args
    } else {
        args.to_vec()
    };

    // OSR (Phase 10.4) is enabled here — this path has a stable arity
    // identity, so a hot loop inside a single call can promote to native
    // code mid-run.
    let result = crate::tiered::ir_interp::interpret_ir_with_osr(
        ir_func,
        ir_args,
        &caller_env.globals,
        &f.defining_ns,
        &mut env,
        Some(arity.ir_arity_id),
    );

    crate::env::callback::pop_eval_context();
    env.pop_frame();
    result
}

/// Invoke a JIT-compiled native function.
///
/// Roots the caller env and the argument slice on the GC shadow stack before
/// entering native code, so GC safepoints inside the JIT frame can find them.
///
/// For a variadic arity the compiled function's signature is
/// `(fixed…, rest_list)` — the IR lowers the rest parameter to a single value
/// that receives a list of the trailing arguments.  We therefore pack the
/// trailing args into a list here, exactly as [`execute_ir`] does for the IR
/// interpreter, so the native call receives the `arity.params.len() + 1`
/// arguments it was compiled for instead of the raw call argument count.
fn call_jit_native(
    f: &CljxFn,
    fn_ptr: *const (),
    epoch: u64,
    arity: &cljrs_value::CljxFnArity,
    args: &[Value],
    caller_env: &mut Env,
) -> EvalResult {
    // Register this native frame's code epoch so code unloading cannot free the
    // backing module while it executes.  Pushed *before* entering native code
    // and *before* any safepoint can occur, and popped on return/unwind.
    let _jit_frame = crate::tiered::jit_state::push_jit_frame(epoch);
    // Register the caller env so its GcPtrs survive any GC triggered inside
    // the JIT frame (at rt_safepoint calls).
    let _caller_root = crate::env::gc_roots::push_env_root(caller_env);

    // Native code resolves globals (rt_load_global) and calls function values
    // (rt_call, the HOF bridges) through rt_abi, which dispatches via the
    // thread-local eval context — exactly as `execute_ir` pushes one for the
    // Tier-1 interpreter.  Without it every such bridge fails and silently
    // yields nil.
    let _eval_ctx = crate::env::callback::install_eval_context_guard(
        caller_env.globals.clone(),
        f.defining_ns.clone(),
    );

    // Build the argument list the native code expects.  Fixed arities pass args
    // through unchanged; variadic arities pack the trailing args into the rest
    // list so the native arg count matches the compiled signature.
    let call_args: Vec<Value> = if arity.rest_param.is_some() {
        let n = arity.params.len();
        let split = n.min(args.len());
        let mut v = args[..split].to_vec();
        let rest_items = &args[split..];
        let rest_val = if rest_items.is_empty() {
            Value::Nil
        } else {
            Value::List(GcPtr::new(PersistentList::from_iter(rest_items.to_vec())))
        };
        v.push(rest_val);
        v
    } else {
        args.to_vec()
    };

    // Register the (owned) arg values on the shadow stack — including the freshly
    // built rest list — so they survive any GC triggered inside the JIT frame.
    let _arg_roots = crate::env::gc_roots::root_values(&call_args);
    // Track all allocations made inside the JIT frame.
    let _alloc_frame = cljrs_gc::push_alloc_frame();

    // Pass raw pointers to the args.  These are valid for the duration of this
    // call because `call_args` outlives the call and `_arg_roots` roots the
    // underlying Values.
    let arg_ptrs: Vec<*const Value> = call_args.iter().map(|v| v as *const Value).collect();

    // SAFETY: fn_ptr was produced by Cranelift JIT with SystemV ABI and the
    // correct number of *const Value params; all arg pointers are live.
    let result_ptr = unsafe { crate::tiered::jit_state::dispatch_jit_call(fn_ptr, &arg_ptrs) };

    // Deoptimization (Phase 10.6): a specialized compilation whose entry
    // type guard failed returns the deopt sentinel.  Guards run before any
    // side effect, so re-executing the whole call at Tier 1 is sound.  The
    // failure is counted; repeated violations discard the specialization
    // (record_deopt), after which dispatch returns to Tier 1 until a generic
    // recompile is published.
    if caller_env.globals.jit().is_deopt_result(result_ptr) {
        caller_env.globals.jit().record_deopt(arity.ir_arity_id);
        if let Some(ir_func) = caller_env.globals.ir_cache().get(arity.ir_arity_id) {
            return execute_ir(f, arity, &ir_func, args, caller_env);
        }
        return crate::interp::apply::call_cljrs_fn(f, args, caller_env);
    }

    // SAFETY: result_ptr was returned by rt_abi; it points to a live Value
    // in ALLOC_ROOTS.  Clone it before the alloc frame drops.
    let result = unsafe { (*result_ptr).clone() };

    let gas_exhausted = crate::env::gas::is_exhausted();

    // An uncaught `(throw …)` inside native code stashes the thrown value in a
    // thread-local and returns the nil sentinel.  Surface it as an error here
    // (while the alloc frame still roots it), exactly as Tier-1 would have
    // propagated it — and so a stale slot cannot misfire a later `rt_try`.
    let pending_exception = caller_env.globals.jit().take_pending_exception();
    if gas_exhausted {
        return Err(crate::env::error::EvalError::GasExhausted);
    }
    if let Some(thrown) = pending_exception {
        return Err(crate::env::error::EvalError::Thrown(thrown));
    }
    Ok(result)
}