Spike: native codegen (lever 1) feasibility for jolt-5vsp (#144)
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 <yogthos@gmail.com>
This commit is contained in:
parent
ae3f9f6e84
commit
a2ce6bb5f6
4 changed files with 248 additions and 0 deletions
98
docs/foundational-runtime-lever1-native-codegen.md
Normal file
98
docs/foundational-runtime-lever1-native-codegen.md
Normal file
|
|
@ -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
|
||||||
53
spike/native/bench-native.janet
Normal file
53
spike/native/bench-native.janet
Normal file
|
|
@ -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))
|
||||||
91
spike/native/mandel.c
Normal file
91
spike/native/mandel.c
Normal file
|
|
@ -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 <janet.h>
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
6
spike/native/project.janet
Normal file
6
spike/native/project.janet
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
(declare-project
|
||||||
|
:name "mandel-native-spike")
|
||||||
|
|
||||||
|
(declare-native
|
||||||
|
:name "mandel"
|
||||||
|
:source ["mandel.c"])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue