Skip to main content

logicaffeine_compile/vm/
native_tier.rs

1//! Tier-up seam: the VM profiles calls and hands HOT functions to a pluggable
2//! native backend. The backend (the copy-and-patch JIT in
3//! `logicaffeine_forge`) is injected as a trait object by whatever binary
4//! links both crates — this crate publishes no new dependencies, and WASM
5//! builds simply never install a tier.
6//!
7//! Deopt contract: the native code only handles the integer subset, so the
8//! call site GUARDS — if any argument is not an Int, or compilation bailed,
9//! the bytecode path runs instead. Both paths are differentially tested to
10//! produce identical outcomes.
11
12use std::sync::atomic::{AtomicI64, Ordering};
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use super::instruction::{Constant, Op};
18use super::value::Value;
19
20/// The per-program native ENTRY TABLE (EXODIA 4.7's hot-swap seam): two
21/// atomic slots per function — [entry pointer, register count] — written
22/// when a function's chain compiles, read by native call stencils through a
23/// patched slot address, and the place Tier-2 swaps optimized code in. The
24/// backing Vec never reallocates (fixed at construction), so slot addresses
25/// are stable for the program's lifetime.
26#[derive(Debug)]
27pub struct FnTable {
28    slots: Vec<AtomicI64>,
29}
30
31impl FnTable {
32    pub fn new(functions: usize) -> Self {
33        FnTable { slots: (0..functions * 2).map(|_| AtomicI64::new(0)).collect() }
34    }
35
36    /// Publish a compiled entry (pointer + callee register count).
37    pub fn publish(&self, fi: usize, entry: i64, register_count: i64) {
38        self.slots[fi * 2 + 1].store(register_count, Ordering::Release);
39        self.slots[fi * 2].store(entry, Ordering::Release);
40    }
41
42    /// The address of function `fi`'s [entry, regcount] slot pair — patched
43    /// into call stencils as an immediate.
44    pub fn slot_addr(&self, fi: usize) -> i64 {
45        &self.slots[fi * 2] as *const AtomicI64 as i64
46    }
47}
48
49/// Per-program cells the native tier shares with every chain it compiles:
50/// the deopt status (any side-exit anywhere unwinds the whole native stack),
51/// and the LIVE LOGOS DEPTH the call stencils count against MAX_CALL_DEPTH.
52/// `Clone` is an `Arc` bump on each cell — a background-compile request carries a
53/// clone so the worker patches the same per-program table/status/depth the
54/// interpreter does.
55#[derive(Debug, Clone)]
56pub struct NativeCtx {
57    pub table: Arc<FnTable>,
58    pub status: Arc<AtomicI64>,
59    pub depth: Arc<AtomicI64>,
60}
61
62/// What one native function call produced.
63#[derive(Debug)]
64pub enum NativeOutcome {
65    /// A fully re-boxed return value (list-returning functions: the
66    /// backend owns the re-boxing because the allocation registry lives
67    /// on its side).
68    ReturnValue(Value),
69    /// Precise side exit: effects already landed stay landed; the VM
70    /// materializes `frames` as real CallFrames and resumes interpreting
71    /// at `resume_pc` (region-grade deopt for functions).
72    DeoptAt { resume_pc: usize, frames: Vec<NativeFrame> },
73    /// The function ran to its return.
74    Return(i64),
75    /// A checked op side-exited (zero divisor). The native code touched only
76    /// its private frame, and every adaptable body is effect-free, so the
77    /// caller simply re-runs the call on bytecode — where the kernel raises
78    /// the exact error at the exact point.
79    Deopt,
80}
81
82/// What one native region run produced.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum RegionOutcome {
85    /// The region ran to its exit; write-back may proceed.
86    Completed,
87    /// Side exit: discard the private frame (NO write-back) and let the
88    /// bytecode loop re-run from the head — VM registers are untouched, so
89    /// the replay recomputes deterministically up to the faulting op.
90    Deopt,
91    /// PRECISE side exit: the region's non-array scalar registers are live in
92    /// the frame; materialize them into the VM registers and resume the
93    /// bytecode AT `resume_pc` (the faulting op) — no replay-from-head, no
94    /// buffer truncate. This is the contract that lets a region with a
95    /// `ListPush` coexisting with an in-place `SetIndex` (BFS-style worklists)
96    /// tier up soundly: completed iterations' effects stand, and the faulting
97    /// op re-runs once on bytecode (raising the exact error or continuing).
98    DeoptAt { resume_pc: usize },
99}
100
101/// A `Return` statement INSIDE the region (the siftDown early-exit shape):
102/// the value slot, its re-boxing kind, and the flag slot the return path
103/// sets — after write-back the VM performs the actual function return.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct RegionReturn {
106    pub flag_slot: u16,
107    pub value_slot: u16,
108    pub kind: RegionReturnKind,
109}
110
111/// How a region-return value travels.
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum RegionReturnKind {
114    /// The value slot holds the raw representation, re-boxed by SlotKind.
115    Slot(SlotKind),
116    /// The value slot holds a VM REGISTER NUMBER whose current value
117    /// returns (lists: mutated in place through their pins, the register
118    /// still holds the same Rc).
119    Register,
120}
121
122/// A natively-compiled function: integer registers in, integer result out.
123/// `depth` is the LIVE LOGOS frame count at the call (the callee's own frame
124/// included) — native self-calls count against the same MAX_CALL_DEPTH the
125/// bytecode enforces.
126pub trait NativeFn: Send + Sync {
127    /// `args` are the marshalled parameter slots (scalars by value, floats
128    /// as bits, list params as don't-care placeholders); `pins` are the
129    /// pre-extracted `[vec_handle, ptr, len]` triples, one per list
130    /// parameter in declaration order, landing in the frame's pin slots.
131    fn call(&self, args: &[i64], pins: &[i64], depth: usize) -> NativeOutcome;
132    /// How the result re-boxes: a scalar from the raw i64, or the IDENTITY
133    /// of one of the caller's list arguments (return-by-parameter).
134    fn ret(&self) -> NativeRet {
135        NativeRet::Scalar(SlotKind::Int)
136    }
137    /// The chain's entry pointer, for the program's [`FnTable`].
138    fn entry_ptr(&self) -> i64;
139    /// The regcount value to PUBLISH next to the entry pointer:
140    /// `frame_size − 3`, which the call stencil's bound math and limit
141    /// planting are calibrated against (the plain register count for the
142    /// classic layout).
143    fn published_regc(&self) -> i64;
144}
145
146/// How a native function's return value re-boxes.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum NativeRet {
149    Scalar(SlotKind),
150    /// The function returns its j-th parameter (a list passed through —
151    /// the caller re-boxes by cloning the argument's Rc, preserving
152    /// identity).
153    ListParam(u8),
154    /// The function returns a list BY VEC HANDLE: a registry-owned fresh
155    /// allocation (detach and wrap) or one of the caller's list arguments
156    /// (match the pin handles, clone that argument). The backend resolves
157    /// it and emits [`NativeOutcome::ReturnValue`].
158    ListByHandle,
159}
160
161/// A DECLARED parameter's native representation.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163pub enum ParamKind {
164    Scalar(SlotKind),
165    /// `Seq of <scalar>` — pinned at the boundary exactly like a region
166    /// array (one borrow for the whole call, buffer pointer + length in
167    /// dedicated frame slots).
168    List(PinElem),
169}
170
171/// One materialized native frame for a PRECISE deopt (outermost first).
172/// `offset`/`return_pc`/`return_reg` describe the link from the PREVIOUS
173/// frame (they are meaningless for index 0, whose link belongs to the
174/// interpreted call site).
175#[derive(Debug)]
176pub struct NativeFrame {
177    pub offset: usize,
178    pub return_pc: usize,
179    pub return_reg: u16,
180    pub regs: Vec<i64>,
181    pub kinds: Vec<RegBox>,
182    /// Registers whose values the BACKEND already re-boxed (native-owned
183    /// fresh lists detached from the allocation registry at deopt — they
184    /// must survive into the materialized frame).
185    pub resolved: Vec<(u16, Value)>,
186}
187
188/// How one raw register slot re-boxes during frame materialization.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum RegBox {
191    /// Not defined at this point — leave the register as Nothing.
192    Dead,
193    Int,
194    Bool,
195    Float,
196    /// Holds the identity of list parameter j.
197    ListParam(u8),
198    /// Backend-resolved (see [`NativeFrame::resolved`]); the kind row
199    /// entry is a placeholder.
200    Resolved,
201}
202
203/// A backend that can try to compile one VM function to native code.
204pub trait NativeTier: Send + Sync {
205    /// Attempt to compile the function whose bytecode is `code`
206    /// (`code[0]` is the instruction at `entry_pc`; jump targets inside are
207    /// ABSOLUTE program pcs and need rebasing by `entry_pc`). Return None to
208    /// leave the function on the bytecode path forever.
209    ///
210    /// `callees` carries every program function's declared signature,
211    /// indexed by `FuncIdx` — calls to OTHER functions compile to table
212    /// dispatch when the callee's signature is all-scalar (an unpublished
213    /// callee deopts at the call until it tiers up on its own).
214    #[allow(clippy::too_many_arguments)]
215    fn compile_function(
216        &self,
217        code: &[Op],
218        entry_pc: usize,
219        constants: &[Constant],
220        param_count: u16,
221        register_count: u16,
222        self_fi: u16,
223        param_kinds: &[Option<ParamKind>],
224        ret_kind: Option<SlotKind>,
225        ctx: &NativeCtx,
226        callees: &[CalleeSig],
227    ) -> Option<Box<dyn NativeFn>>;
228
229    /// Attempt to compile a loop region. `code[0]` is the op at `head_pc`
230    /// (the back-edge target); the slice ends at the back-edge jump
231    /// (inclusive). Every jump out of the region must target `exit_pc`.
232    /// `named[r]` marks frame registers carrying a user-visible name — the
233    /// only slots whose post-region values are observable (scratches are
234    /// dead at statement boundaries by the compiler's allocation
235    /// discipline). `observed[r]` is the register's runtime kind at this hot
236    /// crossing — the speculation seed, re-checked by the guard set on every
237    /// entry. Default: regions stay on bytecode.
238    fn compile_region(
239        &self,
240        code: &[Op],
241        head_pc: usize,
242        exit_pc: usize,
243        constants: &[Constant],
244        register_count: u16,
245        named: &[bool],
246        observed: &[ObservedKind],
247        ctx: &NativeCtx,
248        callees: &[CalleeSig],
249    ) -> Option<Box<dyn RegionFn>> {
250        let _ = (
251            code, head_pc, exit_pc, constants, register_count, named, observed, ctx, callees,
252        );
253        None
254    }
255}
256
257/// The runtime kind a native frame slot carries: entry guards check the VM
258/// register's discriminant against it and copy the raw representation in
259/// (f64 travels as bits); write-back re-boxes by it.
260#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
261pub enum SlotKind {
262    Int,
263    Bool,
264    Float,
265}
266
267/// What the VM OBSERVED in each register at the hot back-edge that triggered
268/// region compilation — the kinds the adapter SPECULATES on (sound because
269/// every later entry re-checks them via the guard set).
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub enum ObservedKind {
272    Int,
273    Bool,
274    Float,
275    /// A List whose payload is the unboxed all-Int repr.
276    IntList,
277    /// A List whose payload is the half-width (`Vec<i32>`) narrowed Int repr
278    /// (`ListRepr::IntsI32`). Pinned as a 4-byte-element buffer (sign-extending
279    /// loads, truncating stores) under `LOGOS_NARROW_VM`.
280    IntListI32,
281    /// A List whose payload is the unboxed all-Float repr.
282    FloatList,
283    /// A List whose payload is the unboxed all-Bool repr.
284    BoolList,
285    /// A Map (the boxed kernel storage rides a pinned pointer; the int
286    /// fast lane verifies keys/values per helper call).
287    Map,
288    /// An ASCII `Text` carried AS BYTES: char index == byte index and char
289    /// count == byte length, so `item i of text` is a pinned 1-byte load and
290    /// char compares are integer byte compares. Observed ONLY when the Text is
291    /// ASCII; a non-ASCII Text stays [`ObservedKind::Other`] (keeps bailing) so
292    /// the per-char decode path runs and the JIT can never diverge.
293    TextBytes,
294    /// Anything the native frame cannot carry (non-ASCII Text, boxed lists,
295    /// Nothing…).
296    Other,
297}
298
299/// One pinned array for a region run: the register holding the list, the
300/// frame slots that receive its buffer pointer and length at entry, and the
301/// element kind the region speculated on. The VM borrows each DISTINCT
302/// `Rc<RefCell<…>>` exactly once for the whole native run (aliased registers
303/// resolve to the same buffer, so native writes through one name are visible
304/// through every other — and there is zero refcount or borrow traffic inside
305/// the loop). Arrays mutate IN PLACE: no write-back, and the deopt replay is
306/// sound by prefix-idempotence (the replay recomputes exactly the values the
307/// native prefix already wrote).
308/// Element kind of a pinned buffer (decides the stencil's access width:
309/// 8-byte for Int/Float bits, 1-byte for Bool).
310#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
311pub enum PinElem {
312    Int,
313    /// A pinned half-width Int buffer (`ListRepr::IntsI32` = `Vec<i32>`): the
314    /// stencil/regalloc access width is 4 bytes — loads sign-extend (`movsxd`),
315    /// stores truncate the low 4 bytes. The narrowing proof guarantees every
316    /// stored value fits `i32`, so truncation is lossless; a region-entry pin
317    /// only admits an `IntsI32` buffer for this lane.
318    IntI32,
319    Float,
320    Bool,
321    /// A pinned MAP: `vec_slot` carries `&mut MapStorage as *mut _`;
322    /// the ptr/len slots are unused. Get/set/contains go through pure
323    /// helpers against the kernel's own storage (iteration order is
324    /// untouched by construction).
325    Map,
326    /// A pinned ASCII `Text` carried AS BYTES: `ptr_slot` receives the string's
327    /// byte buffer pointer (`Rc<String>::as_bytes().as_ptr()`) and `len_slot`
328    /// its BYTE length (== char count for ASCII). Read-only — the `Rc<String>`
329    /// is never mutated in place, so no snapshot/rollback applies; the entry
330    /// pin RE-CHECKS ASCII and declines (deopt) on a non-ASCII Text.
331    TextBytes,
332    /// A pinned MUTABLE `Text` accumulator (`Set text to text + ch`): `vec_slot`
333    /// receives a `*mut Value` pointing AT THE VM REGISTER CELL (the cell is
334    /// stable for the whole native run, even though the `Rc<String>` inside it
335    /// reallocs/COWs on append). The `ptr_slot`/`len_slot` are unused. The
336    /// `logos_rt_str_append` helper grows the accumulator THROUGH this pointer
337    /// with EXACTLY the VM's `add_assign` semantics — in place when the
338    /// `Rc<String>` is sole-owned, copy-on-write (a fresh `Rc` written back into
339    /// the cell, the alias untouched) otherwise — so the tiered run is
340    /// bit-identical to the tree-walker for every alias case. The entry pin
341    /// declines (deopt to bytecode) if the observed value is not a `Text`. A
342    /// classic replay-from-head `Deopt` is NOT replay-idempotent over an
343    /// already-grown accumulator (it would double-append), so the VM SNAPSHOTS
344    /// the register's `Value` on entry and restores it before a classic replay
345    /// (see [`ArrayPin::mutated`]); a precise region resumes at the faulting op
346    /// and keeps the live grown value.
347    TextMut,
348}
349
350#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub struct ArrayPin {
352    pub reg: u16,
353    /// Frame slot receiving `&mut Vec<…> as *mut _ as i64` — the handle the
354    /// push helper extends through.
355    pub vec_slot: u16,
356    pub ptr_slot: u16,
357    pub len_slot: u16,
358    pub elem: PinElem,
359    /// This buffer is written IN PLACE (`SetIndex`) in a region that can take a
360    /// recoverable side exit AND replays from the head on deopt (non-precise).
361    /// Such a write is NOT replay-idempotent (a read-modify-write or swap would
362    /// double-apply), so the VM snapshots this buffer's full contents on region
363    /// entry and restores them on a classic `Deopt` before replaying. Read-only
364    /// buffers, push-only buffers (length truncate suffices), and precise
365    /// regions (resume-at-op, no replay) leave this `false`.
366    pub mutated: bool,
367}
368
369/// A natively-compiled LOOP REGION (OSR-lite): no arguments, no return —
370/// every effect flows through the enclosing frame's registers.
371/// What a REGION needs to know about a function it might call: the
372/// declared signature (all-scalar = replay-pure = inlinable) and the
373/// function index for its table slot.
374#[derive(Debug, Clone)]
375pub struct CalleeSig {
376    pub param_kinds: Vec<Option<ParamKind>>,
377    /// Declared scalar return; `None` keeps the callee out of regions.
378    pub ret: Option<SlotKind>,
379    /// LEVER B (a region may CALL a list-parameter function). Sound only when
380    /// the callee never reallocates a list-param buffer (no `Push` reaching a
381    /// list param) — then the caller's derived raw buffer pointer stays valid
382    /// across the call. `true` ⇒ list args don't move under the call.
383    pub list_params_stable: bool,
384    /// Every `Return` traces (through `Move`s) to a list PARAMETER, so the
385    /// returned handle aliases a passed-in (already-pinned) buffer rather than a
386    /// fresh one. Lets a LIST-returning callee be admitted; a scalar return is
387    /// governed by `ret` and ignores this. `false` for non-list / fresh-list
388    /// / multi-element-kind returns.
389    pub returns_list_param: bool,
390}
391
392/// A hoisted region-entry bounds check (V8 loop bound-check elimination): the
393/// loop's covered accesses run unchecked iff, at entry, the pinned array is
394/// long enough for the whole loop and the induction floor is in range. The VM
395/// declines the region (replays on bytecode) if it fails.
396#[derive(Debug, Clone, Copy)]
397pub struct HoistGuard {
398    /// Frame slot holding the pinned array's length (loaded by the prologue).
399    pub len_slot: u16,
400    /// VM register holding the loop's upper bound.
401    pub bound_reg: u16,
402    /// VM register holding the induction variable (its value at entry is the
403    /// loop's minimum for the remaining run).
404    pub iv_reg: u16,
405    /// `length >= bound + add_max` (max access index over the loop).
406    pub add_max: i32,
407    /// `iv + add_min >= 1` (min access index over the loop, 1-based).
408    pub add_min: i32,
409}
410
411pub trait RegionFn: Send + Sync {
412    /// Slots whose CURRENT values the region may read before writing (or
413    /// must preserve across a conditional write): the VM guards each one's
414    /// kind and copies its raw representation into the native frame.
415    fn guard_set(&self) -> &[(u16, SlotKind)];
416    /// Slots whose incoming values are provably DEAD (written before read,
417    /// e.g. the loop-condition scratch): no guard, no copy-in.
418    fn free_set(&self) -> &[u16];
419    /// Slots the region writes, with their re-boxing kinds.
420    fn write_set(&self) -> &[(u16, SlotKind)];
421    /// Arrays the region reads/writes through pinned buffers (see
422    /// [`ArrayPin`]). Default: none.
423    fn array_set(&self) -> &[ArrayPin] {
424        &[]
425    }
426    /// Hoisted region-entry bounds checks (see [`HoistGuard`]). Default: none.
427    fn hoist_guards(&self) -> &[HoistGuard] {
428        &[]
429    }
430    /// In-region `Return` support (see [`RegionReturn`]). Default: none —
431    /// such regions bail.
432    fn region_return(&self) -> Option<RegionReturn> {
433        None
434    }
435    fn frame_size(&self) -> usize;
436    /// Extra arena headroom beyond `frame_size` (regions that CALL
437    /// functions window their callees past the frame and need real call
438    /// arena depth). Default: none.
439    fn arena_slots(&self) -> usize {
440        0
441    }
442    /// PRECISE-deopt re-box kinds at `resume_pc`: one entry per VM register —
443    /// `Some(kind)` re-boxes the frame slot's raw bits, `None` keeps the VM
444    /// register's current value (a pinned array, or a read-only/unknown slot).
445    /// `None` overall ⇒ this region does not use precise deopt. Only meaningful
446    /// for a `RegionOutcome::DeoptAt { resume_pc }` outcome.
447    fn precise_kinds(&self, _resume_pc: usize) -> Option<&[Option<SlotKind>]> {
448        None
449    }
450    fn run(&self, frame: &mut [i64], depth: usize) -> RegionOutcome;
451}
452
453/// The process-wide tier, installed once by the binary that links a backend
454/// (e.g. `logicaffeine-jit`). The live VM constructors attach it to every
455/// program they run; nothing installs it on WASM, so the browser stays pure
456/// bytecode.
457static INSTALLED_TIER: std::sync::OnceLock<&'static (dyn NativeTier + 'static)> =
458    std::sync::OnceLock::new();
459
460/// Install `tier` as the process-wide native tier. Idempotent: the first
461/// install wins and later calls return `false`.
462pub fn install_native_tier(tier: &'static (dyn NativeTier + 'static)) -> bool {
463    INSTALLED_TIER.set(tier).is_ok()
464}
465
466/// The installed process-wide tier, if any.
467pub fn installed_native_tier() -> Option<&'static (dyn NativeTier + 'static)> {
468    INSTALLED_TIER.get().copied()
469}
470
471/// Calls before a function is considered hot.
472pub const NATIVE_TIER_THRESHOLD: u32 = 100;
473
474/// Back-edge crossings before a Main loop is considered hot.
475pub const REGION_TIER_THRESHOLD: u32 = 100;
476
477/// Per-region tier state (keyed by loop-head pc).
478pub(crate) enum RegionSlot {
479    Failed,
480    Ready {
481        rf: Box<dyn RegionFn>,
482        exit_pc: usize,
483        /// Guard failures + side exits since compile. A region that keeps
484        /// missing (bounds-probing loops, repr churn) re-runs work on every
485        /// entry — demote it to Failed so pure bytecode takes over.
486        misses: u32,
487    },
488}
489
490/// Consecutive guard failures / side exits before a Ready region demotes.
491pub(crate) const REGION_DEMOTE_AFTER: u32 = 8;
492
493/// Per-function tier state.
494pub(crate) enum NativeSlot {
495    /// Still profiling (or below threshold).
496    Untried,
497    /// Submitted to the BACKGROUND compiler (HOTSWAP §6); runs bytecode until the
498    /// worker's result is drained and published. Not re-submitted while pending.
499    Pending,
500    /// Compilation was attempted and bailed — never retried.
501    Failed,
502    /// Compiled; the guard still applies per call.
503    Ready(Box<dyn NativeFn>),
504}