Merge loops/sx-vm-extensions into architecture: serving-mode JIT (opt-in)

Brings the bytecode JIT to the persistent epoch serving mode, gated opt-in via
SX_SERVING_JIT=1 (default OFF → zero change for existing loops). Includes the
correctness fixes that make the JIT match the CEK interpreter, and the
interpret-only exclusions that keep continuation-based guest interpreters safe.

Kernel / shared:
- SX_SERVING_JIT gate in sx_server.ml (loads lib/compiler.sx + register_jit_hook
  only when opted in).
- compiler.sx-as-`compile` correctness: else-symbol in compile-cond/case/guard;
  OP_DIV rational; OP_EQ/_fast_eq rational+ListRef; callable? accepts VmClosure.
- Three composable interpret-only signals in jit_compile_lambda:
  (1) jit-exclude! name / "ns-*" prefix; (2) PUSH_HANDLER recursive bytecode
  scan (guard/handler-bind/Dream catch); (3) jit-exclude-callers-of! +
  code_refs_escaping_caller (call/cc-establishing form callers).

Per-guest interpret-only declarations in each guest runtime: smalltalk (dispatch
core + pharo-test-class), scheme (scheme-*/scm-*), erlang (er-*/erlang-*),
prolog (pl-*), common-lisp (cl-*/clos-* + condition-form callers), js
(js-*/jp-*), haskell (hk-*).

Verified under SX_SERVING_JIT=1 (== CEK, no hang): host 181, smalltalk 847,
scheme/flow 166, erlang 530, prolog 590/mod 390, haskell 285, common-lisp 487,
js 148, apl 152, datalog/forth/ocaml. run_tests --jit 4813/1131 (was 4809/1135,
improved), no-jit 4834/1110 (unchanged). Default-OFF gate => no loop regresses.
This commit is contained in:
2026-06-28 17:05:31 +00:00
6 changed files with 93 additions and 1 deletions

View File

@@ -4190,6 +4190,16 @@ let () =
match args with
| [String n] | [Symbol n] -> Bool (Sx_types.jit_name_excluded n)
| _ -> Bool false);
register "jit-exclude-callers-of!" (fun args ->
(* Register call/cc-establishing forms (e.g. cl-restart-case). Any function
whose bytecode references one of these is itself interpret-only — JIT
would force the form into a nested cek-run where its continuation can't
escape. A guest declares its condition-system / escaping forms here. *)
List.iter (fun a ->
match a with
| String n | Symbol n -> Hashtbl.replace Sx_types.jit_excluded_caller_names n ()
| _ -> ()) args;
Nil);
register "jit-reset-counters!" (fun _args ->
Sx_types.jit_compiled_count := 0;
Sx_types.jit_skipped_count := 0;

View File

@@ -502,6 +502,20 @@ let jit_name_excluded name =
String.length name >= String.length p
&& String.sub name 0 (String.length p) = p) !jit_excluded_prefixes
(** Names of functions that ESTABLISH an escaping continuation via call/cc
(e.g. Common-Lisp's [cl-restart-case] / [cl-handler-case] — the condition
system). Any SX function that *calls* one of these is itself unsafe to JIT:
JIT-compiling the caller forces the call/cc-wrapping form to run in a nested
cek-run, where invoking the captured continuation runs-to-completion-and-
returns instead of escaping — so a restart/non-local exit silently fails
and the body falls through (observed as result accumulation / no-abort).
These callers are NOT a fixed namespace (they are arbitrary user/test code),
so they cannot be prefix-excluded. Instead a guest declares its escaping
forms here (via [jit-exclude-callers-of!]) and [jit_compile_lambda] skips
any function whose constant pool references one of them. *)
let jit_excluded_caller_names : (string, unit) Hashtbl.t = Hashtbl.create 16
(** {2 JIT cache LRU eviction — Phase 2}
Once a lambda crosses the threshold, its [l_compiled] slot is filled.

View File

@@ -1156,6 +1156,22 @@ let rec code_uses_handler code =
(try code_uses_handler (code_from_value c) with _ -> false)
| _ -> false) code.vc_constants
(** True if [code] — or any nested closure code — references (in its constant
pool, as a GLOBAL_GET/CALL name) a function registered in
[Sx_types.jit_excluded_caller_names] (a call/cc-establishing form like
Common-Lisp's cl-restart-case/cl-handler-case). Such a caller must run on
the CEK so the continuation captured inside the called form can escape.
The constant-pool string IS the referenced symbol name, so membership is a
direct lookup; recurse into nested closure codes. Skipped entirely (no
Hashtbl walk) when no escaping forms are registered. *)
let rec code_refs_escaping_caller code =
Array.exists (fun c ->
match c with
| String s -> Hashtbl.mem Sx_types.jit_excluded_caller_names s
| Dict d when Hashtbl.mem d "bytecode" || Hashtbl.mem d "vc-bytecode" ->
(try code_refs_escaping_caller (code_from_value c) with _ -> false)
| _ -> false) code.vc_constants
let jit_compile_lambda (l : lambda) globals =
let fn_name = match l.l_name with Some n -> n | None -> "<anon>" in
if !_jit_compiling then (
@@ -1241,6 +1257,13 @@ let jit_compile_lambda (l : lambda) globals =
Printf.eprintf "[jit] SKIP %s: installs an exception handler (guard) — interpret-only\n%!"
fn_name;
None
end else if Hashtbl.length Sx_types.jit_excluded_caller_names > 0
&& code_refs_escaping_caller code then begin
(* Calls a call/cc-establishing form (e.g. cl-restart-case): must
run on the CEK so the captured continuation can escape. *)
Printf.eprintf "[jit] SKIP %s: calls a call/cc-establishing form — interpret-only\n%!"
fn_name;
None
end else
Some { vm_code = code; vm_upvalues = [||];
vm_name = l.l_name; vm_env_ref = effective_globals; vm_closure_env = Some l.l_closure }

View File

@@ -764,3 +764,17 @@
;; a compiled frame can't transfer control through a CEK continuation. Exclude
;; the cl-/clos- namespaces from JIT. See Sx_types.jit_excluded_prefixes.
(jit-exclude! "cl-*" "clos-*")
;; cl-restart-case / cl-handler-case / cl-handler-bind wrap their body in
;; call/cc (restarts + non-local handler exit). Any function that CALLS one of
;; these (e.g. SX fixtures driving the condition system: parse-recover,
;; interactive-debugger) must also be interpret-only: JIT'ing such a caller
;; forces the call/cc form into a nested cek-run where the captured
;; continuation runs-to-completion-and-returns instead of escaping, so a
;; restart fails to abort and the body falls through (accumulation/no-abort).
(jit-exclude-callers-of! "cl-restart-case" "cl-handler-case" "cl-handler-bind")
;; Also the INVOKE side: cl-invoke-restart / cl-invoke-debugger / cl-signal
;; trigger the continuation escape; a JIT'd caller can't let the escape
;; propagate out of its frame (e.g. make-policy-debugger building a debugger
;; hook that invokes a restart). Mark their callers interpret-only too.
(jit-exclude-callers-of! "cl-invoke-restart" "cl-invoke-debugger" "cl-signal" "cl-error-with-debugger")

View File

@@ -6999,4 +6999,4 @@
;; The JS evaluator (transpile.sx) uses call/cc for control flow (exceptions,
;; early return); a JIT-compiled frame can't escape through a CEK continuation.
;; Exclude the js- namespace from JIT. See Sx_types.jit_excluded_prefixes.
(jit-exclude! "js-*")
(jit-exclude! "js-*" "jp-*")

View File

@@ -203,3 +203,34 @@ general fix is propagate-don't-rerun (run_tests' hook semantics) but that
surfaces genuine mid-run miscompiles as errors and must land with fixing/
excluding those — deferred (shared CEK/VM change). The default-OFF gate makes
all of this opt-in, so nothing regresses by default.
---
## common-lisp residual resolved — call/cc-caller exclusion (2026-06-28)
Investigated the 6 CL opt-in-JIT failures. Findings:
- **geometry / mop-trace (0/0) are NOT JIT regressions** — they error "Undefined
symbol: refl-class-chain-depth-with" on BOTH CEK and JIT (the CLOS suites in
conformance.sh don't preload lib/guest/reflective/class-chain.sx). Pre-existing
harness gap; not counted in the 6.
- The **6 real failures** (parse-recover 4, interactive-debugger 2) were all
condition-system continuation escape. cl-restart-case/cl-handler-case/
cl-handler-bind wrap their body in call/cc. When an SX function driving the
condition system (parse-numbers, make-policy-debugger) is JIT-compiled, the
call/cc form runs in a NESTED cek-run where invoking the captured continuation
runs-to-completion-and-returns instead of escaping → restart fails to abort,
body falls through. Seen as accumulation ((1 3 0 3) vs (1 3)) and no-abort
(999 sentinel). Also produced a +3 double-execution over-count (490 vs 487).
Fix: a third interpret-only signal beyond name/prefix and PUSH_HANDLER —
`jit-exclude-callers-of!` registers call/cc-establishing/invoking form names;
`jit_compile_lambda` skips any function whose constant pool (recursively)
references one (`code_refs_escaping_caller`). Guarded so it's a no-op for guests
that don't register. CL registers cl-restart-case/cl-handler-case/cl-handler-bind
(establish) + cl-invoke-restart/cl-invoke-debugger/cl-signal/cl-error-with-debugger
(invoke). Result: **CL under SX_SERVING_JIT=1 = 487/0, exactly matching CEK.**
The three interpret-only signals now: (1) name / "ns-*" prefix [jit-exclude!],
(2) PUSH_HANDLER in bytecode [guard users, structural], (3) references a
registered escaping form [call/cc-establishing callers]. Together they cover the
continuation-unsafe surface without a deep VM continuation rewrite.