Numeric return-type hints: ^double/^long on a defn (round 3)

A ^double/^long return hint on a fn's name now (a) coerces the fn's value on the
way out — exact->inexact / jolt->fx, like a JVM primitive return — and (b) types a
call to it, so an accumulator over the result specializes:

  (defn ^double work [^double x ^double y] (+ (* x x) (* y y)))
  (loop [acc 0.0] (recur (+ acc (work a b))))   ; (+ acc (work ..)) -> fl+

The analyzer pushes the name's numeric tag onto each arity (:ret-nhint) for the
back-end coercion, and resolve-global surfaces the callee's declared return
(:num-ret, read from var meta) onto the :var node so jolt.passes.numeric types the
call. defn carries the name hint through.

This unblocks the accumulator-over-fn-result pattern that round 2 had to demote.
The win is bounded by call overhead in an open/dispatched build (~1.15x on a hot
loop whose body is a helper call); it compounds with direct-linking and, later,
inlining. A numeric return hint is a contract, like ^long — redefining the var to
return another type in an open build breaks it.

Not yet: per-arity arglist return hints, (defn f (^double [x] ..)). Gate:
test/chez/numeric-test.ss 39/39; full make test green, 0 new corpus divergences.
This commit is contained in:
Yogthos 2026-06-23 17:21:53 -04:00
parent 0db417a491
commit 5a9acd3cf4
6 changed files with 227 additions and 164 deletions

View file

@ -32,6 +32,9 @@
(define hc-kw-name (keyword #f "name")) (define hc-kw-name (keyword #f "name"))
(define hc-kw-var (keyword #f "var")) (define hc-kw-var (keyword #f "var"))
(define hc-kw-unresolved (keyword #f "unresolved")) (define hc-kw-unresolved (keyword #f "unresolved"))
(define hc-kw-num-ret (keyword #f "num-ret"))
(define hc-kw-double (keyword #f "double"))
(define hc-kw-long (keyword #f "long"))
(define hc-kw-regex (keyword #f "regex")) (define hc-kw-regex (keyword #f "regex"))
(define hc-kw-inst (keyword #f "#inst")) (define hc-kw-inst (keyword #f "#inst"))
(define hc-kw-uuid (keyword #f "#uuid")) (define hc-kw-uuid (keyword #f "#uuid"))
@ -176,13 +179,24 @@
;; No :host branch: there is no separate native-op env — the hot ;; No :host branch: there is no separate native-op env — the hot
;; clojure.core primitives (+,-,map,...) are declared in clojure.core below so ;; clojure.core primitives (+,-,map,...) are declared in clojure.core below so
;; they classify as :var and the emitter's native-op path lowers them. ;; they classify as :var and the emitter's native-op path lowers them.
;; A var's declared numeric return (^double/^long on its name) -> :double/:long,
;; read from its meta. Lets jolt.passes.numeric type a call to it.
(define (hc-cell-num-ret cell)
(let ((m (and cell (hashtable-ref var-meta-table cell #f))))
(and m (let ((t (jolt-get m hc-kw-tag)))
(cond ((equal? t "double") hc-kw-double)
((equal? t "long") hc-kw-long)
(else #f))))))
(define (hc-resolve-global ctx sym) (define (hc-resolve-global ctx sym)
(let* ((nm (symbol-t-name sym)) (let* ((nm (symbol-t-name sym))
(cell (hc-resolve-cell ctx sym))) (cell (hc-resolve-cell ctx sym)))
(if (and cell (var-cell-defined? cell)) (if (and cell (var-cell-defined? cell))
(jolt-hash-map hc-kw-kind hc-kw-var (let ((base (jolt-hash-map hc-kw-kind hc-kw-var
hc-kw-ns (var-cell-ns cell) hc-kw-ns (var-cell-ns cell)
hc-kw-name (var-cell-name cell)) hc-kw-name (var-cell-name cell)))
(nr (hc-cell-num-ret cell)))
(if nr (jolt-assoc base hc-kw-num-ret nr) base))
(jolt-hash-map hc-kw-kind hc-kw-unresolved hc-kw-name nm)))) (jolt-hash-map hc-kw-kind hc-kw-unresolved hc-kw-name nm))))
(define (hc-intern! ctx ns-name nm) (declare-var! ns-name nm) jolt-nil) (define (hc-intern! ctx ns-name nm) (declare-var! ns-name nm) jolt-nil)

File diff suppressed because one or more lines are too long

View file

@ -80,15 +80,26 @@
(let [m (form-sym-meta sym)] (let [m (form-sym-meta sym)]
(when m (let [t (get m :tag)] (when t (record-ctor-key ctx t)))))) (when m (let [t (get m :tag)] (when t (record-ctor-key ctx t))))))
;; A primitive numeric hint (^long / ^double) on a binding symbol -> :long / ;; A ^long / ^double tag -> :long / :double, else nil. The tag is a string on the
;; :double, else nil. Drives the fl*/fx* fast path (jolt.passes.numeric). The tag ;; data reader; tolerate a symbol from macroexpansion too.
;; is a string on the data reader; tolerate a symbol from macroexpansion too. (defn- tag->nkind [t]
(defn- nhint-of [ctx sym] (let [s (cond (form-sym? t) (form-sym-name t) (string? t) t :else nil)]
(let [m (form-sym-meta sym)
t (when m (get m :tag))
s (cond (form-sym? t) (form-sym-name t) (string? t) t :else nil)]
(cond (= s "double") :double (= s "long") :long :else nil))) (cond (= s "double") :double (= s "long") :long :else nil)))
;; A primitive numeric hint (^long / ^double) on a binding symbol. Drives the
;; fl*/fx* fast path (jolt.passes.numeric).
(defn- nhint-of [ctx sym]
(let [m (form-sym-meta sym)] (when m (tag->nkind (get m :tag)))))
;; Push a numeric return hint (from ^double/^long on a defn's name) onto each arity
;; of its fn, so the back end coerces the body's value to that kind on return —
;; making the hint a contract a caller's arithmetic can trust.
(defn- with-ret-nhint [node kind]
(if (and kind (= :fn (:op node)))
(assoc node :arities (mapv (fn [a] (if (:ret-nhint a) a (assoc a :ret-nhint kind)))
(:arities node)))
node))
(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)]
@ -304,7 +315,8 @@
base0) base0)
node-meta (if has-doc (assoc base-meta :doc (nth items 2)) base-meta)] node-meta (if has-doc (assoc base-meta :doc (nth items 2)) base-meta)]
(host-intern! ctx cur nm) (host-intern! ctx cur nm)
(def-node cur nm (analyze ctx val-form env) node-meta))))) ;; a ^double/^long return hint on the name applies to all arities of the fn.
(def-node cur nm (with-ret-nhint (analyze ctx val-form env) (tag->nkind tag)) node-meta)))))
;; (set! (.-field obj) v) mutates a deftype instance field in place; (set! *var* v) ;; (set! (.-field obj) v) mutates a deftype instance field in place; (set! *var* v)
;; sets the var's innermost thread binding, else its root. A local target (jolt ;; sets the var's innermost thread binding, else its root. A local target (jolt
@ -493,14 +505,16 @@
(let [h (get (:hints env) nm)] (if h (assoc (local nm) :hint h) (local 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)) (cond-> (var-ref (:ns r) (:name r)) (:num-ret r) (assoc :num-ret (:num-ret r)))
;; A non-var qualified ref `Class/member` is a host class static ;; A non-var qualified ref `Class/member` is a host class static
;; (Math/sqrt, Long/MAX_VALUE, System/getenv). The Chez back end ;; (Math/sqrt, Long/MAX_VALUE, System/getenv). The Chez back end
;; lowers it to a runtime static dispatch. ;; lowers it to a runtime static dispatch.
(host-static ns nm))) (host-static ns nm)))
:else (let [r (resolve-global ctx form)] :else (let [r (resolve-global ctx form)]
(case (:kind r) (case (:kind r)
:var (var-ref (:ns r) (:name r)) ;; :num-ret (a ^double/^long declared return) rides on the var node so
;; jolt.passes.numeric types a call to it (an accumulator over the result).
:var (cond-> (var-ref (:ns r) (:name r)) (:num-ret r) (assoc :num-ret (:num-ret r)))
:host (host-ref (:name r)) :host (host-ref (:name r))
;; :unresolved — emitting a var-ref here would auto-intern an ;; :unresolved — emitting a var-ref here would auto-intern an
;; UNBOUND var, so a typo'd symbol would die later as 'Cannot call ;; UNBOUND var, so a typo'd symbol would die later as 'Cannot call

View file

@ -373,8 +373,15 @@
pbind (map (fn [o p] (str "(" p " " (nhint-init nh o p) ")")) orig params) pbind (map (fn [o p] (str "(" p " " (nhint-init nh o p) ")")) orig params)
binds (if restp binds (if restp
(concat pbind [(str "(" restp " (list->cseq " restp "))")]) (concat pbind [(str "(" restp " (list->cseq " restp "))")])
pbind)] pbind)
[paramlist (str "(let " label " (" (str/join " " binds) ") " body ")")])) lett (str "(let " label " (" (str/join " " binds) ") " body ")")
;; a ^double/^long return hint coerces the arity's value on the way out
;; (exact->inexact / jolt->fx), like a JVM primitive return — so a caller's
;; arithmetic over the result is sound.
ret (:ret-nhint a)]
[paramlist (cond (= ret :double) (str "(exact->inexact " lett ")")
(= ret :long) (str "(jolt->fx " lett ")")
:else lett)]))
(defn- emit-fn [node] (defn- emit-fn [node]
(let [arities (:arities node) (let [arities (:arities node)

View file

@ -100,8 +100,13 @@
argnodes (mapv (fn [r] (nth r 1)) ars) argnodes (mapv (fn [r] (nth r 1)) ars)
node1 (assoc node :args argnodes) node1 (assoc node :args argnodes)
n (count ars)] n (count ars)]
(if (nil? nm) (cond
[nil node1] ;; a call to a var with a declared numeric return (^double/^long) yields that
;; kind, so an accumulator over the result types. The call itself isn't an
;; arithmetic op to lower — its body already coerces the return.
(get fnode :num-ret) [(get fnode :num-ret) node1]
(nil? nm) [nil node1]
:else
(let [;; per-operand class: :double / :long (typed), :wild (integer literal, (let [;; per-operand class: :double / :long (typed), :wild (integer literal,
;; usable in either), or :no (anything else — blocks specialization). ;; usable in either), or :no (anything else — blocks specialization).
cls (mapv (fn [r] (let [k (nth r 0) nd (nth r 1)] cls (mapv (fn [r] (let [k (nth r 0) nd (nth r 1)]

View file

@ -100,5 +100,24 @@
(ok "long-seeded loop accumulator counts to 100" (ok "long-seeded loop accumulator counts to 100"
(= 100 (jnum->exact (ev "((fn* ([^long start] (loop [acc start] (if (< acc 100) (recur (inc acc)) acc)))) 0)")))) (= 100 (jnum->exact (ev "((fn* ([^long start] (loop [acc start] (if (< acc 100) (recur (inc acc)) acc)))) 0)"))))
;; --- numeric return hints (round 3) ---
;; a ^double / ^long return hint coerces the fn's value on the way out (the contract).
(jolt-compile-eval "(def ^double dsq (fn* ([x] (* x x))))" "u")
(ok "^double return coerces value to a flonum" (flonum? (ev "(dsq 3)")))
(jolt-compile-eval "(def ^long ldbl (fn* ([x] (+ x x))))" "u")
(ok "^long return coerces value to a fixnum" (let ((r (ev "(ldbl 5)"))) (and (fixnum? r) (= r 10))))
;; the defn macro must carry the name's return hint through to the def.
(jolt-compile-eval "(defn ^double dnsq [x] (* x x))" "u")
(ok "defn ^double return coerces to flonum" (flonum? (ev "(dnsq 4)")))
;; caller propagation: a call to a ^double-returning fn types an accumulator over it.
(let ((e (emitf "u" "(fn* ([] (loop [acc 0.0 i 0] (if (< i 3) (recur (+ acc (dsq 2.0)) (inc i)) acc))))")))
(ok "accumulator over a ^double-returning call lowers to fl+" (has? e "(fl+")))
(ok "accumulator over ^double call runtime: 3 * (2*2) = 12.0"
(= 12 (jnum->exact (ev "((fn* ([] (loop [acc 0.0 i 0] (if (< i 3) (recur (+ acc (dsq 2.0)) (inc i)) acc)))))"))))
;; a ^double call result also specializes a straight-line op
(let ((e (emitf "u" "(fn* ([^double y] (+ y (dsq 2.0))))")))
(ok "straight-line op over ^double call lowers to fl+" (has? e "(fl+")))
(printf "~a/~a passed~n" (- total fails) total) (printf "~a/~a passed~n" (- total fails) total)
(exit (if (zero? fails) 0 1)) (exit (if (zero? fails) 0 1))