From d5fea19a4201dc6782760b8370474435b5730a38 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 23 Jun 2026 21:30:28 -0400 Subject: [PATCH] docs: document tree-shaking + the runtime-resolution limitation README + tools-deps.md cover --tree-shake and --direct-link: what tree-shaking does (whole-program reachability over app + libraries + clojure.core, drop unreachable, drop the compiler for no-eval apps), and why it bails to keep-all when reachable code resolves vars by name at runtime (eval/resolve/ns-resolve/...), with the diagnostic output and how to make an app shakeable. Notes the Stalin soundness model. --- README.md | 18 ++++++++++++++++-- docs/tools-deps.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c935ac1..fa808c5 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,22 @@ bin/joltc build -m myapp.core -o myapp # compile myapp.core's -main into ./mya ``` Modes trade dynamism for speed: the default (release) build uses the proven code -generator; `--opt` also runs the inference + scalar-replacement passes over the -closed-world program; `--dev` is unoptimized. +generator; `--opt` also runs the inference + inlining + scalar-replacement passes +over the closed-world program; `--dev` is unoptimized. + +Two opt-in closed-world flags cut dispatch cost and binary size: + +```bash +bin/joltc build -m myapp.core --direct-link # app->app calls bind directly (no var lookup) +bin/joltc build -m myapp.core --tree-shake # ship only code reachable from -main +``` + +`--tree-shake` walks the call graph across your app, its libraries, and +`clojure.core`, drops everything unreachable from `-main` (and the compiler itself +when the app never `eval`s), and typically removes 1–2 MB. It stays sound by bailing +out — keeping everything, and reporting which library is responsible — when reachable +code resolves vars by name at runtime (`eval`/`resolve`/`ns-resolve`/…). See +[docs/tools-deps.md](docs/tools-deps.md) and `docs/rfc/0007`. This needs Chez's kernel development files (`libkernel.a`, `scheme.h`) and a C compiler. They come with a from-source Chez install; a distro `chezscheme` diff --git a/docs/tools-deps.md b/docs/tools-deps.md index 8bce643..a5501ed 100644 --- a/docs/tools-deps.md +++ b/docs/tools-deps.md @@ -104,6 +104,46 @@ ordinary builds (including `release` and `--opt`) stay dynamically linked. A var marked `^:redef` or `^:dynamic` stays indirect even under `--direct-link`, and calls into `clojure.core` stay indirect in every mode. +## Tree-shaking + +`--tree-shake` (or `:jolt/build {:tree-shake true}`) ships only the code reachable +from `-main`. The build constructs one call graph spanning the app, every resolved +library, and the `clojure.core`/stdlib prelude, then keeps `-main`, every +side-effecting top-level form (so a `defmethod`/`defrecord`/protocol registration +keeps its targets live), and everything reachable from those — dropping the rest. A +reference counts whether it's a call or a value (`#'x`, a fn passed to `map`, a fn +stored in a map): any reference keeps its target live, so nothing reachable is ever +dropped. An app that never compiles at runtime (no reachable `eval`/`load-string`) +also drops the analyzer and back end from the binary. Typical savings are 1–2 MB; +behaviour is unchanged. + +**It bails — keeps everything — when reachable code resolves a var by name at +runtime** (`eval`, `resolve`, `ns-resolve`, `requiring-resolve`, `find-var`, +`intern`, `load-string`, `load-file`). A static call graph can't follow a runtime +`resolve`, so dropping anything would be unsound. The build prints which definitions +forced the bail: + +``` +jolt build: tree-shake skipped (reachable code resolves vars at runtime): + selmer.filters/generate-json -> clojure.core/resolve + clojure.tools.logging/call-str -> clojure.core/ns-resolve +``` + +These are almost always libraries, not your code — `resolve` is how mature Clojure +libraries implement plugin systems and optional integrations (a logging backend +chosen at runtime, a template filter that lazily loads an optional dependency). On +the JVM that costs nothing; in a closed-world binary it defeats reachability. To make +an app tree-shakeable, keep runtime resolution off the *reachable* path: a backend +that's fixed on jolt can be referenced directly rather than resolved (the jolt +`tools.logging` port dropped the JVM's dynamic factory selection for exactly this), +and an optional integration you don't use can be dropped or hard-wired. Unreached +`resolve`-using code is shaken away like anything else — only resolution on the live +path triggers the bail. + +The closed-world soundness model follows Stalin's dead-code analysis: in a program +with no `eval`, a definition is live iff it is referenced (called or as a value) from +a root, transitively. + ## Limitations - Pure `clj`/`cljc` only — JVM interop, host classes, and unimplemented