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>
6.3 KiB
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) | ~10–12 ms | ~18–22× faster | faster than JVM |
| Janet loop → C hot-fn (forward crossing) | ~11–13 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 ~18–22× 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 ~11–13 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. Thejanet_callcost (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 cc → require 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.hpresent (/opt/homebrew/include/janet.h, Janet 1.41.2).jpm declare-nativebuilds a.socleanly.- 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)
- 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. - 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.
- Trigger + cache. AOT at build/first-run vs lazy JIT on hot fns;
.socache keyed by source hash + flags (add toctx-shaping-env-vars/ image-cache machinery if it becomes a ctx knob). - Coverage. Closures/upvalues, multi-arity,
recuracross the C boundary, portability ofccflags per platform.
Artifacts (spike/native/)
mandel.c— native mandelbrot:run-c(pure C),count-point-c(leaf cfn),run-callback(C loop →janet_callback, the reverse-crossing probe)project.janet—declare-nativebuildbench-native.janet— the three-leg benchmark + harness