jolt/docs/foundational-runtime-lever1-native-codegen.md
Dmitri Sotnikov a2ce6bb5f6
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>
2026-06-16 16:30:17 +00:00

98 lines
5.1 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.

# 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) | **~1012 ms** | **~1822× faster** | **faster than JVM** |
| Janet loop → C hot-fn (forward crossing) | ~1113 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 ~1822× 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 ~1113 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