From a2ce6bb5f6334c56ef515410400ac01906f98671 Mon Sep 17 00:00:00 2001 From: Dmitri Sotnikov Date: Tue, 16 Jun 2026 16:30:17 +0000 Subject: [PATCH] Spike: native codegen (lever 1) feasibility for jolt-5vsp (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probes the ceiling and incremental strategy for compiling hot fns to native C, the only lever that moves the ~10.8x Janet-VM floor the localization spike found. Native-C mandelbrot (Janet native module) runs ~10-12ms — faster than JVM Clojure (14.2ms) and ~18-22x faster than jolt's 219ms. The boundary cost is asymmetric: a bytecode loop calling a C hot-fn 40k times is nearly free (~11ms), but a C fn calling back into bytecode via janet_call costs ~3.5us/call (~152ms, no win). So the strategy is leaf-first / whole-hot-cluster compilation, crossing only at cold edges. A plain cc-built .so (no jpm) loads at runtime via require at full speed, so the native tier fits jolt's dynamic compile model. Adds the spike artifacts under spike/native/ and the writeup. Next step is jolt-ihdp (IR->C for the numeric subset). No source changes. Co-authored-by: Yogthos --- ...ndational-runtime-lever1-native-codegen.md | 98 +++++++++++++++++++ spike/native/bench-native.janet | 53 ++++++++++ spike/native/mandel.c | 91 +++++++++++++++++ spike/native/project.janet | 6 ++ 4 files changed, 248 insertions(+) create mode 100644 docs/foundational-runtime-lever1-native-codegen.md create mode 100644 spike/native/bench-native.janet create mode 100644 spike/native/mandel.c create mode 100644 spike/native/project.janet diff --git a/docs/foundational-runtime-lever1-native-codegen.md b/docs/foundational-runtime-lever1-native-codegen.md new file mode 100644 index 0000000..11a3030 --- /dev/null +++ b/docs/foundational-runtime-lever1-native-codegen.md @@ -0,0 +1,98 @@ +# 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*. 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_call`s 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.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`. + +## 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.janet` — `declare-native` build +- `bench-native.janet` — the three-leg benchmark + harness diff --git a/spike/native/bench-native.janet b/spike/native/bench-native.janet new file mode 100644 index 0000000..316cbe9 --- /dev/null +++ b/spike/native/bench-native.janet @@ -0,0 +1,53 @@ +# Benchmark the native-C mandelbrot vs the spike's other legs (jolt-5vsp lever 1). +# janet spike/native/bench-native.janet 200 +(import ./build/mandel :as mandel) + +(defn bench [label f n] + (repeat 2 (f (div n 2))) + (def times @[]) + (var last-r 0) + (repeat 3 + (def t0 (os/clock)) + (set last-r (f n)) + (array/push times (* 1000.0 (- (os/clock) t0)))) + (printf "%-28s n %d result %d mean %.2f ms" + label n last-r (/ (sum times) 3))) + +# Leg A: whole run in native C (pure native-codegen ceiling). +(defn run-pure-c [n] (mandel/run-c n)) + +# Leg B: Janet `while` loop, but count-point is a native C cfunction called n^2 +# times — measures the Janet->C boundary-crossing cost (the incremental hybrid). +(defn run-boundary [n] + (def cap 200) + (def nd (* 1.0 n)) + (var acc 0) + (var y 0) + (while (< y n) + (def ci (- (/ (* 2.0 y) nd) 1.0)) + (var x 0) + (var a 0) + (while (< x n) + (def cr (- (/ (* 2.0 x) nd) 1.5)) + (set a (+ a (mandel/count-point-c cr ci cap))) + (++ x)) + (set acc (+ acc a)) + (++ y)) + acc) + +# Leg C: C run loop calling a Janet bytecode count-point back via janet_call n^2 +# times — the reverse crossing (hot C fn -> cold bytecode helper). +(defn count-point-janet [cr ci cap] + (var i 0) (var zr 0.0) (var zi 0.0) + (while (and (< i cap) (<= (+ (* zr zr) (* zi zi)) 4.0)) + (def nzr (+ (- (* zr zr) (* zi zi)) cr)) + (def nzi (+ (* 2.0 (* zr zi)) ci)) + (set zr nzr) (set zi nzi) (++ i)) + i) +(defn run-callback [n] (mandel/run-callback n count-point-janet)) + +(defn main [& args] + (def n (if (> (length args) 1) (scan-number (get args 1)) 200)) + (bench "native-C whole run" run-pure-c n) + (bench "Janet loop -> C count-point" run-boundary n) + (bench "C loop -> janet_call back" run-callback n)) diff --git a/spike/native/mandel.c b/spike/native/mandel.c new file mode 100644 index 0000000..f555177 --- /dev/null +++ b/spike/native/mandel.c @@ -0,0 +1,91 @@ +/* Native-C mandelbrot, exposed as a Janet module — the lever-1 ceiling probe for + * the foundational-runtime epic (jolt-5vsp). Measures: + * (1) mandel/run-c — whole run in C (count_point inlined in C). The pure + * native-codegen ceiling: no Janet in the hot loop. + * (2) mandel/count-point-c — just count_point exposed as a Janet cfunction, so a + * Janet `while` loop can call it n^2 times. Measures the + * Janet->C boundary-crossing cost — the incremental + * hybrid (hot fn in C, caller still bytecode) pays this. + * Build: jpm --local build (project.janet declares the native module). */ +#include + +/* Pure C. cr/ci/cap are doubles; cap compared as int iteration count. */ +static long count_point(double cr, double ci, long cap) { + long i = 0; + double zr = 0.0, zi = 0.0; + while (i < cap && (zr*zr + zi*zi) <= 4.0) { + double nzr = zr*zr - zi*zi + cr; + double nzi = 2.0*zr*zi + ci; + zr = nzr; zi = nzi; + i++; + } + return i; +} + +static long run_c(long n) { + long cap = 200; + double nd = (double)n; + long acc = 0; + for (long y = 0; y < n; y++) { + double ci = (2.0*y)/nd - 1.0; + long a = 0; + for (long x = 0; x < n; x++) { + double cr = (2.0*x)/nd - 1.5; + a += count_point(cr, ci, cap); + } + acc += a; + } + return acc; +} + +static Janet cfun_run_c(int32_t argc, Janet *argv) { + janet_fixarity(argc, 1); + long n = (long)janet_getinteger(argv, 0); + return janet_wrap_number((double)run_c(n)); +} + +/* count_point exposed for the Janet-loop-calls-C boundary test. */ +static Janet cfun_count_point_c(int32_t argc, Janet *argv) { + janet_fixarity(argc, 3); + double cr = janet_getnumber(argv, 0); + double ci = janet_getnumber(argv, 1); + long cap = (long)janet_getinteger(argv, 2); + return janet_wrap_number((double)count_point(cr, ci, cap)); +} + +/* run loop in C, but count_point is a Janet function called back via janet_call + * n^2 times — the reverse crossing: a C-compiled hot fn invoking a cold bytecode + * helper. Measures janet_call overhead (the cost the hybrid pays when native code + * calls back into the bytecode world). */ +static Janet cfun_run_callback(int32_t argc, Janet *argv) { + janet_fixarity(argc, 2); + long n = (long)janet_getinteger(argv, 0); + JanetFunction *cp = janet_getfunction(argv, 1); + long cap = 200; + double nd = (double)n; + long acc = 0; + for (long y = 0; y < n; y++) { + double ci = (2.0*y)/nd - 1.0; + long a = 0; + for (long x = 0; x < n; x++) { + double cr = (2.0*x)/nd - 1.5; + Janet args[3] = { janet_wrap_number(cr), janet_wrap_number(ci), + janet_wrap_number((double)cap) }; + Janet r = janet_call(cp, 3, args); + a += (long)janet_unwrap_number(r); + } + acc += a; + } + return janet_wrap_number((double)acc); +} + +static const JanetReg cfuns[] = { + {"run-c", cfun_run_c, "(mandel/run-c n) whole mandelbrot run in native C."}, + {"count-point-c", cfun_count_point_c, "(mandel/count-point-c cr ci cap) one point, native C."}, + {"run-callback", cfun_run_callback, "(mandel/run-callback n count-point-fn) C loop calling a Janet fn back via janet_call."}, + {NULL, NULL, NULL} +}; + +JANET_MODULE_ENTRY(JanetTable *env) { + janet_cfuns(env, "mandel", cfuns); +} diff --git a/spike/native/project.janet b/spike/native/project.janet new file mode 100644 index 0000000..327f1b6 --- /dev/null +++ b/spike/native/project.janet @@ -0,0 +1,6 @@ +(declare-project + :name "mandel-native-spike") + +(declare-native + :name "mandel" + :source ["mandel.c"])