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:
Dmitri Sotnikov 2026-06-16 16:30:17 +00:00 committed by GitHub
parent ae3f9f6e84
commit a2ce6bb5f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 248 additions and 0 deletions

View 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
View 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);
}

View file

@ -0,0 +1,6 @@
(declare-project
:name "mandel-native-spike")
(declare-native
:name "mandel"
:source ["mandel.c"])