perf: hint-driven keyword-lookup guard elimination (^:struct)

A constant-keyword lookup (:k m) currently emits a guarded form,
(if (get m :jolt/type) (core-get m k) (get m k)), to tell a plain struct
(raw get is correct) from a phm/sorted/transient (needs core-get). On a
struct that guard is a second get, so the lookup costs ~36ns where a bare
get is ~20ns. Profiling the ray tracer (jolt-dad) showed keyword lookups are
~50% of a render and the guard is the only avoidable part, but dropping it
needs to know statically that the subject is a plain struct.

Type hints are exactly that information, and jolt already parses them and
otherwise ignores them. This wires one through: a local hinted ^:struct
asserts a plain struct/record map, so a (:k local) lookup on it skips the
guard and emits a bare get. The hint rides on the binding symbol into the
analyzer, which records it per-local and attaches it to :local IR nodes; the
back end reads it on the lookup subject. It also propagates through inlining:
when the inliner let-binds a non-trivial arg to a fresh local, it carries the
called fn's param hint onto that local, so lookups inside the spliced body
keep the bare path. This is a programmer assertion, like a Clojure type hint
(an inaccurate hint just makes the raw get return the wrong value, the same
contract as a wrong ^String), so it stays opt-in and off by default.

On the ray tracer (with inlining on) this is 13.3s to 10.9s, 1.22x, taking it
to 7.8x JVM from 9.4x after the inline pass. The unhinted path emits identical
code (the fast arm is just factored out), so nothing changes without hints.

Verified: a seeded full render produces an identical checksum hinted vs
unhinted; conformance 335/335 in all three modes and the full jpm test pass;
new test/integration/struct-hint-test.janet pins the guard removal, the
inline propagation, and that an accurate hint is correctness-preserving.
This commit is contained in:
Yogthos 2026-06-12 17:45:18 -04:00
parent e41832c05d
commit c4be5d8a0e
4 changed files with 89 additions and 13 deletions

View file

@ -39,11 +39,21 @@
(swap! gensym-counter inc) (swap! gensym-counter inc)
(str "_r$" prefix n))) (str "_r$" prefix n)))
(defn- empty-env [] {:locals #{}}) (defn- empty-env [] {:locals #{} :hints {}})
(defn- local? [env nm] (contains? (:locals env) nm)) (defn- local? [env nm] (contains? (:locals env) nm))
(defn- add-locals [env names] (update env :locals #(reduce conj % names))) (defn- add-locals [env names] (update env :locals #(reduce conj % names)))
(defn- with-recur [env name] (assoc env :recur name)) (defn- with-recur [env name] (assoc env :recur name))
;; Type hints (jolt-dad). The reader keeps ^hint metadata on the binding symbol;
;; we recognize ^:struct, which asserts the value is a plain struct/record map so
;; a constant-keyword lookup can skip the :jolt/type guard and emit a bare get.
;; Other hints parse and are ignored, as before.
(defn- hint-of [sym]
(let [m (form-sym-meta sym)]
(when (and m (get m :struct)) :struct)))
(defn- add-hint [env nm h]
(if h (assoc env :hints (assoc (:hints env) nm h)) env))
(defn- analyze-seq [ctx forms env] (defn- analyze-seq [ctx forms env]
(let [v (mapv #(analyze ctx % env) forms) (let [v (mapv #(analyze ctx % env) forms)
n (count v)] n (count v)]
@ -59,20 +69,25 @@
(when-not (form-sym? bsym) (uncompilable "destructuring binding")) (when-not (form-sym? bsym) (uncompilable "destructuring binding"))
(let [nm (form-sym-name bsym) (let [nm (form-sym-name bsym)
init (analyze ctx (nth bvec (inc i)) env)] init (analyze ctx (nth bvec (inc i)) env)]
(recur (+ i 2) (add-locals env [nm]) (conj pairs [nm init])))) (recur (+ i 2) (add-hint (add-locals env [nm]) nm (hint-of bsym))
(conj pairs [nm init]))))
[pairs env]))) [pairs env])))
(defn- parse-params [pvec] (defn- parse-params [pvec]
(loop [i 0 fixed [] rest-name nil] ;; :hints is a vector of [name hint] pairs (vector, not a map, so the caller
;; folds it with a plain reduce — no reduce-over-map in the kernel subset).
(loop [i 0 fixed [] rest-name nil hints []]
(if (< i (count pvec)) (if (< i (count pvec))
(let [p (nth pvec i)] (let [p (nth pvec i)]
(when-not (form-sym? p) (uncompilable "destructuring fn param")) (when-not (form-sym? p) (uncompilable "destructuring fn param"))
(if (= "&" (form-sym-name p)) (if (= "&" (form-sym-name p))
(let [r (nth pvec (inc i))] (let [r (nth pvec (inc i))]
(when-not (form-sym? r) (uncompilable "destructuring fn rest")) (when-not (form-sym? r) (uncompilable "destructuring fn rest"))
(recur (+ i 2) fixed (form-sym-name r))) (recur (+ i 2) fixed (form-sym-name r) hints))
(recur (inc i) (conj fixed (form-sym-name p)) rest-name))) (let [nm (form-sym-name p) h (hint-of p)]
{:fixed fixed :rest rest-name}))) (recur (inc i) (conj fixed nm) rest-name
(if h (conj hints [nm h]) hints)))))
{:fixed fixed :rest rest-name :hints hints})))
(defn- analyze-arity [ctx pvec body env fn-name] (defn- analyze-arity [ctx pvec body env fn-name]
(let [pp (parse-params (vec (form-vec-items pvec))) (let [pp (parse-params (vec (form-vec-items pvec)))
@ -88,7 +103,8 @@
;; keeps recur targets unique per compilation unit. ;; keeps recur targets unique per compilation unit.
rname (gen-name (str (compile-ns ctx) "/" (or fn-name "fn") "--")) rname (gen-name (str (compile-ns ctx) "/" (or fn-name "fn") "--"))
names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name)) names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name))
env* (-> (add-locals env names) (with-recur rname)) env0 (-> (add-locals env names) (with-recur rname))
env* (reduce (fn [e pr] (add-hint e (nth pr 0) (nth pr 1))) env0 (:hints pp))
arity {:params fixed :recur-name rname arity {:params fixed :recur-name rname
:body (analyze-seq ctx body env*)}] :body (analyze-seq ctx body env*)}]
;; :rest only when variadic — an absent :rest reads back nil, same as before, ;; :rest only when variadic — an absent :rest reads back nil, same as before,
@ -190,7 +206,8 @@
(defn- analyze-symbol [ctx form env] (defn- analyze-symbol [ctx form env]
(let [nm (form-sym-name form) ns (form-sym-ns form)] (let [nm (form-sym-name form) ns (form-sym-ns form)]
(cond (cond
(and (nil? ns) (local? env nm)) (local nm) (and (nil? ns) (local? env nm))
(let [h (get (:hints env) nm)] (if h (assoc (local nm) :hint h) (local nm)))
ns (let [r (resolve-global ctx form)] ns (let [r (resolve-global ctx form)]
(if (= :var (:kind r)) (if (= :var (:kind r))
(var-ref (:ns r) (:name r)) (var-ref (:ns r) (:name r))

View file

@ -178,7 +178,15 @@
(let [op (get node :op)] (let [op (get node :op)]
(cond (cond
(= op :local) (let [r (get env (get node :name))] (= op :local) (let [r (get env (get node :name))]
(if r r node)) ;; carry the param's ^:struct hint onto a let-bound fresh
;; local, so lookups inside the inlined body keep the bare
;; (no-guard) path (jolt-dad). The param hint asserts the
;; arg is a struct; inlining doesn't change that contract.
(if r
(if (and (= :local (get r :op)) (get node :hint) (not (get r :hint)))
(assoc r :hint (get node :hint))
r)
node))
(= op :if) (assoc node (= op :if) (assoc node
:test (subst (get node :test) env) :test (subst (get node :test) env)
:then (subst (get node :then) env) :then (subst (get node :then) env)

View file

@ -331,6 +331,13 @@
(>= 2 (length args) 1)) (>= 2 (length args) 1))
(let [k (fnode :val) (let [k (fnode :val)
m-expr (in args 0) m-expr (in args 0)
# ^:struct hint (jolt-dad): the subject IR local asserts a plain
# struct/record map, so skip the :jolt/type guard entirely and emit a
# bare get (~20ns vs ~36ns guarded). Programmer-asserted, like a
# Clojure type hint — a lie just makes the raw get return the wrong
# thing, same contract as ^String. Only for a :local subject.
subj (norm-node (in (vview (node :args)) 0))
hinted (and (= :local (subj :op)) (= :struct (subj :hint)))
# when the subject is already a janet symbol (a local), read it # when the subject is already a janet symbol (a local), read it
# directly — the guard + lookup both reference it, and locals are # directly — the guard + lookup both reference it, and locals are
# immutable reads, so no rebinding let is needed (saves a binding # immutable reads, so no rebinding let is needed (saves a binding
@ -338,13 +345,13 @@
m (if (symbol? m-expr) m-expr (jsym)) m (if (symbol? m-expr) m-expr (jsym))
wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body]))] wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body]))]
(if (= 1 (length args)) (if (= 1 (length args))
(wrap ['if ['get m :jolt/type] (tuple core-get m k) ['get m k]]) (let [fast ['get m k]]
(wrap (if hinted fast ['if ['get m :jolt/type] (tuple core-get m k) fast])))
(let [d-expr (in args 1) (let [d-expr (in args 1)
d (if (symbol? d-expr) d-expr (jsym)) d (if (symbol? d-expr) d-expr (jsym))
v (jsym) v (jsym)
body ['if ['get m :jolt/type] fast ['let [v ['get m k]] ['if ['nil? v] d v]]
(tuple core-get m k d) body (if hinted fast ['if ['get m :jolt/type] (tuple core-get m k d) fast])
['let [v ['get m k]] ['if ['nil? v] d v]]]
body (if (symbol? d-expr) body ['let [d d-expr] body])] body (if (symbol? d-expr) body ['let [d d-expr] body])]
(wrap body)))) (wrap body))))
(direct-call? ctx fnode) (tuple (emit ctx fnode) ;args) (direct-call? ctx fnode) (tuple (emit ctx fnode) ;args)

View file

@ -0,0 +1,44 @@
# ^:struct type hint (jolt-dad). A constant-keyword lookup on a local hinted
# ^:struct skips the :jolt/type guard and emits a bare get (~20ns vs ~36ns),
# the way Clojure type hints let the compiler specialize. The hint is a
# programmer assertion (a lie just makes the raw get return the wrong thing,
# same contract as ^String); these tests pin that an ACCURATE hint is
# correctness-preserving, that it drops the guard, and that it survives inlining.
(import ../../src/jolt/api :as api)
(import ../../src/jolt/backend :as backend)
(import ../../src/jolt/reader :as reader)
(print "Struct hint (jolt-dad)...")
(os/setenv "JOLT_DIRECT_LINK" "1") # inline on, so hint-through-inline is exercised
(def ctx (api/init {:compile? true}))
(api/eval-string ctx "(ns sh)")
(each s ["(defn v3 [r g b] {:r r :g g :b b})"
"(defn dot [^:struct l ^:struct r] (+ (+ (* (:r l) (:r r)) (* (:g l) (:g r))) (* (:b l) (:b r))))"
"(defn sub [^:struct l ^:struct r] {:r (- (:r l) (:r r)) :g (- (:g l) (:g r)) :b (- (:b l) (:b r))})"
"(defn lensq [^:struct v] (dot v v))"]
(api/eval-string ctx s))
(defn guards [src]
(def code (string/format "%p" (backend/emit-ir ctx (backend/analyze-form ctx (reader/parse-string src)))))
(length (string/find-all ":jolt/type" code)))
# the guard is dropped for hinted subjects, kept for unhinted ones
(assert (= 1 (guards "(fn [v] (:r v))")) "unhinted (:r v) keeps the guard")
(assert (= 0 (guards "(fn [^:struct v] (:r v))")) "hinted (:r v) drops the guard")
(assert (= 0 (guards "(fn [^:struct v] (+ (+ (:r v) (:g v)) (:b v)))")) "all three hinted lookups bare")
(assert (= 0 (guards "(fn [^:struct v] (lensq v))")) "hint survives through an inlined call")
# accurate hints are correctness-preserving (value identical to the guarded path)
(assert (= 32 (api/eval-string ctx "(dot (v3 1 2 3) (v3 4 5 6))")) "hinted dot value")
(assert (= 14 (api/eval-string ctx "(lensq (v3 1 2 3))")) "hinted lensq (inline-flow) value")
(assert (= 7 (api/eval-string ctx "(:r (sub (v3 9 8 7) (v3 2 0 0)))")) "hinted sub field")
# a hinted value flowing through an inlined call still reads correctly
(api/eval-string ctx "(defn hit [^:struct ray ^:struct c] (lensq (sub (:origin ray) c)))")
(assert (= 48 (api/eval-string ctx "(hit {:origin (v3 5 5 5) :direction (v3 0 0 0)} (v3 1 1 1))"))
"hinted value through nested inline reads correctly")
# a missing key on a hinted struct still reads nil (struct miss), like a guarded get
(assert (= nil (api/eval-string ctx "((fn [^:struct m] (:absent m)) (v3 1 2 3))")) "hinted struct miss -> nil")
(print "Struct hint passed!")