Tree-shaking: drop library code unreachable from -main (lever 3/4)
`jolt build --tree-shake` (or deps.edn :jolt/build {:tree-shake true}) does
reachability DCE over the re-emitted app + library namespaces: keep -main, every
side-effecting (non-def) top-level form, and every def reachable from those; drop
the rest. A macro (expanded at AOT, never called at runtime) is prunable too.
Sound: bails (keeps everything) if REACHABLE code resolves vars by name at runtime
(eval/resolve/ns-resolve/requiring-resolve/find-var/intern/load-string/...), which a
static call graph can't follow. Unreached eval-using library code is simply shaken
away and never triggers the bail. clojure.core and the compiler image stay baked
(prelude + image blobs), so only re-emitted namespaces are shaken for now.
The reachability machinery is in emit-image.ss (records: keep?/fqn/refs/str via
reduce-ir-children) + build.ss (BFS + bail check). build-smoke covers it (drops the
unreachable `twice` macro, output unchanged). Opt-in; default builds are untouched.
full make test green.
Scope note: this shakes the re-emitted app/lib code only. Measurement shows jolt's
compiled code is ~5.8MB of a ~9.8MB binary, dominated by the clojure.core prelude
(~1.5-2MB) and the compiler image (~0.8MB) — both baked blobs this pass doesn't
touch. Those (shake-core, drop-compiler-when-no-eval) are the larger footprint wins,
filed as follow-ups.
This commit is contained in:
parent
3e0fb805da
commit
75ac93689b
4 changed files with 139 additions and 10 deletions
|
|
@ -84,6 +84,67 @@
|
|||
|
||||
(define (ei-emit-ns ns-name src) (ei-emit-ns* ns-name src #f #t))
|
||||
|
||||
;; --- tree-shaking (jolt build --tree-shake) ---------------------------------
|
||||
;; Reachability DCE over the re-emitted app + library forms: keep -main, every
|
||||
;; side-effecting (non-def) top-level form, and every def reachable from those;
|
||||
;; drop the rest (unused library code). Bails (keeps everything) if the app resolves
|
||||
;; vars by name at runtime (eval/resolve/...), which static reachability can't
|
||||
;; follow. clojure.core / the compiler stay baked (the prelude + image blobs), so
|
||||
;; only the re-emitted namespaces are shaken.
|
||||
(define dce-kw-op (keyword #f "op"))
|
||||
(define dce-kw-var (keyword #f "var"))
|
||||
(define dce-kw-def (keyword #f "def"))
|
||||
(define dce-kw-ns (keyword #f "ns"))
|
||||
(define dce-kw-name (keyword #f "name"))
|
||||
(define dce-reduce-children (var-deref "jolt.ir" "reduce-ir-children"))
|
||||
|
||||
;; "ns/name" of every :var reference anywhere in node, prepended to acc. Arg order
|
||||
;; (acc node) matches reduce-ir-children's fold fn, so it nests directly.
|
||||
(define (dce-collect-refs acc node)
|
||||
(if (eq? (jolt-get node dce-kw-op) dce-kw-var)
|
||||
(cons (string-append (jolt-get node dce-kw-ns) "/" (jolt-get node dce-kw-name)) acc)
|
||||
(dce-reduce-children dce-collect-refs acc node)))
|
||||
|
||||
;; The fqn of a bare top-level def (the only prunable form), else #f.
|
||||
(define (dce-def-fqn node)
|
||||
(and (eq? (jolt-get node dce-kw-op) dce-kw-def)
|
||||
(string-append (jolt-get node dce-kw-ns) "/" (jolt-get node dce-kw-name))))
|
||||
|
||||
;; A reference whose presence forces keep-everything (runtime name resolution).
|
||||
(define dce-bail-refs
|
||||
'("clojure.core/eval" "clojure.core/resolve" "clojure.core/ns-resolve"
|
||||
"clojure.core/requiring-resolve" "clojure.core/find-var" "clojure.core/intern"
|
||||
"clojure.core/load-string" "clojure.core/load-file" "clojure.core/load-reader"
|
||||
"clojure.core/load"))
|
||||
|
||||
;; One record per form: (vector keep? fqn refs str). keep? #t = a non-def form,
|
||||
;; always emitted, its refs are reachability roots; #f = a prunable def emitted only
|
||||
;; if fqn is reached. A macro is a prunable def (its expander isn't called at runtime
|
||||
;; in an AOT build). Strict (no guard) like the build's ei-emit-ns* path.
|
||||
(define (ei-emit-ns-records ns-name src)
|
||||
(let loop ((forms (ei-read-all src)) (acc '()))
|
||||
(if (null? forms)
|
||||
(reverse acc)
|
||||
(let ((f (car forms)))
|
||||
(ce-scan-requires! f ns-name)
|
||||
(cond
|
||||
((ei-ns-form? f) (loop (cdr forms) acc))
|
||||
((ce-macro-form? f)
|
||||
(let-values (((nm fn-form) (ce-defmacro->fn f)))
|
||||
(let* ((ctx (make-analyze-ctx ns-name))
|
||||
(ir (jolt-ce-run-passes (jolt-ce-analyze ctx fn-form) ctx))
|
||||
(str (ei-macro-string ns-name nm (jolt-ce-emit-top ir) #f))
|
||||
(refs (dce-collect-refs '() ir)))
|
||||
(loop (cdr forms) (cons (vector #f (string-append ns-name "/" nm) refs str) acc)))))
|
||||
(else
|
||||
(let* ((ctx (make-analyze-ctx ns-name))
|
||||
(ir (jolt-ce-run-passes (jolt-ce-analyze ctx f) ctx))
|
||||
(str (jolt-ce-emit-top ir))
|
||||
(fqn (dce-def-fqn ir))
|
||||
(refs (dce-collect-refs '() ir)))
|
||||
(loop (cdr forms)
|
||||
(cons (if fqn (vector #f fqn refs str) (vector #t #f refs str)) acc)))))))))
|
||||
|
||||
;; Scheme string literal for a ns/name — uses the runtime's own writer
|
||||
;; (printable ASCII identifiers only here).
|
||||
(define (ei-str-lit s) (with-output-to-string (lambda () (write s))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue