jolt/docs/foundational-runtime-spike-results.md
Dmitri Sotnikov ae3f9f6e84
Spike: localize the mandelbrot 15x floor (jolt-5vsp) (#143)
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>
2026-06-16 16:20:40 +00:00

92 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-IRC 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).