jolt/docs/rfc/0007-compilation-modes-and-binary-output.md
Yogthos 43778eafd7 jolt build: compile an app to a standalone binary (Phase 4 stages 1-2)
Restores the standalone-binary capability the Janet host had. `bin/joltc build
-m NS -o OUT` AOT-compiles an app into a single self-contained executable — the
whole runtime, clojure.core, stdlib and compiler embedded, no Chez install or
jolt source needed at runtime.

Pipeline (host/chez/build.ss, host primitive jolt.host/build-binary driven by
jolt.main's build command): resolve deps, load the entry namespace recording the
app namespaces in dependency order, re-emit each to Scheme, textually inline the
cli.ss runtime load sequence into one flat source + the app + a launcher, then
compile-file -> make-boot-file -> embed the boot as C bytes -> cc-link against
libkernel.a.

Two non-obvious bits: the compile pass runs in a fresh Chez, not the loaded
runtime (regex.ss shadows top-level `error`, which otherwise bakes a broken
reference into the boot); and the launcher installs scheme-start rather than
running -main at top level, since boot top-level forms execute during heap build
before argv is set, so args only reach -main through scheme-start.

Loader: a require of an in-memory namespace with no source file now no-ops, so
AOT'd app namespaces satisfy require in a built binary.

Mode flags (--opt/--dev, default release) are plumbed; the optimization passes
they gate come in a later stage. RFC 0007 has the design. Gated by `make
buildsmoke`.
2026-06-22 23:01:36 -04:00

9 KiB
Raw Permalink Blame History

RFC 0007 — Compilation modes and binary output

  • Status: Draft. No code yet; this fixes the design before Phase 4 work (beads jolt-cf1q.5) starts.
  • Champions: jolt maintainers
  • Created: 2026-06-22

Summary

Give jolt a jolt build command that emits a standalone executable, and a three-mode model that trades dynamism for speed:

  • dev — open/indirect linking, redefinition works, no perf focus. What repl/-e/nrepl already are.
  • release (default for a built program) — direct-linked, closed-world, per-namespace inference. Fast, still a recognizable Clojure runtime.
  • optimized — whole-program inference, fl*/fx* typed emission, Chez whole-program optimization. Fastest, sacrifices dynamic redefinition.

All three already have their machinery in the tree — the inference and inline passes were ported into jolt-core/jolt/passes/. What is missing is (a) a code path that writes emitted Scheme to disk and AOT-compiles it instead of eval'ing it in process, and (b) a switch that turns the dormant passes on. This RFC specifies both.

Motivation

The Janet host could produce binaries (jolt uberscript with dead-code elimination, jolt cgen-build for a single native binary). The Chez rehost dropped that machinery with the Janet host — it was Janet-specific (IR→C made sense when the host was Janet). On Chez the natural target is Chez's own native compiler, so the old emitters were deleted rather than re-pointed.

The result today: bin/joltc only ever loads the checked-in seed and compile-evals in process. jolt.main/-main dispatches run / -M / -A / repl / nrepl / task and nothing else. There is no way to ship an app as a binary, and the optimization passes are inert — jolt.host/inline-enabled? is a stub returning #f (host/chez/host-contract.ss:283), so every call links indirectly and nothing inlines. Jolt on Chez runs only in what this RFC calls dev mode.

The passes themselves survived intact:

  • jolt/passes/types.clj — structural collection-type inference (RFC 0005) + success-type checking (RFC 0006).
  • jolt/passes/inline.clj — inline + flatten-lets + scalar-replace, already gated "direct-link only".
  • jolt/passes/fold.clj — const-fold, including predicate folding.

So this is not a port of lost code. It is wiring: a build front-end, a file-emitting back-end path, and a mode switch over passes that already exist.

The three modes

Mode Linking Inference Redefinition Driver
dev indirect (var-deref per call) off yes repl, -e, nrepl, run of a file by default
release direct, closed-world per-namespace no (closed world) jolt build default
optimized direct + whole-program whole-program fixpoint, fl*/fx* no jolt build --opt / -M-style entry

The modes are points on one axis (how much the back end may assume is fixed), not three code paths. Each mode is a setting of two independent knobs the passes already understand:

  • direct-link? — may a call to a var compile to a direct procedure reference instead of a var-deref? Enables inlining and call-site folding. Opt-out is per-target: a ^:redef or ^:dynamic var always links indirect.
  • whole-program? — does inference see the whole reachable program at once (closed world), so a record param's callers in other namespaces are visible and its field reads specialize? Without it, inference is per-namespace and a cross-ns param de-specializes to :any (the cross-ns penalty documented in the cross-ns-param-penalty memory; declared ^RecordType hints are the open-world escape hatch).
dev:        direct-link? = false   whole-program? = false
release:    direct-link? = true    whole-program? = false
optimized:  direct-link? = true    whole-program? = true

fl*/fx* typed emission (unchecked flonum/fixnum Scheme ops) rides on optimized: only whole-program inference proves the types that make dropping the numeric-tower dispatch sound. Release keeps the tower.

CLI surface

jolt build [-m NS | FILE] [-o OUT] [--opt] [--dev]
  • Resolves deps.edn exactly as run does (reuse jolt.deps).
  • Default mode is release. --opt selects optimized; --dev builds an unoptimized binary (useful to ship a debuggable build, not for the REPL).
  • -o names the output (default the entry ns / file stem).
  • Output is a single executable: a Chez boot file plus the compiled program, launched by a thin wrapper, or a fully linked image where the platform allows. App libraries are baked in — no source roots needed at runtime.

Env opt-outs for the build (mirrors the Janet knobs, now keyed off the mode rather than the run): JOLT_NO_DIRECT_LINK forces open linking even in a build, JOLT_NO_WHOLE_PROGRAM keeps direct-link but per-namespace, JOLT_WHOLE_PROGRAM=1 forces whole-program. These already name the two knobs above.

Emission pipeline

The in-process spine today (host/chez/compile-eval.ss) is, per form:

source → read → analyze (→ IR) → emit (→ Scheme string) → (eval (read …))

jolt build keeps everything up to emit and replaces the per-form eval with accumulate-then-compile:

  1. Assemble the program. Starting from the entry ns's -main, load the transitive require graph (the loader already does this) and collect every reachable top-level form, in dependency order, with its compile namespace.
  2. Dead-code elimination. Re-target the uberscript DCE idea: compute reachability from -main plus non-prunable forms, drop dead defn/defn-. Bail to keep-all on resolve/ns-resolve/requiring-resolve/find-var/ intern/eval/load-string (anything that defeats static reachability); keep and scan defmethod/defrecord/extend bodies so dispatch targets stay live.
  3. Emit to a file. Run analyze → emit for each surviving form under the mode's knobs, concatenating the Scheme strings into one program source (the core overlay prelude first, exactly as the seed image is built today).
  4. Compile. Feed that source to Chez compile-program (release) or compile-whole-program (optimized, which also lets Chez cross-module inline), producing a compiled object, then link a boot file / wrapper into the final executable.

Steps 34 are the only genuinely new back-end code. Step 2 is a re-port of a deleted pass. Steps before them already run on every joltc invocation.

Turning the passes on

inline-enabled? is the existing gate. Today host-contract.ss hardwires it to #f. Under this RFC the build sets it (and a parallel whole-program? flag) from the chosen mode before compiling, so:

  • release: inline-enabled? → true, whole-program off. Per-ns inference and inlining light up; fl*/fx* stays off.
  • optimized: both on; the types pass runs its whole-program fixpoint and the back end may emit unchecked numeric ops where a flonum/fixnum is proven.

No new pass is required to reach release — it is the ported passes, ungated.

Staging

  1. Spike (de-risk Chez AOT). Emit a trivial whole program to disk and prove compile-program + boot/static link yields a standalone binary that runs. This is the only real unknown.
  2. jolt build release. Front-end + file-emitting back-end path + flip inline-enabled? from the mode. Gate against the bench/corpus suites; binary output must pass the corpus a run passes.
  3. DCE. Re-port the reachability pass; gate with a test like the old uberscript-dce case.
  4. Optimized. Whole-program flag, compile-whole-program, fl*/fx* emission. Gate on the bench suite (ray tracer, binary-trees) for size and speed vs the spike baseline.

Each stage is TDD against the existing gates (make test, make corpus, the bench/ programs). Modes land behind the build command, so dev — the only mode today — is unaffected until a stage proves out.

Open questions

  • Static vs. boot-file linking. A fully static Chez image is the smallest, most portable artifact but the most work to link; a boot file plus a stub launcher is the easy first cut. Spike decides which step 1 targets.
  • FFI in a built binary. jolt.ffi loads native libraries at runtime; a closed-world build still needs that to work. The build must bake the FFI Clojure side and keep dynamic dlopen at run time.
  • Macro and eval at runtime. Release/optimized are closed-world, but an app that calls eval/load-string needs the compiler present. Either ship the compiler image in the binary (larger) or reject those builds (the DCE bail-out already detects the calls).

Prior art in this repo

The optimization design these modes turn on is RFC 0004 (type hints), RFC 0005 (structural inference), RFC 0006 (success checking). The linking model — direct linking as a per-unit property, ^:redef/^:dynamic as the only opt-out — and the cross-ns specialization penalty are recorded in beads memories (jolt-linking-model, cross-ns-param-penalty). Phase 4 (jolt-cf1q.5) is the tracking issue.