compiler: IR pass pipeline + constant folding (jolt-2om, nanopass-lite)

jolt.passes is the new portable pipeline stage between the analyzer and the
back end: pure IR -> IR rewrites, total over node :ops (unknown ops pass
through with folded children), loaded with the compiler namespaces and
resolved lazily by analyze-form (JOLT_NO_IR_PASSES=1 disables — the same
escape-hatch pattern as the macro oracle). The shape is flatiron's opt.clj
applied to the jolt IR, which is what jolt-2om asked for.

The first pass is constant folding: a call of a foldable numeric SEED fn
(the later tiers don't exist when the compiler loads) whose args are all
constant numbers becomes a constant, and an if with a constant test becomes
the taken branch (dead-branch elimination — the untaken side never even
resolves). Folding computes with the ACTUAL jolt fns, so results match
runtime semantics by construction; a fold that would throw (mod 5 0) is
left for runtime.

Two walk lessons paid for in debugging: let/loop bindings are
[name init-ir] PAIRS, not maps (assoc'ing :init into a pair corrupts it);
and a throw inside the interpreted pass unwinds past the interpreter's ns
restores, so analyze-form restores the compile ns after the (protected)
pass call — without that, one pass error left current-ns in jolt.passes and
the rest of the tier compile resolved against the wrong namespace (sort-by
landed on the 2-arg JANET builtin).

ir-passes-test pins folds, conservatism (free vars, throwing folds), and
end-to-end eval. Gate exit 0.
This commit is contained in:
Yogthos 2026-06-10 19:29:32 -04:00
parent 40da75cee4
commit 35e8821a92
3 changed files with 151 additions and 2 deletions

99
jolt-core/jolt/passes.clj Normal file
View file

@ -0,0 +1,99 @@
(ns jolt.passes
"IR optimization passes (nanopass-lite, jolt-2om). Each pass is a pure
IR -> IR rewrite, total over node :ops (unknown ops pass through with
folded children, so adding a node kind can't silently break a pass), run
in a fixed order by run-passes between the analyzer and the back end.
Portable Clojure: same constraint as jolt.analyzer kernel-tier fns +
seed primitives only (it loads with the compiler namespaces).")
;; Folding computes with THE ACTUAL jolt fns, so a folded result matches what
;; the unfolded code would produce at runtime by construction. Conservative:
;; numbers only, the op table only names pure numeric fns, and any throw
;; during folding (e.g. (mod x 0)) leaves the node alone for runtime.
(def ^:private foldable
;; SEED fns only: this ns loads with the compiler, BEFORE the later core
;; tiers — a name from 20-coll (min/max/abs) wouldn't resolve yet.
{"+" + "-" - "*" * "/" /
"<" < ">" > "<=" <= ">=" >= "=" =
"inc" inc "dec" dec
"mod" mod "rem" rem "quot" quot
"bit-and" bit-and "bit-or" bit-or "bit-xor" bit-xor})
(defn- const? [n] (= :const (get n :op)))
(defn- const-num? [n] (and (const? n) (number? (get n :val))))
(defn- fold-fn [fnode]
(let [op (get fnode :op)]
(when (or (and (= op :var) (= "clojure.core" (get fnode :ns)))
(= op :host))
(get foldable (get fnode :name)))))
(defn const-fold
"Bottom-up constant folding: a call of a foldable numeric fn whose args are
all constant numbers becomes a constant; an if with a constant test becomes
the taken branch."
[node]
(let [op (get node :op)]
(cond
(= op :invoke)
(let [f (const-fold (get node :fn))
args (mapv const-fold (get node :args))
ff (fold-fn f)
folded (when (and ff (pos? (count args)) (every? const-num? args))
(try
{:op :const :val (apply ff (mapv (fn [a] (get a :val)) args))}
(catch Exception e nil)))]
(or folded (assoc node :fn f :args args)))
(= op :if)
(let [t (const-fold (get node :test))]
(if (const? t)
;; jolt truthiness = Clojure's: nil/false take else
(if (or (nil? (get t :val)) (= false (get t :val)))
(const-fold (get node :else))
(const-fold (get node :then)))
(assoc node
:test t
:then (const-fold (get node :then))
:else (const-fold (get node :else)))))
(= op :do)
(assoc node
:statements (mapv const-fold (get node :statements))
:ret (const-fold (get node :ret)))
;; let/loop bindings are [name-string init-ir] PAIRS (see
;; analyzer/analyze-bindings), not maps.
(= op :let)
(assoc node
:bindings (mapv (fn [b] [(nth b 0) (const-fold (nth b 1))])
(get node :bindings))
:body (const-fold (get node :body)))
(= op :loop)
(assoc node
:bindings (mapv (fn [b] [(nth b 0) (const-fold (nth b 1))])
(get node :bindings))
:body (const-fold (get node :body)))
(= op :recur)
(assoc node :args (mapv const-fold (get node :args)))
(= op :fn)
(assoc node
:arities (mapv (fn [a] (assoc a :body (const-fold (get a :body))))
(get node :arities)))
(= op :def) (assoc node :init (const-fold (get node :init)))
(= op :throw) (assoc node :expr (const-fold (get node :expr)))
(= op :vector) (assoc node :items (mapv const-fold (get node :items)))
(= op :set) (assoc node :items (mapv const-fold (get node :items)))
(= op :map) (assoc node :pairs (mapv (fn [pr] (mapv const-fold pr)) (get node :pairs)))
;; leaves and anything this pass doesn't know: unchanged
:else node)))
(defn run-passes
"All passes, in order. The back end applies this to every analyzed form."
[node]
(const-fold node))