Fast startup: inference is opt-in, and dependencies compile once (jolt-87e)

Two things made a program run catastrophically slow to start (ring-app: 111s),
which is backwards — direct-linking should make startup FASTER (Clojure: smaller
classes, faster startup; Stalin: whole-program analysis is strictly ahead-of-
time, zero startup cost). We were doing the opposite: paying compile-time work at
every startup.

1. Decouple inference from direct-linking. Direct-linking (cheap compile-time
   call resolution) was bundled with the whole inference/specialization pipeline
   (inlining + scalar replacement + structural types + the per-ns re-emit
   fixpoint) — the expensive part. Now a program run direct-links + uses records
   by default (fast, faster calls) but the inference is opt-in via JOLT_OPTIMIZE
   (or an explicit JOLT_DIRECT_LINK / the native build). ring-app: 111s -> 6s.

2. Compile dependencies once. The baked core loads in ~10ms, but a program still
   re-compiled every dependency namespace (reitit, ring, selmer, honeysql, …)
   from source on every run — the whole 6s. Snapshot the ctx after the require
   chain to a per-project image (the same marshal/fork the core image uses),
   keyed on version + entry + source roots + flags, with a per-file mtime
   manifest so any edit invalidates it. First run compiles + caches; later runs
   fork the image (~10ms) and skip compilation. ring-app: 6s -> ~1s warm,
   competitive with the JVM. JOLT_NO_DEPS_CACHE disables.

Full gate green (all -m integration tests now exercise the cache); deps-image
invalidation verified (touching a source recompiles).
This commit is contained in:
Yogthos 2026-06-14 18:21:48 -04:00
parent cf271e218a
commit 9f1cebc097

View file

@ -8,6 +8,7 @@
(use ./plist) (use ./plist)
(use ./config) (use ./config)
(use ./reader) (use ./reader)
(import ./core :as jcore)
(def jolt-version "0.1.0") (def jolt-version "0.1.0")
@ -17,7 +18,9 @@
# result always matches the interpreter; see backend.janet / loader/eval-toplevel). # result always matches the interpreter; see backend.janet / loader/eval-toplevel).
# Set JOLT_INTERPRET=1 to force the tree-walking interpreter (debugging / A-B). # Set JOLT_INTERPRET=1 to force the tree-walking interpreter (debugging / A-B).
(def compile-default? (not (= "1" (os/getenv "JOLT_INTERPRET")))) (def compile-default? (not (= "1" (os/getenv "JOLT_INTERPRET"))))
(def ctx (init {:compile? compile-default?})) # A var, not a def: a -m run may replace it with a forked deps-image ctx that
# already has every dependency compiled (see run-main / the deps image cache).
(var ctx (init {:compile? compile-default?}))
(ctx-set-current-ns ctx "user") (ctx-set-current-ns ctx "user")
(defn read-line [prompt] (defn read-line [prompt]
@ -424,19 +427,77 @@
(defn- print-version [] (defn- print-version []
(print "jolt v" jolt-version)) (print "jolt v" jolt-version))
# --- Deps image cache: compile the dependencies ONCE -------------------------
# The baked binary loads core in ~10ms (it's marshaled in), but a program still
# re-compiles ALL of its dependency namespaces (reitit, ring, …) from source on
# every run — seconds of analyzer->IR->emit that never change between runs. Like
# Clojure's AOT or Stalin's compile-once model, snapshot the ctx AFTER the
# require chain and reuse it: the first run compiles + caches, later runs fork
# the image (~10ms) and skip compilation entirely. The key includes the jolt
# version, entry ns, source roots, and the compile flags; the image carries a
# manifest of every loaded source file's mtime, so any source edit (app or dep)
# invalidates it. JOLT_NO_DEPS_CACHE=1 disables.
(defn- deps-image-path [ns-name]
(def dir (or (os/getenv "JOLT_IMAGE_CACHE_DIR") (os/getenv "TMPDIR") "/tmp"))
(def env (ctx :env))
(def key (string/format "%q|%q|%q|%q|%q|%q|%q|%q"
jolt-version ns-name (os/getenv "JOLT_PATH")
(get env :direct-linking?) (get env :inline?)
(get env :shapes?) (get env :whole-program?)
(os/getenv "JOLT_FEATURES")))
(string dir "/jolt-deps-" (band (hash key) 0x7FFFFFFF) ".jimg"))
(defn- manifest-of [files]
(def m @{})
(each f files (when-let [st (os/stat f)] (put m f (st :modified))))
m)
(defn- manifest-current? [manifest]
(var ok true)
(eachp [f mt] manifest
(def st (os/stat f))
(unless (and st (= (st :modified) mt)) (set ok false)))
ok)
(defn- try-load-deps-image [path]
(when (and (not (os/getenv "JOLT_NO_DEPS_CACHE")) (os/stat path))
(def r (protect (fork (slurp path))))
(when (and (r 0) (ctx? (r 1)))
(def c (r 1))
(def manifest (get (c :env) :deps-manifest))
(when (and manifest (manifest-current? manifest))
# per-process wiring an image restore must redo (module state, not in the
# marshaled ctx) — same as init-cached does for the core image.
(jcore/install-print-method-cb! c)
c))))
(defn- save-deps-image [c path]
(unless (os/getenv "JOLT_NO_DEPS_CACHE")
(put (c :env) :deps-manifest (manifest-of (or (get (c :env) :loaded-files) @[])))
(def tmp (string path "." (os/getpid) ".tmp"))
(when (protect (spit tmp (snapshot c)))
(protect (os/rename tmp path)))))
(defn- run-main [ns-name argv] (defn- run-main [ns-name argv]
(when (nil? ns-name) (eprint "Error: -m/--main requires a namespace") (os/exit 1)) (when (nil? ns-name) (eprint "Error: -m/--main requires a namespace") (os/exit 1))
(set-command-line-args argv) (set-command-line-args argv)
(try (try
(do (do
(load-string ctx (string "(require '[" ns-name "])")) (def path (deps-image-path ns-name))
# whole-program (jolt-t34): every unit is loaded now — run the one closed- (if-let [cached (try-load-deps-image path)]
# world fixpoint over all of them before -main, so cross-ns types propagate # cache hit: every dependency is already compiled in the image
(when (get (ctx :env) :whole-program?) (do (set ctx cached) (ctx-set-current-ns ctx "user"))
(when-let [ip (get (ctx :env) :infer-program!)] (protect (ip ctx))) # cache miss: compile the requires, then snapshot for next time. Track the
# the batch pass is done — a namespace required later (inside -main) now # loaded files for the manifest.
# falls back to per-ns inference instead of being deferred and never run. (do
(put (ctx :env) :infer-program-done? true)) (put (ctx :env) :loaded-files @[])
(load-string ctx (string "(require '[" ns-name "])"))
# whole-program (jolt-t34): every unit is loaded now — run the one
# closed-world fixpoint over all of them before -main.
(when (get (ctx :env) :whole-program?)
(when-let [ip (get (ctx :env) :infer-program!)] (protect (ip ctx)))
(put (ctx :env) :infer-program-done? true))
(save-deps-image ctx path)))
(load-string ctx (string "(apply " ns-name "/-main *command-line-args*)"))) (load-string ctx (string "(apply " ns-name "/-main *command-line-args*)")))
([err fib] (report-error err fib) (os/exit 1)))) ([err fib] (report-error err fib) (os/exit 1))))
@ -533,7 +594,17 @@
:none)) :none))
(def dl (case dl-forced :off false :on true (not open-mode?))) (def dl (case dl-forced :off false :on true (not open-mode?)))
(put (ctx :env) :direct-linking? dl) (put (ctx :env) :direct-linking? dl)
(put (ctx :env) :inline? dl) # Inference / specialization (inlining + scalar replacement + structural type
# inference + the per-namespace re-emit fixpoint) is the EXPENSIVE part — like
# Stalin's whole-program analysis or a Clojure AOT pass, it belongs at BUILD
# time or behind an explicit opt-in, not paid on every program startup. Direct-
# linking (cheap compile-time call resolution; FASTER startup + calls, per
# Clojure's :direct-linking) does not need it. So default it OFF and turn it on
# with JOLT_OPTIMIZE (or an explicit JOLT_DIRECT_LINK, which signals a fully
# optimized build). The native build bakes an optimized image once.
(def optimize? (and dl (truthy? (or (os/getenv "JOLT_OPTIMIZE")
(= "1" (os/getenv "JOLT_DIRECT_LINK"))))))
(put (ctx :env) :inline? optimize?)
# Mark direct-linking that was AUTO-enabled by the run mode (vs explicitly # Mark direct-linking that was AUTO-enabled by the run mode (vs explicitly
# requested). The success checker (RFC 0006) rides on the inference for free, # requested). The success checker (RFC 0006) rides on the inference for free,
# but a casual program run shouldn't spam type warnings just because it now # but a casual program run shouldn't spam type warnings just because it now
@ -554,7 +625,7 @@
# other direct-linked modes; JOLT_NO_WHOLE_PROGRAM opts out. Namespaces required # other direct-linked modes; JOLT_NO_WHOLE_PROGRAM opts out. Namespaces required
# later (inside -main) fall back to per-ns inference (see loader). # later (inside -main) fall back to per-ns inference (see loader).
(put (ctx :env) :whole-program? (put (ctx :env) :whole-program?
(and dl (and optimize?
(not (os/getenv "JOLT_NO_WHOLE_PROGRAM")) (not (os/getenv "JOLT_NO_WHOLE_PROGRAM"))
(or main-entry? (os/getenv "JOLT_WHOLE_PROGRAM")))) (or main-entry? (os/getenv "JOLT_WHOLE_PROGRAM"))))
(cond (cond