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`.
9 KiB
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/nreplalready 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^:redefor^:dynamicvar 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 thecross-ns-param-penaltymemory; declared^RecordTypehints 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.ednexactly asrundoes (reusejolt.deps). - Default mode is release.
--optselects optimized;--devbuilds an unoptimized binary (useful to ship a debuggable build, not for the REPL). -onames 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:
- Assemble the program. Starting from the entry ns's
-main, load the transitiverequiregraph (the loader already does this) and collect every reachable top-level form, in dependency order, with its compile namespace. - Dead-code elimination. Re-target the uberscript DCE idea: compute
reachability from
-mainplus non-prunable forms, drop deaddefn/defn-. Bail to keep-all onresolve/ns-resolve/requiring-resolve/find-var/intern/eval/load-string(anything that defeats static reachability); keep and scandefmethod/defrecord/extendbodies so dispatch targets stay live. - Emit to a file. Run
analyze → emitfor 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). - Compile. Feed that source to Chez
compile-program(release) orcompile-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 3–4 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
- 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. jolt buildrelease. Front-end + file-emitting back-end path + flipinline-enabled?from the mode. Gate against the bench/corpus suites; binary output must pass the corpus arunpasses.- DCE. Re-port the reachability pass; gate with a test like the old
uberscript-dcecase. - 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.ffiloads native libraries at runtime; a closed-world build still needs that to work. The build must bake the FFI Clojure side and keep dynamicdlopenat run time. - Macro and
evalat runtime. Release/optimized are closed-world, but an app that callseval/load-stringneeds 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.