jolt/docs/foundational-runtime-lever1-native-codegen.md
Dmitri Sotnikov 393656d8d9
cgen: install native fns into the compile path under JOLT_CGEN (jolt-ihdp) (#146)
Wires src/jolt/cgen.janet into the backend's :def emit. With JOLT_CGEN=1 (off by
default, needs direct-linking), a plain defn of a numeric-leaf fn is compiled to
C at def time and the cfunction installed as the var root, so direct-linked
callers embed native code. The fn is not inline-stashed when cgen fires —
callers must call the C fn, not inline the bytecode body. ^:redef/^:dynamic stay
bytecode.

The leaf-first rule falls out: run calls count-point (a user var), so run isn't a
numeric leaf and stays bytecode, calling the native count-point over the cheap
forward crossing. mandelbrot 200: 224ms -> 12.4ms (~18x), result unchanged.

Adds JOLT_CGEN to ctx-shaping-env-vars (rides the disk-cache key) and :cgen? to
resolve-run-mode. Default path (cgen off) is a no-op: cgen-root returns nil and
the normal bytecode emit runs. Gate green (117 files). Test:
test/integration/cgen-pipeline-test.janet.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 17:12:48 +00:00

6.3 KiB
Raw Blame History

Lever 1 — Native codegen (jolt-IR → C): feasibility spike

Epic: jolt-5vsp · Date: 2026-06-16 Predecessor: the localization spike (docs/foundational-runtime-spike-results.md) showed the 15.4× mandelbrot floor is ~70% Janet-VM floor (only native codegen moves it) + ~30% loop-lowering (cheap backend fix, jolt-v28u). This spike probes lever 1's ceiling and the incremental hot-fn-in-C strategy before committing to a backend.

All legs return the identical result (3288753 at n=200). Numbers are means of 3 after warmup; the dev machine swaps, so treat these as orders-of-magnitude (the ≈ vs JVM call is robust; ±2ms is noise).

The native-C ceiling — it beats JVM

Native mandelbrot built as a Janet native module (spike/native/mandel.c):

Leg mean vs jolt (219ms) vs JVM (14.2ms)
native-C whole run (pure C, no Janet in loop) ~1012 ms ~1822× faster faster than JVM
Janet loop → C hot-fn (forward crossing) ~1113 ms ~18× faster ≈ JVM
C loop → janet_call bytecode (reverse crossing) ~152 ms ~no better ~11× slower
(reference) jolt-compiled 219 ms 15.4×
(reference) JVM Clojure 14.2 ms 1.0×

Verdict: lever 1 is validated and its ceiling is excellent. Compiling the hot compute path to C makes it ~1822× faster than today's jolt and edges out JVM Clojure — native code has no VM-dispatch floor at all. This is the only lever that touches the ~10.8× Janet-VM floor, and the payoff is the full gap.

The crossing-direction rule (the key strategic finding)

The boundary cost is wildly asymmetric:

  • Forward (bytecode → C): nearly free. A Janet bytecode loop calling a C hot-fn n² (=40 000) times runs at ~1113 ms — within ~15% of pure C. So you can compile just the inner hot fn to C and capture ~95% of the win while the outer loop stays bytecode. Incremental adoption works.
  • Reverse (C → janet_call → bytecode): ~3.5 µs/call. A C fn calling a bytecode helper per iteration runs at ~152 ms — no better than jolt today. The janet_call cost (entering the VM/fiber per call) dominates.

Design constraint → compile leaf-first / whole-hot-cluster. A fn is a profitable C-compilation candidate only if its hot path calls nothing that stays in bytecode — only primitives or other C-compiled fns. Cross the boundary only at cold edges. For mandelbrot, count-point is a leaf (calls only arithmetic primitives) → the ideal first target; compiling it alone captures the win (forward crossing), but a half-compiled hybrid that janet_calls back per iteration buys nothing.

The dynamic-compile path works (no jpm needed)

jolt's compile model is dynamic (analyze → IR → Janet → eval at runtime). Native codegen fits the same shape: a .so compiled with a plain cc invocation (no jpm/project.janet) loads at runtime via require and runs at full native speed (verified: run-c(200) correct, 13.5 ms cold).

cc -shared -fPIC -O2 -I/opt/homebrew/include -undefined dynamic_lookup \
   mandel.c -o mandel.so          # macOS; Linux drops -undefined dynamic_lookup
(require "path/to/mandel")          # loads at runtime, cfunctions callable

So the native tier mirrors today's interpret/compile hybrid: emit C for a hot fn → shell to ccrequire the .so → bytecode callers call into it via the (cheap, forward) native-module call path. Caching keyed by fn-source-hash mirrors the existing ctx image cache.

Toolchain confirmed (this machine)

  • janet.h present (/opt/homebrew/include/janet.h, Janet 1.41.2).
  • jpm declare-native builds a .so cleanly.
  • Direct cc (no jpm) builds a loadable .so.
  • C API used: janet_getnumber/getinteger, janet_wrap_number, janet_fixarity, janet_getfunction, janet_call, janet_cfuns, JANET_MODULE_ENTRY.

Status: wired into the compile path (JOLT_CGEN, opt-in)

src/jolt/cgen.janet (IR→C translator) is wired into the backend's :def emit via cgen-root, gated behind JOLT_CGEN=1 (off by default; needs direct-linking). When on, a plain defn of a numeric-leaf fn is compiled to C at def time and the cfunction installed as the var root — so direct-linked callers embed native code. The fn is NOT inline-stashed when cgen fires (callers must call the C fn, not inline the bytecode body). ^:redef/^:dynamic defns stay bytecode.

The leaf-first rule emerges for free: run calls count-point (a user var, not a native-op), so run isn't a numeric leaf and stays bytecode — calling the native count-point over the cheap forward crossing.

Measured end-to-end (jolt -m mandelbrot 200): 224 ms → 12.4 ms, ~18×, with the correct result — matching the spike's native-C ceiling. The default gate (cgen off) is unchanged. Tests: test/integration/cgen-pipeline-test.janet.

Known limitation: building core with JOLT_CGEN=1 would try to cgen core numeric-leaf fns into the cached ctx image, where embedded cfunctions may not serialize — keep cgen for app/user code until image-cache interaction is handled.

Open questions for the implementation (next beads)

  1. IR→C for the numeric subset. Translate jolt IR → C for proven-double arithmetic + tail loop/recur (count-point's shape). The native-arith type proof (jolt-3pl) that already gates native Janet arith is the same proof that gates C unboxing — reuse it. Start narrow: unbox doubles at entry, primitive ops inline, rebox at exit; bail to bytecode for any unsupported form.
  2. Boundary policy. Non-primitive args stay Janet values (no unbox); per-iteration calls allowed only to other C-compiled fns. Encode the leaf-first/cluster rule as the compile-candidate predicate.
  3. Trigger + cache. AOT at build/first-run vs lazy JIT on hot fns; .so cache keyed by source hash + flags (add to ctx-shaping-env-vars / image-cache machinery if it becomes a ctx knob).
  4. Coverage. Closures/upvalues, multi-arity, recur across the C boundary, portability of cc flags per platform.

Artifacts (spike/native/)

  • mandel.c — native mandelbrot: run-c (pure C), count-point-c (leaf cfn), run-callback (C loop → janet_call back, the reverse-crossing probe)
  • project.janetdeclare-native build
  • bench-native.janet — the three-leg benchmark + harness