Hintless whole-program double inference (#230)

The closed-world fixpoint (#226) flowed record types across fn boundaries; this
adds a numeric refinement so a hintless fn whose every call site passes a flonum
has its param unboxed to fl-ops, no ^double hint needed.

Lattice gains :double, a flonum refinement of :num: two doubles join to :double,
a double joined with anything else widens to :num — so a param is :double only
when every contributing value is a flonum, which is what makes the fl-op sound.
infer types a flonum literal and flonum arithmetic (+ - * / min max inc dec over
double/int-literal operands) as :double, and the fixpoint joins those across call
sites and return types like any other lattice value.

The bridge to the existing hint-directed pass is a synthetic [param :double]
nhint: wp-infer! stashes the :double params separately from the structural seeds,
and run-passes injects them as nhints before numeric/annotate, so the fl-op
emission and the exact->inexact entry coercion (a no-op on a proven flonum) apply
unchanged.

Sound subset only: :double, never :long — an untyped integer can be a bignum and
fx-ops would overflow/diverge from jolt's arbitrary precision. So an integer
caller leaves a param generic; an escaped fn (unknown callers) keeps :any.

run-numwp.ss gate: cross-fn :double propagation incl. through a flonum-returning
helper, the integer-caller and escape negatives, and the full run-passes path
emitting fl* + entry coercion. make test / shakesmoke green, selfhost holds, 0
new divergences.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-26 14:18:10 +00:00 committed by GitHub
parent e6e3612332
commit 8ae45057d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 453 additions and 268 deletions

View file

@ -22,7 +22,7 @@
set-rtenv! set-vtypes! join-types
set-record-shapes! set-map-shapes! set-protocol-methods!
reset-escapes! collected-escapes
wp-infer! param-seeds-for
wp-infer! param-seeds-for param-num-seeds-for
set-check-mode! take-diags!]]))
;; Cap on inline -> flatten -> scalar-replace -> const-fold iterations. Each pass
@ -40,6 +40,22 @@
(let [a (first (:arities (:init node)))]
{:params (:params a) :body (:body a) :nhints (:nhints a) :ret (:ret-nhint a)}))
(defn inject-wp-nhints
"Merge the whole-program :double param seeds into a def's arity :nhints as
synthetic ^double hints, so the numeric pass unboxes a hintless fn whose callers
all pass flonums (the entry coercion exact->inexact is a no-op on a proven
flonum). Only un-hinted params are added an explicit hint wins. A no-op unless
the closed-world fixpoint typed a param :double (param-num-seeds-for)."
[node]
(let [seeds (when (= :def (:op node)) (param-num-seeds-for (str (:ns node) "/" (:name node))))
f (:init node)]
(if (and seeds (= :fn (:op f)) (= 1 (count (:arities f))))
(let [a (first (:arities f))
have (into #{} (map first (:nhints a)))
add (for [[p k] seeds :when (not (have p))] [p k])]
(assoc node :init (assoc f :arities [(assoc a :nhints (vec (concat (:nhints a) add)))])))
node)))
(defn run-passes
"All passes, in order. The back end applies this to every analyzed form. When
inlining is enabled for the unit (user code under direct-linking),
@ -75,6 +91,7 @@
;; everything else takes the ordinary per-form inference.
seeds (when (= :def (:op opt)) (param-seeds-for (str (:ns opt) "/" (:name opt))))]
;; a final const-fold after inference propagates any predicate folded to a
;; constant, collapsing the `if` it gates to the taken branch.
(const-fold (if seeds (reinfer-def opt seeds) (run-inference opt))))
;; constant, collapsing the `if` it gates to the taken branch; then inject
;; any whole-program :double param hints for the numeric pass that follows.
(inject-wp-nhints (const-fold (if seeds (reinfer-def opt seeds) (run-inference opt)))))
(const-fold node))))

View file

@ -135,7 +135,7 @@
(if (and (seq vs) (not (nil? (first vs))) (apply = vs)) (first vs) nil))
:else
(case pname
"number?" (= t :num)
"number?" (or (= t :num) (= t :double))
"string?" (= t :str)
"keyword?" (= t :kw)
"record?" (record-t? t)
@ -154,6 +154,23 @@
(defn- ty [r] (nth r 0))
(defn- nd [r] (nth r 1))
;; arithmetic core ops that yield a flonum when their operands are flonums — a
;; mirror of jolt.passes.numeric/dbl-spec's arithmetic set, used to flow :double
;; across fn boundaries so a hintless fn whose callers all pass doubles is unboxed.
;; Comparisons are excluded: they yield a boolean, not a number.
(def ^:private dbl-arith-ops #{"+" "-" "*" "/" "min" "max" "inc" "dec"})
(defn- int-lit-node? [n]
(and (= :const (get n :op)) (let [v (get n :val)] (and (number? v) (integer? v)))))
;; an arithmetic result is :double when every operand is a proven flonum or an
;; integer literal (a wildcard the fl-op coerces) and at least one is a flonum — so
;; (* x 2) with x:double is :double, but (* a b) with both :num stays :num (no
;; flonum proof, no fl-op).
(defn- dbl-arith? [ares argnodes]
(and (pos? (count ares))
(every? (fn [i] (or (= :double (ty (nth ares i))) (int-lit-node? (nth argnodes i))))
(range (count ares)))
(some (fn [r] (= :double (ty r))) ares)))
;; HOFs that apply their fn arg to the ELEMENTS of a collection. :epos is which
;; param of the fn receives an element. reduce is
;; handled separately (its arity changes the coll position, and its closure
@ -310,6 +327,9 @@
(= cn "range") (mk-vec :num)
(and cn (contains? elem-fns cn) (> n 0))
(let [a0 (ty (nth ares 0))] (if (vec-type? a0) (velem a0) :any))
;; flonum arithmetic yields a flonum — flows :double into a callee param
;; (and into the fixpoint's return type) so hintless double code unboxes.
(and cn (contains? dbl-arith-ops cn) (dbl-arith? ares args)) :double
:else (call-ret-type fnode env))
(if rtype
(assoc base :devirt-type rtype :devirt-proto (nth pm 0) :devirt-method (nth pm 1))
@ -355,7 +375,8 @@
(cond
(= op :const)
[(let [v (get node :val)]
(cond (number? v) :num
(cond (and (number? v) (float? v)) :double ; a flonum literal is :double
(number? v) :num
(string? v) :str
(keyword? v) :kw
(or (nil? v) (= false v)) :any ; nil/false are not struct-eligible
@ -676,6 +697,15 @@
nil. Set by wp-infer!, read by run-passes during the final per-def emit."
[k] (get @wp-seeds-box k))
;; numeric refinement of the same fixpoint: params the closed-world join proved
;; are always flonums. Kept SEPARATE from the structural box — these don't reinfer
;; (field-read/devirt), they become synthetic ^double nhints (jolt.passes/inject-
;; wp-nhints) so the hint-directed pass unboxes the arithmetic.
(def ^:private wp-num-seeds-box (atom {}))
(defn param-num-seeds-for
"The param-name -> :double seed map for a def's hintless flonum params, or nil."
[k] (get @wp-num-seeds-box k))
;; var-key -> {:params [names] :body ir} for each single-fixed-arity fn def.
(defn- wp-specializable [nodes]
(reduce (fn [m d]
@ -751,16 +781,21 @@
(let [seed-ptypes (if converged?
new-ptypes
(reduce (fn [m k] (assoc m k (vec (repeat (count (get m k)) :any))))
new-ptypes ks))]
(reset! wp-seeds-box
(reduce (fn [m k]
(let [s (get spec k)
ptmap (reduce (fn [pm pr]
new-ptypes ks))
;; build both seed maps from the same converged ptypes: the
;; structural one (struct/vec, drives reinfer-def's field-read/
;; devirt) excludes :double; the numeric one keeps only :double.
pick (fn [keep?]
(reduce (fn [m k]
(let [s (get spec k)
pm (reduce (fn [pm pr]
(let [nm (nth pr 0) t (nth pr 1)]
(if (and t (not= t :any)) (assoc pm nm t) pm)))
(if (and t (keep? t)) (assoc pm nm t) pm)))
{} (map vector (:params s) (get seed-ptypes k)))]
(if (seq ptmap) (assoc m k ptmap) m)))
{} ks)))
(if (seq pm) (assoc m k pm) m)))
{} ks))]
(reset! wp-seeds-box (pick (fn [t] (and (not= t :any) (not= t :double)))))
(reset! wp-num-seeds-box (pick (fn [t] (= t :double)))))
(recur (inc iter) new-ptypes new-rets))))))
;; Piggyback checking (jolt audit). In direct-link mode infer-top already runs

View file

@ -79,6 +79,12 @@
(= a b) a
(nil? a) b
(nil? b) a
;; :double is a flonum refinement of :num: two doubles stay :double (caught by
;; = above), but a double joined with anything else loses the flonum guarantee
;; and widens to :num before joining — so a param is :double only when EVERY
;; contributing value is a flonum, which is what makes the hintless fl-op sound.
(or (= a :double) (= b :double))
(join-t (if (= a :double) :num a) (if (= b :double) :num b))
(and (struct-type? a) (struct-type? b))
(let [merged (mk-struct (merge-fields (sfields a) (sfields b)))]
;; joining two values of the SAME complete shape preserves it — the
@ -145,7 +151,7 @@
;; (vs a phm) when every value is non-nil/non-false, so a map literal is a struct
;; only when all its values have such a type. Collections are non-nil.
(defn truthy-type? [t]
(or (= t :num) (= t :str) (= t :kw) (= t :truthy) (= t :phm)
(or (= t :num) (= t :double) (= t :str) (= t :kw) (= t :truthy) (= t :phm)
(struct-type? t) (vec-type? t) (set-type? t)))
;; core fns whose result is a number (so it is non-nil/non-false and, for the