diff --git a/bench/dump-mandelbrot-emit.janet b/bench/dump-mandelbrot-emit.janet new file mode 100644 index 0000000..7b196c7 --- /dev/null +++ b/bench/dump-mandelbrot-emit.janet @@ -0,0 +1,28 @@ +# Dump the Janet that jolt's backend emits for the mandelbrot hot fns, so we can +# A/B it against bench/mandelbrot-hand.janet and localize the ~1.43x jolt-over- +# hand-Janet gap measured in the foundational-runtime spike (jolt-5vsp). +(import ../src/jolt/api :as api) +(import ../src/jolt/backend :as backend) +(import ../src/jolt/reader :as reader) + +(def ctx (api/init-cached {:compile? true})) +(put (ctx :env) :direct-linking? true) +(put (ctx :env) :inline? true) +(api/eval-string ctx "(ns mandelbrot)") + +(def count-point-src + "(defn count-point [cr ci cap] (loop [i 0 zr 0.0 zi 0.0] (if (or (>= i cap) (> (+ (* zr zr) (* zi zi)) 4.0)) i (recur (inc i) (+ (- (* zr zr) (* zi zi)) cr) (+ (* 2.0 (* zr zi)) ci)))))") + +(def run-src + "(defn run [n] (let [cap 200 nd (* 1.0 n)] (loop [y 0 acc 0] (if (< y n) (let [ci (- (/ (* 2.0 y) nd) 1.0) row (loop [x 0 a 0] (if (< x n) (let [cr (- (/ (* 2.0 x) nd) 1.5)] (recur (inc x) (+ a (count-point cr ci cap)))) a))] (recur (inc y) (+ acc row))) acc))))") + +(api/eval-string ctx count-point-src) +(api/eval-string ctx run-src) + +(defn emit [src] + (string/format "%p" (backend/emit-ir ctx (backend/analyze-form ctx (reader/parse-string src))))) + +(print "===== count-point emitted Janet =====") +(print (emit count-point-src)) +(print "\n===== run emitted Janet =====") +(print (emit run-src)) diff --git a/bench/mandelbrot-hand-rec.janet b/bench/mandelbrot-hand-rec.janet new file mode 100644 index 0000000..d8e0249 --- /dev/null +++ b/bench/mandelbrot-hand-rec.janet @@ -0,0 +1,49 @@ +# mandelbrot — hand-written Janet that MIRRORS jolt's loop-lowering: every loop/ +# recur becomes a self-recursive local closure stored in a var and called once +# per iteration (see bench/dump-mandelbrot-emit.janet). If this lands at jolt's +# ~219ms (vs the while-loop mandelbrot-hand.janet's ~153ms), the ~1.43x jolt- +# over-hand-Janet gap is the recursive-closure loop lowering, not anything else. +# janet bench/mandelbrot-hand-rec.janet 200 + +(defn count-point [cr ci cap] + (var loopfn nil) + (set loopfn (fn [i zr zi] + (if (let [t (>= i cap)] (if t t (> (+ (* zr zr) (* zi zi)) 4))) + i + (loopfn (+ i 1) + (+ (- (* zr zr) (* zi zi)) cr) + (+ (* 2 (* zr zi)) ci))))) + (loopfn 0 0 0)) + +(defn run [n] + (def cap 200) + (def nd (* 1 n)) + (var yloop nil) + (set yloop (fn [y acc] + (if (< y n) + (let [ci (- (/ (* 2 y) nd) 1) + row (do + (var xloop nil) + (set xloop (fn [x a] + (if (< x n) + (let [cr (- (/ (* 2 x) nd) 1.5)] + (xloop (+ x 1) (+ a (count-point cr ci cap)))) + a))) + (xloop 0 0))] + (yloop (+ y 1) (+ acc row))) + acc))) + (yloop 0 0)) + +(defn main [& args] + (def n (if (> (length args) 1) (scan-number (get args 1)) 1000)) + (repeat 2 (run (div n 2))) + (def times @[]) + (var last-r 0) + (repeat 3 + (def t0 (os/clock)) + (def r (run n)) + (array/push times (* 1000.0 (- (os/clock) t0))) + (set last-r r)) + (printf "mandelbrot n %d result %d" n last-r) + (print "runs: " (string/join (map |(string (/ (math/round (* $ 10.0)) 10.0)) times) " ")) + (printf "mean: %.1f ms" (/ (sum times) 3))) diff --git a/bench/mandelbrot-hand.janet b/bench/mandelbrot-hand.janet new file mode 100644 index 0000000..08c8d53 --- /dev/null +++ b/bench/mandelbrot-hand.janet @@ -0,0 +1,53 @@ +# mandelbrot — hand-written idiomatic Janet, same nested loop as bench/mandelbrot.clj. +# This is the "optimal Janet" leg of the foundational-runtime spike (jolt-5vsp): +# comparing jolt-emitted Janet vs this vs JVM localizes the 15x compute floor — +# jolt-backend overhead vs the Janet VM's own floor. +# +# janet bench/mandelbrot-hand.janet 200 + +(defn count-point [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 [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 (count-point cr ci cap))) + (++ x)) + (set acc (+ acc a)) + (++ y)) + acc) + +(defn main [& args] + (def n (if (> (length args) 1) (scan-number (get args 1)) 1000)) + # warmup + (repeat 2 (run (div n 2))) + (def runs 3) + (def times @[]) + (var last-r 0) + (repeat runs + (def t0 (os/clock)) + (def r (run n)) + (def ms (* 1000.0 (- (os/clock) t0))) + (set last-r r) + (array/push times ms)) + (def mean (/ (sum times) runs)) + (printf "mandelbrot n %d result %d" n last-r) + (print "runs: " (string/join (map |(string (/ (math/round (* $ 10.0)) 10.0)) times) " ")) + (printf "mean: %.1f ms" mean)) diff --git a/docs/foundational-runtime-handoff.md b/docs/foundational-runtime-handoff.md index fbf4555..65059dd 100644 --- a/docs/foundational-runtime-handoff.md +++ b/docs/foundational-runtime-handoff.md @@ -85,7 +85,16 @@ Every other axis adds structural overhead **on top** of that floor. (jolt-4x9 element types + jolt-t6r). Helps dispatch. Bounded ceiling (still bytecode underneath). -## START HERE — the spike (do this before committing to any lever) +## START HERE — the spike (DONE — see results) + +**The spike ran 2026-06-16. Results: `docs/foundational-runtime-spike-results.md`.** +Outcome in one line: the 15.4× floor decomposes into a **Janet-VM floor ≈10.8× +JVM** (the dominant ~70%; only native codegen / lever 1 moves it) plus a **jolt +loop-lowering ≈1.43×** on top (cheap backend win — `loop`/`recur` is lowered to a +recursive closure called per iteration; emit Janet `while`+`var`/`set` instead; +bead **jolt-v28u**). Janet numbers are already unboxed (not a lever). Next: the +lever-1 jolt-IR→C spike for one hot fn (confirm Janet's incremental native-module +path first). The original spike instructions are preserved below for context. **Localize the 15× floor.** Build three `mandelbrot` implementations and compare: diff --git a/docs/foundational-runtime-spike-results.md b/docs/foundational-runtime-spike-results.md new file mode 100644 index 0000000..6bdb242 --- /dev/null +++ b/docs/foundational-runtime-spike-results.md @@ -0,0 +1,92 @@ +# 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.clj` on 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**: + +1. **Janet VM floor ≈ 10.8× JVM** (70% of the gap). Optimal hand-written Janet — + pure `while` loop 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. + +2. **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 every `loop`/`recur` to a **self-recursive + local closure called once per iteration**, not a `while` loop. 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 …)))` + and `emit-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 a `while` loop 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`/`recur` with scalar bindings to a Janet + `while` + `var`/`set` instead of a recursive closure. Closes the 1.43×, taking + `mandelbrot` from 15.4× → ~10.8× JVM. Filed separately (see epic children). + +## Open questions answered + +- **Are Janet numbers boxed?** No — already unboxed. The `while` leg 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's `number` is 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. The `mandelbrot` legs 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` — optimal `while` Janet (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).