backend: inline the var deref at indirect call sites (fib 1.75x)

An indirect global reference emitted ((var-get 'cell) ...) — a function call
per deref, whose body is a binding-stack check plus a root read. The emitter
now inlines that: (if (in 'cell :dynamic) (var-get 'cell) (in 'cell :root)).
Non-dynamic vars — the vast majority of references — pay two native table
ops and a branch instead of a call; dynamic vars take the full var-get
(thread-binding walk). Redefinition stays live (:root is read per call) and
binding semantics are exact: the :dynamic check is PER CALL, not at emit,
because a (def ^:dynamic x) in the same compiled unit marks the cell dynamic
only when the def runs — the same reason JVM Clojure's Var.deref() checks
the thread-bound bit every call (an emit-time variant was 1.7x faster still
on fib but failed conformance exactly there).

fib 130 -> 74 ms (1.75x); bench TOTAL 4564 -> 4437 back-to-back. This
displaces the gen-counter inline-cache design from jolt-8sq: with var-get's
existing fast path, resolution was never the cost — the call was. A
gen-guarded cache would add state per site to save nothing further, and
couldn't skip the dynamic check anyway.

Found while benching: map-read regressed ~7x back in batch 2 (canonical
zipmap builds a phm where kvs->map built a struct) — filed as jolt-s3y.

Gate: jpm test exit 0, conformance 326x3, suite >= baseline.
This commit is contained in:
Yogthos 2026-06-10 16:42:46 -04:00
parent 19d59cba85
commit 639effd58c

View file

@ -286,10 +286,25 @@
:var (let [cell (cell-for ctx (node :ns) (node :name))] :var (let [cell (cell-for ctx (node :ns) (node :name))]
(if (direct-var? ctx cell) (if (direct-var? ctx cell)
(cell :root) # direct link: embed the fn value (cell :root) # direct link: embed the fn value
# Indirect: live deref. Quote the cell so it's embedded by # Indirect: live deref, with the var-get FN CALL inlined away
# reference (a bare table in arg position would be re-evaluated as # (jolt-8sq): a non-dynamic var's value is always its root, so
# a constructor — deep-copying it, and any atom in :root, each call). # the common case is two native table ops + a branch instead of
(tuple var-get (tuple 'quote cell)))) # a function call. Dynamic vars take the full var-get (thread-
# binding walk). The cell is quoted so it's embedded by
# reference (a bare table in arg position would be re-evaluated
# as a constructor — deep-copying it, and any atom in :root,
# each call). Redefinition stays live: :root is read per call.
# The :dynamic check must be PER CALL, not at emit: a
# (def ^:dynamic x) in the same compiled unit marks the cell
# dynamic only when the def RUNS, after this site was emitted —
# the same reason JVM Clojure's Var.deref() checks the
# thread-bound bit on every call. Non-dynamic vars (the vast
# majority) pay two native table ops + a branch instead of a
# function call.
(let [qcell (tuple 'quote cell)]
['if ['in qcell :dynamic]
(tuple var-get qcell)
['in qcell :root]])))
# (var x): the var object itself (not its value) — the embedded cell, by # (var x): the var object itself (not its value) — the embedded cell, by
# reference. binding keys its thread-binding frame on this exact cell. # reference. binding keys its thread-binding frame on this exact cell.
:the-var (tuple 'quote (cell-for ctx (node :ns) (node :name))) :the-var (tuple 'quote (cell-for ctx (node :ns) (node :name)))