jolt/docs/foundational-runtime-lever1-native-codegen.md
Dmitri Sotnikov 393656d8d9
cgen: install native fns into the compile path under JOLT_CGEN (jolt-ihdp) (#146)
Wires src/jolt/cgen.janet into the backend's :def emit. With JOLT_CGEN=1 (off by
default, needs direct-linking), a plain defn of a numeric-leaf fn is compiled to
C at def time and the cfunction installed as the var root, so direct-linked
callers embed native code. The fn is not inline-stashed when cgen fires —
callers must call the C fn, not inline the bytecode body. ^:redef/^:dynamic stay
bytecode.

The leaf-first rule falls out: run calls count-point (a user var), so run isn't a
numeric leaf and stays bytecode, calling the native count-point over the cheap
forward crossing. mandelbrot 200: 224ms -> 12.4ms (~18x), result unchanged.

Adds JOLT_CGEN to ctx-shaping-env-vars (rides the disk-cache key) and :cgen? to
resolve-run-mode. Default path (cgen off) is a no-op: cgen-root returns nil and
the normal bytecode emit runs. Gate green (117 files). Test:
test/integration/cgen-pipeline-test.janet.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 17:12:48 +00:00

120 lines
6.3 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`.
## Status: wired into the compile path (JOLT_CGEN, opt-in)
`src/jolt/cgen.janet` (IR→C translator) is wired into the backend's `:def` emit
via `cgen-root`, gated behind **`JOLT_CGEN=1`** (off by default; needs
direct-linking). When on, a plain defn of a numeric-leaf fn is compiled to C at
def time and the cfunction installed as the var root — so direct-linked callers
embed native code. The fn is NOT inline-stashed when cgen fires (callers must
call the C fn, not inline the bytecode body). `^:redef`/`^:dynamic` defns stay
bytecode.
The leaf-first rule emerges for free: `run` calls `count-point` (a user var, not
a native-op), so `run` isn't a numeric leaf and stays bytecode — calling the
native `count-point` over the cheap forward crossing.
**Measured end-to-end (`jolt -m mandelbrot 200`): 224 ms → 12.4 ms, ~18×**, with
the correct result — matching the spike's native-C ceiling. The default gate
(cgen off) is unchanged. Tests: `test/integration/cgen-pipeline-test.janet`.
Known limitation: building *core* with `JOLT_CGEN=1` would try to cgen core
numeric-leaf fns into the cached ctx image, where embedded cfunctions may not
serialize — keep cgen for app/user code until image-cache interaction is handled.
## 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