Files
rose-ash/plans/jit-cache-architecture.md
giles 21e6351657
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
HS: batched conformance runner + JIT cache architecture plan
tests/hs-run-batched.js — fresh-kernel-per-batch conformance runner.
Solves the WASM kernel JIT-cache-saturation problem (compiled VmClosures
accumulate over a single process and slow tests at the tail of the run)
by spawning a child Node process per batch. Each batch starts with an
empty cache, so tests at index 1400 perform identically to tests at
index 100. Configurable batch size (HS_BATCH_SIZE, default 150) and
parallelism (HS_PARALLEL, default 1).

This is option 2 from the cache-architecture plan — the lowest-risk fix:
zero kernel changes, deterministic results, runs in the same time as the
single-process version when parallelism matches CPU count.

plans/jit-cache-architecture.md — sketches the SX-wide architectural
fix in three phases:

  1. Tiered compilation — call counter on lambdas; only JIT after K
     invocations. Filters out one-shot lambdas (test harness, dynamic
     eval, REPLs) at the source.
  2. LRU eviction — central cache with fixed budget. Predictable memory
     ceiling regardless of input pattern.
  3. Reset API — jit-reset!, jit-clear-cold!, jit-stats, jit-pin!
     primitives for app-driven cache management.

Layer split: cache datastructure + LRU in hosts/ocaml/lib/sx_jit_cache.ml
(new), VM integration in sx_vm.ml, primitives registered in
sx_primitives.ml, declarative spec in spec/primitives.sx, and SX-level
ergonomics (with-jit-threshold, with-fresh-jit, jit-report) in lib/jit.sx.
This is host-specific to the OCaml WASM kernel but the SX API surface is
shared across all hosted languages (HS, Common Lisp, Erlang, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:41:06 +00:00

8.4 KiB

JIT Cache Architecture — Tiered + LRU + Reset API

Problem statement

The OCaml WASM kernel JIT-compiles every lambda body on first call and caches the resulting vm_closure in a mutable slot on the lambda itself (Lambda.l_compiled, Component.c_compiled, Island.i_compiled). Cache growth is unbounded — there is no eviction, no threshold, no reset.

Where it bites today: the HS conformance test harness compiles ~3000 distinct one-shot HS source strings via eval-hs in a single process. Each compilation creates a fresh lambda → fresh vm_closure. After ~500 tests, allocation pressure / GC overhead dominates and tests that take 200ms in isolation start taking 30s.

Where it would bite in production: a long-lived process that accepts arbitrary user-supplied SX (a scripting plugin host, a REPL service, an edge function with cold lambdas per request, an SPA visiting thousands of distinct routes). Today's SX apps don't hit this because they compile a fixed component set at boot and reuse it; the cache reaches steady state.

Architecture

Three coordinated mechanisms, deployed in order:

1. Tiered compilation — "filter what enters the cache"

Most lambdas in our test harness are call-once-and-discard. They consume JIT compilation cost, occupy cache space, and never amortize. Solution: don't JIT until a lambda has been called K times.

OCaml changes:

(* sx_types.ml *)
type lambda = {
  ...
  mutable l_compiled  : vm_closure option;  (* unchanged *)
  mutable l_call_count: int;                (* NEW *)
}
(* sx_vm.ml — in cek_call_or_suspend *)
let jit_threshold = ref 4

let maybe_jit lam =
  match lam.l_compiled with
  | Some _ -> ()  (* already compiled *)
  | None ->
    lam.l_call_count <- lam.l_call_count + 1;
    if lam.l_call_count >= !jit_threshold then
      lam.l_compiled <- !jit_compile_ref lam globals

Tunable via primitive: (jit-set-threshold! N) (default 4; 1 = old behavior; ∞ = disable JIT).

Expected impact:

  • Cold lambdas (test harness, eval-hs throwaways) never enter the cache.
  • Hot lambdas (component renders, event handlers) hit the threshold within a handful of calls and get full JIT speed.
  • Eliminates the test-harness pathology entirely without touching cache size.

2. LRU eviction — "bound memory regardless of input"

Even with tiered compilation, a long-lived process eventually compiles enough hot lambdas to exceed memory budget. Pure LRU eviction with a fixed budget gives a predictable ceiling.

OCaml changes:

(* sx_jit_cache.ml — NEW module *)
type cache_entry = {
  closure : vm_closure;
  mutable last_used : int;  (* generation counter *)
  mutable pinned : bool;    (* hot-path opt-out *)
}

let cache : (int, cache_entry) Hashtbl.t = Hashtbl.create 256
let mutable cache_budget = 5000   (* lambdas, not bytes — easy to reason about *)
let mutable generation = 0

let lookup lambda_id = ...
let insert lambda_id closure = 
  generation <- generation + 1;
  Hashtbl.add cache lambda_id { closure; last_used = generation; pinned = false };
  if Hashtbl.length cache > cache_budget then evict_oldest ()
let pin lambda_id = ...

Migration: Lambda.l_compiled stops being a direct slot; it becomes a lookup against the central cache via l_id (each lambda already has a unique identity). Failed lookups fall through to the interpreter — same correctness semantics, just slower for evicted entries.

Tunable: (jit-set-budget! N) (default 5000; 0 = disable cache).

Pinning: (jit-pin! 'fn-name) keeps a function from ever being evicted. Use for stdlib helpers, hot rendering paths.

3. Manual reset API — "escape hatch for app checkpoints"

Some app patterns know exactly when their cache should be flushed:

  • A web server between request batches
  • An SPA on logout / navigation
  • A test runner between batches (yes, even with #1 + #2)
  • A REPL on :reset

Primitives:

Primitive Behavior
(jit-reset!) Drop all cache entries. Hot paths re-JIT on next call.
(jit-clear-cold!) Drop only entries that haven't been used in N generations.
(jit-stats) Returns dict: {:size N :budget M :hits H :misses I :evictions E}.
(jit-set-threshold! N) Raise/lower compilation threshold at runtime.
(jit-set-budget! N) Raise/lower cache size budget.
(jit-pin! sym) Pin a named function against eviction.
(jit-unpin! sym) Unpin.

All zero-cost when not called — just a few atomic counter increments.

Where it lives

The JIT is host-specific (OCaml WASM kernel). The plan splits across three layers:

hosts/ocaml/lib/sx_jit_cache.ml      NEW — cache datastructure + LRU
hosts/ocaml/lib/sx_vm.ml             Modified — call counter, lookup integration
hosts/ocaml/lib/sx_types.ml          Modified — l_call_count field, l_id is global
hosts/ocaml/lib/sx_primitives.ml     Modified — register jit-* primitives
spec/primitives.sx                   Modified — declarative spec for jit-* primitives
lib/jit.sx                            NEW — SX-level helpers + macros

lib/jit.sx would contain:

;; Convenience: temporarily change threshold
(define-macro (with-jit-threshold n & body)
  `(let ((__old (jit-stats)))
     (jit-set-threshold! ,n)
     (let ((__r (do ,@body))) (jit-set-threshold! (get __old :threshold)) __r)))

;; Convenience: drop cache before/after a block
(define-macro (with-fresh-jit & body)
  `(let ((__r (do (jit-reset!) ,@body))) (jit-reset!) __r))

;; Monitoring helper for dev mode
(define jit-report
  (fn ()
    (let ((s (jit-stats)))
      (str "jit: " (get s :size) "/" (get s :budget) " entries, "
           (get s :hits) " hits / " (get s :misses) " misses ("
           (* 100 (/ (get s :hits) (max 1 (+ (get s :hits) (get s :misses)))))
           "%)"))))

This is shared SX — every host language (HS, Common Lisp, Erlang, etc.) gets the same API for free.

Rollout

Phase 1: Tiered compilation (1-2 days)

  • Add l_call_count to lambda type
  • Wire counter increment in cek_call_or_suspend
  • Add jit-set-threshold! primitive
  • Default threshold = 1 (no change in behavior)
  • Bump default to 4 once test suite confirms stability
  • Verify: HS conformance full-suite run completes without JIT saturation

Phase 2: LRU cache (3-5 days)

  • Extract Lambda.l_compiled into central sx_jit_cache.ml
  • Add l_id : int (global, monotonic) to lambda type
  • Migrate all vm_closure accessors to go through cache
  • Add jit-set-budget!, jit-pin!, jit-unpin! primitives
  • Verify: same full-suite run with budget=100 — cache hit/miss ratio reasonable

Phase 3: Reset API + monitoring (1 day)

  • Add jit-reset!, jit-clear-cold!, jit-stats primitives
  • Add lib/jit.sx SX-level wrappers
  • Integrate into HS test runner: call jit-reset! between batches as belt-and-suspenders
  • Document in CLAUDE.md / migration notes

Phase 4: Production hardening (incremental)

  • Memory pressure hooks (browser performance.measureUserAgentSpecificMemory)
  • Bytecode interning (dedupe identical vm_closure bodies across lambdas)
  • Generational sweep on idle (browser requestIdleCallback)
  • These are nice-to-have, not required for correctness.

Testing

Each phase ships with:

  • Unit tests in spec/tests/test-jit-cache.sx (new file)
  • Conformance must remain 100% per-suite
  • Wall-clock benchmark: full HS suite single-process before/after

Phase 1 acceptance criterion: HS conformance suite completes in single process under 10 minutes wall time.

Phase 2 acceptance: same as 1 but with budget=500. Cache size stays bounded throughout the run; hit rate >90% on hot paths.

Phase 3 acceptance: jit-reset! between batches reduces test-harness wall time by >50% vs no reset (because hot stdlib stays cached, but test-specific lambdas don't accumulate).

Why this order

Tiered compilation is the highest-leverage change — it solves the test-harness problem at the source (most lambdas never enter the cache) without touching cache machinery. LRU is the safety net (unbounded growth still possible if every lambda is hot, e.g., huge dynamic component graph). Reset is the escape hatch for situations neither mechanism can handle (logout, hard memory pressure, app restart without process restart).

Doing them in reverse would invert the value — reset alone fixes nothing without app-level integration, and LRU without tiered compilation churns the cache constantly on cold lambdas.