Enable IR inlining: splice small defns at call sites (lever 1/4)

jolt.passes.inline was fully written but dormant — it fetched bodies via the
inline-ir host hook, which was a stub returning nil. Wire it up: run-passes stashes
each inline-eligible defn (single fixed arity) as its form is optimized, and
inline-ir hands the body back at call sites under --opt.

The catch was the ^double/^long coercion: an inlined fn drops its param-entry and
return coercion, so (work 3 4) on a ^double fn would return 25 instead of 25.0. New
:coerce IR node carries the coercion inside the spliced body — the inline pass wraps
a hinted param's arg and the return in :coerce, the back end lowers it
(exact->inexact / jolt->fx), and jolt.passes.numeric reads its :kind. So an inlined
call matches the called one and the body's fl*/fx* fast path still fires.

Only under --opt (closed world); the seed mint and -e don't inline, so selfhost and
the corpus are unaffected. test/chez/inline-test.ss 12/12 (make inline); full make
test green, 0 new corpus divergences.

Bench (hot loop, body is a ^double helper call): direct-link 500ms -> --opt
(inlined) 184ms = 2.7x, by eliminating the call + coercion wrappers and letting Chez
fuse the fl-ops unboxed. ~26x over the default dispatched build.
This commit is contained in:
Yogthos 2026-06-23 17:43:13 -04:00
parent 9927fd7f74
commit 79fa22eeab
9 changed files with 365 additions and 240 deletions

View file

@ -571,6 +571,11 @@
(mapcat (fn [p] [(emit (nth p 0)) (emit (nth p 1))]) (:pairs node)))
:quote (emit-quoted (:form node))
:throw (str "(jolt-throw " (emit (:expr node)) ")")
;; numeric coercion (from an inlined ^double/^long param or return).
:coerce (let [e (emit (:expr node))]
(cond (= :double (:kind node)) (str "(exact->inexact " e ")")
(= :long (:kind node)) (str "(jolt->fx " e ")")
:else e))
:try (emit-try node)
;; regex literal #"…" -> a jolt-regex value (regex.ss, vendored irregex).
:regex (str "(jolt-regex " (chez-str-lit (:source node)) ")")

View file

@ -66,6 +66,13 @@
(defn quote-node [form] {:op :quote :form form})
(defn throw-node [expr] {:op :throw :expr expr})
;; Numeric coercion of a value to a primitive kind (:double / :long), the way a JVM
;; ^double/^long parameter or return coerces. The back end lowers it (exact->inexact
;; / jolt->fx) and jolt.passes.numeric reads its :kind as the value's numeric kind.
;; Carrying coercion as an IR node (rather than a back-end string wrap) lets it
;; travel with inlining and keeps the typed-arithmetic fast path sound.
(defn coerce-node [kind expr] {:op :coerce :kind kind :expr expr})
;; ---------------------------------------------------------------------------
;; Structural recursion over IR child nodes.
;;
@ -91,6 +98,7 @@
(= op :do) (assoc node :statements (mapv f (get node :statements))
:ret (f (get node :ret)))
(= op :throw) (assoc node :expr (f (get node :expr)))
(= op :coerce) (assoc node :expr (f (get node :expr)))
(= op :set-var) (assoc node :val (f (get node :val)))
(= op :set-field) (assoc node :obj (f (get node :obj)) :val (f (get node :val)))
(= op :defmacro) (assoc node :fn (f (get node :fn)))
@ -138,6 +146,7 @@
(= op :if) (f (f (f acc (get node :test)) (get node :then)) (get node :else))
(= op :do) (f (reduce f acc (get node :statements)) (get node :ret))
(= op :throw) (f acc (get node :expr))
(= op :coerce) (f acc (get node :expr))
(= op :set-var) (f acc (get node :val))
(= op :set-field) (f (f acc (get node :obj)) (get node :val))
(= op :defmacro) (f acc (get node :fn))

View file

@ -13,7 +13,7 @@
:refer, so jolt.passes stays the only namespace the back end imports.
Portable Clojure: kernel-tier fns + seed primitives only."
(:require [jolt.host :refer [inline-enabled? record-shapes]]
(:require [jolt.host :refer [inline-enabled? record-shapes stash-inline!]]
[jolt.passes.fold :refer [const-fold]]
[jolt.passes.numeric :as numeric]
[jolt.passes.inline :refer [inline-node flatten-lets scalar-replace dirty set-rec-shapes!]]
@ -28,6 +28,17 @@
;; sets `dirty` when it rewrote something; the loop stops at a clean pass or here.
(def ^:private inline-fixpoint-cap 8)
;; A top-level defn the inline pass may splice: a single fixed arity (no rest). The
;; pass itself checks body size + closedness, so any such fn is stashable.
(defn- inline-eligible? [node]
(and (= :def (:op node)) (:init node) (= :fn (:op (:init node)))
(= 1 (count (:arities (:init node))))
(not (:rest (first (:arities (:init node)))))))
(defn- stash-of [node]
(let [a (first (:arities (:init node)))]
{:params (:params a) :body (:body a) :nhints (:nhints a) :ret (:ret-nhint a)}))
(defn run-passes
"All passes, in order. The back end applies this to every analyzed form. When
inlining is enabled for the unit (user code under direct-linking),
@ -40,6 +51,10 @@
numeric/annotate runs last in both branches (hint-directed fl*/fx* arithmetic);
it benefits open builds too, so it is not gated on inlining."
[node ctx]
;; stash an inline-eligible defn so later call sites can splice it (closed-world
;; optimization only). Done before optimizing, from the analyzed node.
(when (and (inline-enabled? ctx) (inline-eligible? node))
(stash-inline! ctx (:ns node) (:name node) (stash-of node)))
(numeric/annotate
(if (inline-enabled? ctx)
(let [_ (set-rec-shapes! (record-shapes ctx)) ;; record ctor fold

View file

@ -4,7 +4,7 @@
share the alpha-rename invariant (every spliced binder is made globally fresh)
and the `dirty` fixpoint flag. Portable Clojure (compiler-tier)."
(:require [jolt.host :refer [inline-ir]]
[jolt.ir :refer [map-ir-children reduce-ir-children]]
[jolt.ir :refer [map-ir-children reduce-ir-children coerce-node]]
[jolt.passes.fold :refer [scalar-const?]]))
;; ---------------------------------------------------------------------------
@ -47,7 +47,7 @@
;; is rejected by body-size below and never inlined or alpha-renamed.
(or (= op :const) (= op :local) (= op :var) (= op :host) (= op :the-var)
(= op :quote) (= op :if) (= op :do) (= op :let) (= op :invoke)
(= op :map) (= op :vector) (= op :set) (= op :throw)))
(= op :map) (= op :vector) (= op :set) (= op :throw) (= op :coerce)))
(def ^:private inline-budget 120)
@ -141,6 +141,8 @@
(if stash
(let [params (get stash :params)
body (get stash :body)
nh (reduce (fn [m pr] (assoc m (nth pr 0) (nth pr 1))) {} (get stash :nhints))
ret (get stash :ret)
args (get node :args)]
(if (and (= (count params) (count args))
(<= (body-size body) inline-budget)
@ -149,19 +151,25 @@
;; trivial args (local/const) substitute straight in (copy
;; propagation); the rest get a fresh local bound once in a
;; wrapping let, so they evaluate exactly once in source order.
;; A ^double/^long param always binds (no copy-prop) so its
;; entry coercion runs — preserving the called fn's semantics.
res (loop [i 0 env {} binds []]
(if (< i n)
(let [p (nth params i) a (nth args i)]
(if (trivial-arg? a)
(recur (inc i) (assoc env p a) binds)
(let [f (fresh p)]
(recur (inc i)
(assoc env p {:op :local :name f})
(conj binds [f a])))))
(let [p (nth params i) a (nth args i) k (get nh p)]
(cond
k (let [f (fresh p)]
(recur (inc i) (assoc env p {:op :local :name f})
(conj binds [f (coerce-node k a)])))
(trivial-arg? a) (recur (inc i) (assoc env p a) binds)
:else (let [f (fresh p)]
(recur (inc i) (assoc env p {:op :local :name f})
(conj binds [f a])))))
[env binds]))
env (nth res 0)
binds (nth res 1)
rbody (subst body env)]
rbody0 (subst body env)
;; preserve the fn's ^double/^long return coercion.
rbody (if ret (coerce-node ret rbody0) rbody0)]
(mark!)
(if (= 0 (count binds))
rbody

View file

@ -137,6 +137,7 @@
(cond
(= op :const) [(if (float-lit? node) :double nil) node]
(= op :local) [(get tenv (get node :name)) node]
(= op :coerce) [(get node :kind) (assoc node :expr (nth (an (get node :expr) tenv) 1))]
(= op :invoke) (an-invoke node tenv)
(= op :let)
(let [res (reduce (fn [acc b]