shapes: revert default-on to opt-in; faster get-or-shape inline read (jolt-t34)

Measurement (vec-heavy benchmark + read-mechanism micro-benchmarks) showed that
shape-rec'ing generic const-key maps is a net loss in a bytecode VM: an unproven
field read can't beat a native Janet struct-get (one opcode), and an inline cache
doesn't transfer to a VM jolt doesn't control. The win is real only for PROVEN
reads (bare-index beats struct) — i.e. construction-heavy or inference-friendly
code, not general map-through-fn code.

So shapes are opt-in again (JOLT_SHAPE), not defaulted on in direct-link. Kept the
get-or-shape fix: the non-proven shape read now indexes inline off the descriptor
instead of calling core-get (which was ~2.5x slower per read). :shapes? is the
single opt-in gate; image cache keys on JOLT_SHAPE + JOLT_NO_SHAPE.

Next: records with declared shapes get fast field access by default (the general
case), which is where the proven-read win actually lands.
This commit is contained in:
Yogthos 2026-06-13 23:41:26 -04:00
parent 7900173d45
commit 4e7b8f792f
3 changed files with 35 additions and 19 deletions

View file

@ -223,6 +223,12 @@
# the inline pass only inlines targets that won't be redefined, the same # the inline pass only inlines targets that won't be redefined, the same
# safety the direct-linking flag asserts (jolt-87f). # safety the direct-linking flag asserts (jolt-87f).
(put (ctx :env) :inline? (if (get (ctx :env) :direct-linking?) true false)) (put (ctx :env) :inline? (if (get (ctx :env) :direct-linking?) true false))
# Shape-records are OPT-IN (jolt-t34). Measurement showed shaping generic
# const-key maps net-loses in a bytecode VM (unproven reads can't beat a
# native struct-get), so shapes are not defaulted on. The win is records
# with declared shapes + proven reads; that path is enabled separately.
(put (ctx :env) :shapes?
(and (os/getenv "JOLT_SHAPE") (not (os/getenv "JOLT_NO_SHAPE"))))
ctx)) ctx))
# --- Context snapshot/fork (cheap isolated copies) -------------------------- # --- Context snapshot/fork (cheap isolated copies) --------------------------
@ -300,7 +306,7 @@
# Opts land in the key via their printed form; an opt that prints unstably # Opts land in the key via their printed form; an opt that prints unstably
# (e.g. a closure in :namespaces) just degrades to a cache miss, never to a # (e.g. a closure in :namespaces) just degrades to a cache miss, never to a
# wrong hit. Runtime knobs that shape the ctx outside opts ride along too. # wrong hit. Runtime knobs that shape the ctx outside opts ride along too.
(def key (string/format "%q|%q|%q|%q|%q|%q|%q|%q|%q|%q|%q" (def key (string/format "%q|%q|%q|%q|%q|%q|%q|%q|%q|%q|%q|%q"
(string janet/version "-" janet/build) (string janet/version "-" janet/build)
opts opts
(os/getenv "JOLT_PATH") (os/getenv "JOLT_PATH")
@ -311,8 +317,10 @@
(os/getenv "JOLT_DIRECT_LINK") (os/getenv "JOLT_DIRECT_LINK")
(os/getenv "JOLT_NO_IR_PASSES") (os/getenv "JOLT_NO_IR_PASSES")
(os/getenv "JOLT_CHECK_HINTS") (os/getenv "JOLT_CHECK_HINTS")
# JOLT_SHAPE shapes core's own compiled get/record paths # :shapes? is baked into the image; key on every input
(os/getenv "JOLT_SHAPE"))) # to it so a cache hit never carries a wrong shape state
(os/getenv "JOLT_SHAPE")
(os/getenv "JOLT_NO_SHAPE")))
(string dir "/jolt-ctx-" (band h 0x7FFFFFFF) "-" len "-" (band (hash key) 0x7FFFFFFF) ".jimg")) (string dir "/jolt-ctx-" (band h 0x7FFFFFFF) "-" len "-" (band (hash key) 0x7FFFFFFF) ".jimg"))
(defn init-cached (defn init-cached

View file

@ -355,7 +355,7 @@
# - ^:struct / ^Record hinted subject: skip the guard, bare get (~20 vs ~36ns). # - ^:struct / ^Record hinted subject: skip the guard, bare get (~20 vs ~36ns).
# - hinted + JOLT_CHECK_HINTS: keep the guard but THROW on the tagged arm, so a # - hinted + JOLT_CHECK_HINTS: keep the guard but THROW on the tagged arm, so a
# lying hint surfaces a clear error (dev aid; off by default, no perf cost). # lying hint surfaces a clear error (dev aid; off by default, no perf cost).
(defn- emit-kw-lookup [subj-node m-expr k d-expr] (defn- emit-kw-lookup [ctx subj-node m-expr k d-expr]
# the subject is a struct (raw-get-safe) when hinted so — by an explicit # the subject is a struct (raw-get-safe) when hinted so — by an explicit
# ^:struct/^Record hint on a local, OR by inference tagging ANY subject # ^:struct/^Record hint on a local, OR by inference tagging ANY subject
# expression it proved to be a struct (jolt-d6u/RFC 0005), which is what lets # expression it proved to be a struct (jolt-d6u/RFC 0005), which is what lets
@ -374,24 +374,28 @@
# comes from the runtime's canonical shape-sort (the same order shape-for and # comes from the runtime's canonical shape-sort (the same order shape-for and
# emit-map use), so construction and lookup always agree. # emit-map use), so construction and lookup always agree.
(def sidx (def sidx
(when (and (os/getenv "JOLT_SHAPE") subj-node (subj-node :shape)) (when (and (get (ctx :env) :shapes?) subj-node (subj-node :shape))
(def raw (let [s (subj-node :shape)] (if (pv/pvec? s) (pv/pv->array s) s))) (def raw (let [s (subj-node :shape)] (if (pv/pvec? s) (pv/pv->array s) s)))
(def sk (shape-sort raw)) (def sk (shape-sort raw))
(var pos nil) (var i 0) (var pos nil) (var i 0)
(each kk sk (when (= kk k) (set pos i)) (++ i)) (each kk sk (when (= kk k) (set pos i)) (++ i))
(when pos (+ pos 1)))) (when pos (+ pos 1))))
# sidx: complete shape proven -> bare index (fastest). Otherwise, with shapes # sidx => proven complete shape: bare index (fastest). Otherwise a raw-get-safe
# on, a raw-get-safe value may still be a shape-rec (its :shape dropped by a # value may still be a shape-rec (its :shape dropped by a join, or an unproven
# join), so read the index from the value's own descriptor when it is a tuple; # param). Read it INLINE from its own descriptor — a shape-rec is a tuple whose
# a real struct takes the bare get. (jolt-t34) # head is a descriptor struct; index the present field directly. A missing key
# sidx => proven complete shape, bare index (the fast path). Otherwise route a # (incl. the virtual :jolt/deftype) falls to core-get, which is nil-safe and
# TUPLE (a possible shape-rec) to core-get, which is shape-aware and nil-safe; # shape-aware; a struct/other takes the bare get. The inline read matters: a
# a struct takes the bare get. NOT gated on compile-time JOLT_SHAPE: core is # core-get fn call per field read is ~2.5x slower on map-through-fn code.
# baked WITHOUT the flag but still receives user shape-recs, so this must hold # NOT gated on compile-time shapes — core is baked without the flag but still
# in baked core too. Cost on the default path is one tuple? test per # receives user shape-recs, so this must hold in baked core too. (jolt-t34)
# non-proven keyword lookup. (jolt-t34 R3)
(defn get-or-shape [getexpr] (defn get-or-shape [getexpr]
(if sidx ['in m sidx] ['if ['tuple? m] (tuple core-get m k nil) getexpr])) (if sidx ['in m sidx]
(let [pos (jsym)]
['if ['and ['tuple? m] ['struct? ['get m 0]]]
['let [pos ['get [['get m 0] :idx] k]]
['if ['nil? pos] (tuple core-get m k nil) ['in m ['+ 1 pos]]]]
getexpr])))
(if (nil? d-expr) (if (nil? d-expr)
(let [fast (get-or-shape ['get m k])] (let [fast (get-or-shape ['get m k])]
(wrap (cond (wrap (cond
@ -435,7 +439,7 @@
# the subject skips the guard entirely (jolt-94n; see emit-kw-lookup). # the subject skips the guard entirely (jolt-94n; see emit-kw-lookup).
(and (= :const (fnode :op)) (keyword? (fnode :val)) (and (= :const (fnode :op)) (keyword? (fnode :val))
(>= 2 (length args) 1)) (>= 2 (length args) 1))
(emit-kw-lookup (norm-node (in argnodes 0)) (in args 0) (fnode :val) (emit-kw-lookup ctx (norm-node (in argnodes 0)) (in args 0) (fnode :val)
(when (= 2 (length args)) (in args 1))) (when (= 2 (length args)) (in args 1)))
# (get m :kw) / (get m :kw default) — same inlined keyword lookup as (:kw m), # (get m :kw) / (get m :kw default) — same inlined keyword lookup as (:kw m),
# so an explicit get with a constant keyword gets the guard fast path and the # so an explicit get with a constant keyword gets the guard fast path and the
@ -443,7 +447,7 @@
# a variable/number/string key falls through to core-get below. # a variable/number/string key falls through to core-get below.
(and (get-head? fnode) (>= (length args) 2) (<= (length args) 3) (and (get-head? fnode) (>= (length args) 2) (<= (length args) 3)
(let [a1 (norm-node (in argnodes 1))] (and (= :const (a1 :op)) (keyword? (a1 :val))))) (let [a1 (norm-node (in argnodes 1))] (and (= :const (a1 :op)) (keyword? (a1 :val)))))
(emit-kw-lookup (norm-node (in argnodes 0)) (in args 0) (emit-kw-lookup ctx (norm-node (in argnodes 0)) (in args 0)
((norm-node (in argnodes 1)) :val) ((norm-node (in argnodes 1)) :val)
(when (= 3 (length args)) (in args 2))) (when (= 3 (length args)) (in args 2)))
# (count v) on an inferred vector -> pv-count, skipping core-count's dispatch # (count v) on an inferred vector -> pv-count, skipping core-count's dispatch
@ -503,7 +507,7 @@
# compiler's own IR-node map literals (which the back end reads with raw # compiler's own IR-node map literals (which the back end reads with raw
# keyword access and would break if turned into tuples) # keyword access and would break if turned into tuples)
(def shape-keys (def shape-keys
(when (and fast (os/getenv "JOLT_SHAPE") (get (ctx :env) :inline?)) (when (and fast (get (ctx :env) :shapes?) (get (ctx :env) :inline?))
(def ks @[]) (def ks @[])
(each pair pairs (array/push ks ((norm-node (in (vview pair) 0)) :val))) (each pair pairs (array/push ks ((norm-node (in (vview pair) 0)) :val)))
(shape-sort ks))) (shape-sort ks)))

View file

@ -506,6 +506,10 @@
(when (= "1" (os/getenv "JOLT_DIRECT_LINK")) (when (= "1" (os/getenv "JOLT_DIRECT_LINK"))
(put (ctx :env) :direct-linking? true) (put (ctx :env) :direct-linking? true)
(put (ctx :env) :inline? true)) (put (ctx :env) :inline? true))
# Recompute :shapes? from the runtime env (the baked ctx computed it at build
# time, before JOLT_SHAPE was set in this process). Opt-in (jolt-t34).
(put (ctx :env) :shapes?
(and (os/getenv "JOLT_SHAPE") (not (os/getenv "JOLT_NO_SHAPE"))))
(cond (cond
(empty? argv) (run-repl) (empty? argv) (run-repl)
(help-flags (argv 0)) (print-help) (help-flags (argv 0)) (print-help)