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:
parent
e41832c05d
commit
c4be5d8a0e
4 changed files with 89 additions and 13 deletions
|
|
@ -39,11 +39,21 @@
|
|||
(swap! gensym-counter inc)
|
||||
(str "_r$" prefix n)))
|
||||
|
||||
(defn- empty-env [] {:locals #{}})
|
||||
(defn- empty-env [] {:locals #{} :hints {}})
|
||||
(defn- local? [env nm] (contains? (:locals env) nm))
|
||||
(defn- add-locals [env names] (update env :locals #(reduce conj % names)))
|
||||
(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]
|
||||
(let [v (mapv #(analyze ctx % env) forms)
|
||||
n (count v)]
|
||||
|
|
@ -59,20 +69,25 @@
|
|||
(when-not (form-sym? bsym) (uncompilable "destructuring binding"))
|
||||
(let [nm (form-sym-name bsym)
|
||||
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])))
|
||||
|
||||
(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))
|
||||
(let [p (nth pvec i)]
|
||||
(when-not (form-sym? p) (uncompilable "destructuring fn param"))
|
||||
(if (= "&" (form-sym-name p))
|
||||
(let [r (nth pvec (inc i))]
|
||||
(when-not (form-sym? r) (uncompilable "destructuring fn rest"))
|
||||
(recur (+ i 2) fixed (form-sym-name r)))
|
||||
(recur (inc i) (conj fixed (form-sym-name p)) rest-name)))
|
||||
{:fixed fixed :rest rest-name})))
|
||||
(recur (+ i 2) fixed (form-sym-name r) hints))
|
||||
(let [nm (form-sym-name p) h (hint-of p)]
|
||||
(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]
|
||||
(let [pp (parse-params (vec (form-vec-items pvec)))
|
||||
|
|
@ -88,7 +103,8 @@
|
|||
;; keeps recur targets unique per compilation unit.
|
||||
rname (gen-name (str (compile-ns ctx) "/" (or fn-name "fn") "--"))
|
||||
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
|
||||
:body (analyze-seq ctx body env*)}]
|
||||
;; :rest only when variadic — an absent :rest reads back nil, same as before,
|
||||
|
|
@ -190,7 +206,8 @@
|
|||
(defn- analyze-symbol [ctx form env]
|
||||
(let [nm (form-sym-name form) ns (form-sym-ns form)]
|
||||
(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)]
|
||||
(if (= :var (:kind r))
|
||||
(var-ref (:ns r) (:name r))
|
||||
|
|
|
|||
|
|
@ -178,7 +178,15 @@
|
|||
(let [op (get node :op)]
|
||||
(cond
|
||||
(= 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
|
||||
:test (subst (get node :test) env)
|
||||
:then (subst (get node :then) env)
|
||||
|
|
|
|||
|
|
@ -331,6 +331,13 @@
|
|||
(>= 2 (length args) 1))
|
||||
(let [k (fnode :val)
|
||||
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
|
||||
# directly — the guard + lookup both reference it, and locals are
|
||||
# immutable reads, so no rebinding let is needed (saves a binding
|
||||
|
|
@ -338,13 +345,13 @@
|
|||
m (if (symbol? m-expr) m-expr (jsym))
|
||||
wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body]))]
|
||||
(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)
|
||||
d (if (symbol? d-expr) d-expr (jsym))
|
||||
v (jsym)
|
||||
body ['if ['get m :jolt/type]
|
||||
(tuple core-get m k d)
|
||||
['let [v ['get m k]] ['if ['nil? v] d v]]]
|
||||
fast ['let [v ['get m k]] ['if ['nil? v] d v]]
|
||||
body (if hinted fast ['if ['get m :jolt/type] (tuple core-get m k d) fast])
|
||||
body (if (symbol? d-expr) body ['let [d d-expr] body])]
|
||||
(wrap body))))
|
||||
(direct-call? ctx fnode) (tuple (emit ctx fnode) ;args)
|
||||
|
|
|
|||
44
test/integration/struct-hint-test.janet
Normal file
44
test/integration/struct-hint-test.janet
Normal 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!")
|
||||
Loading…
Add table
Add a link
Reference in a new issue