The jolt-vs-hand-Janet-vs-JVM mandelbrot comparison splits the 15.4x floor into two layers: a Janet-VM floor (~10.8x JVM, optimal while-loop Janet over unboxed doubles — only native codegen moves it) plus a ~1.43x jolt loop- lowering overhead on top. The overhead is entirely the loop/recur -> recursive- closure-called-per-iteration lowering; hand-Janet written the same way matches jolt, while a while+var/set version is 1.43x faster. So a cheap backend win (jolt-v28u) sits above the structural native-codegen lever. Adds the spike artifacts under bench/ and the results writeup; marks the spike done in the handoff. No source changes. Co-authored-by: Yogthos <yogthos@gmail.com>
4.5 KiB
Foundational Runtime Spike — Results (the 15× floor, localized)
Epic: jolt-5vsp · Date: 2026-06-16
Spike: the START HERE section of docs/foundational-runtime-handoff.md —
jolt vs hand-written-Janet vs JVM mandelbrot, to localize the ~15× compute
floor before committing to native codegen (lever 1) vs a backend fix.
Setup
Three implementations of the same nested mandelbrot loop, all returning the identical result (3288753 at n=200, confirming correctness across all legs):
- jolt-compiled —
bench/mandelbrot.clj(jolt -m mandelbrot 200, WP + direct-link on) - hand-Janet (
while) —bench/mandelbrot-hand.janet(idiomatic Janet:while+var/set) - JVM Clojure —
bench/mandelbrot.cljon the JVM
Plus a diagnostic fourth leg:
- hand-Janet (recursive) —
bench/mandelbrot-hand-rec.janet: hand Janet that mirrors jolt's loop lowering (self-recursive local closure called per iteration), to test whether the loop lowering alone explains jolt's overhead.
Numbers are stable and sandwiched (A/B/A/B); machine noise < 1%.
The numbers (n=200, mean of 3, after warmup)
| Leg | mean | × JVM |
|---|---|---|
| JVM Clojure | 14.2 ms | 1.0× |
hand-Janet (while) |
153.4 ms | 10.8× |
| hand-Janet (recursive, mirrors jolt) | 215.3 ms | 15.2× |
| jolt-compiled | 219.0 ms | 15.4× |
What this localizes
The 15.4× floor decomposes into two distinct layers:
-
Janet VM floor ≈ 10.8× JVM (70% of the gap). Optimal hand-written Janet — pure
whileloop over unboxed doubles, zero allocation — is still ~11× slower than JVM Clojure. This is the cost of the Janet bytecode VM itself (no JIT, no native code). Only native codegen (lever 1) can touch this. It is the dominant share and validates lever 1 as the big structural lever. -
jolt backend loop-lowering ≈ 1.43× on top (the remaining 30%). jolt is
219 / 153 = 1.43×slower than optimal Janet. The diagnostic leg pins this entirely to one cause: jolt lowers everyloop/recurto a self-recursive local closure called once per iteration, not awhileloop. Hand-Janet written that same way (recursive leg) lands at 215 ms ≈ jolt's 219 ms — so the recursive-closure lowering accounts for essentially all of jolt's backend overhead on pure-compute code.See the emitted Janet (
bench/dump-mandelbrot-emit.janet):emit-loop(src/jolt/backend.janet:210) produces(do (var L nil) (set L (fn (i zr zi) … (L (+ i 1) …))) (let (…) (L …)))andemit-recur(:228) produces the per-iteration call(L …). It relies on Janet TCO for stack safety, but each iteration still pays a function invocation (frame setup + arg bind) that awhileloop skips.
Decision
The handoff posed it as binary (Janet-VM floor or backend headroom). It is both, now sized:
- Native codegen (lever 1) is the only thing that moves the dominant ~70%. Confirmed as the big lever. Pursue the incremental jolt-IR→C spike for one hot fn next, per the handoff.
- A cheap, localized ~30% win sits in the backend, independent of any new
runtime: lower tail-position
loop/recurwith scalar bindings to a Janetwhile+var/setinstead of a recursive closure. Closes the 1.43×, takingmandelbrotfrom 15.4× → ~10.8× JVM. Filed separately (see epic children).
Open questions answered
- Are Janet numbers boxed? No — already unboxed. The
whileleg does pure double arithmetic at a steady 153 ms with no allocation and no GC stutter, and matches the other legs bit-for-bit. Janet'snumberis an immediate IEEE double (stored inline in the Janet value, not heap-allocated). Unboxing is not a lever; it's done. - GC share of
binary-trees— not measured here (the dev machine swaps heavily, which distorts alloc-heavy benches; the handoff flags this). Size lever 2 on a clean machine. Themandelbrotlegs are alloc-free so are unaffected and trustworthy. - Janet native-module / incremental C path — not yet confirmed; this is the gating question for the lever-1 spike (hot fns → C, rest → bytecode).
Artifacts (kept in bench/)
mandelbrot-hand.janet— optimalwhileJanet (the Janet VM floor reference)mandelbrot-hand-rec.janet— recursive-closure Janet (the loop-lowering diagnostic)dump-mandelbrot-emit.janet— dumps the Janet jolt emits for the hot fns
The bench harness (bench/run.sh) ignores these (it iterates a fixed bench list).