phm/phs: bulk bottom-up HAMT build for the map/set builders (jolt-5vsp) (#154)

into {}, frequencies, group-by, set, into #{} and persistent! all built
their result by folding an immutable assoc/conj per element — each call
rebuilt the O(log32 n) trie path and allocated a fresh wrapper. Add a
one-pass bottom-up HAMT builder (phm-from-pairs) and route the builders
through it, the map/set analog of the pvec bulk build in #153.

phm-from-pairs partitions entries by hash and constructs the bin/array/
collision nodes directly, with the same bin<=16 / array-node>=17 promotion
the incremental path uses — so the trie is byte-identical to one built by
phm-assoc (validated across the size and branching boundaries, including
hash collisions, duplicate keys and the nil key). persistent! map/set and
the set constructor bulk-build; into {} keeps the small-scalar-map-stays-a
-struct rule via bulk-map-from-pairs; frequencies/group-by switch to the
canonical transient form and ride the fast persistent!.

50k A/B: into {} 704->270ms, frequencies 582->160, set 615->241,
into #{} 702->240, group-by 1358->919 (bound on persistent vector conj).

Gate: conformance x3, full suite (4718 >= baseline), new maps/sets bulk
boundary specs.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-16 22:24:38 +00:00 committed by GitHub
parent 43e5426601
commit 1873736045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 182 additions and 18 deletions

View file

@ -64,15 +64,18 @@
(defn println [& xs] (apply print xs) (__write "\n") nil)
;; Base is (hash-map), not the {} literal: a literal map is a struct that doesn't
;; canonicalize collection keys across representations (a {:a 1} literal vs
;; (hash-map :a 1) key), whereas a PHM does — so counting/grouping by collection
;; value needs the PHM base (the prior Janet impl used make-phm for this reason).
;; Transient accumulation (canonical JVM form): assoc! into a native-backed
;; scratch table per element, then persistent! bulk-builds the HAMT once —
;; instead of a fresh persistent assoc (full trie-path rebuild) per element.
;; A transient map canonicalizes collection keys (it is canon-keyed, like a
;; PHM), so counting/grouping by collection value still works across map reps.
(defn frequencies [coll]
(reduce (fn [counts x] (assoc counts x (inc (get counts x 0)))) (hash-map) coll))
(persistent!
(reduce (fn [counts x] (assoc! counts x (inc (get counts x 0)))) (transient {}) coll)))
(defn group-by [f coll]
(reduce (fn [ret x] (let [k (f x)] (assoc ret k (conj (get ret k []) x)))) (hash-map) coll))
(persistent!
(reduce (fn [ret x] (let [k (f x)] (assoc! ret k (conj (get ret k []) x)))) (transient {}) coll)))
(defn not-empty [coll]
(if (or (nil? coll) (zero? (count coll))) nil coll))

View file

@ -421,9 +421,43 @@
(string? coll) (make-vec (map |(string/from-bytes $) (string/bytes coll)))
(make-vec @[]))))
# Bulk-build a map value from a native array of [k v] pairs, mirroring
# core-assoc's struct-vs-phm choice: stay a small Janet struct (the
# PersistentArrayMap analog) when every key is a scalar, there is no nil key or
# value, and the distinct count is within the threshold; otherwise bulk-build
# the HAMT once (phm-from-pairs). Used by into-a-struct-map (jolt-5vsp).
(defn- bulk-map-from-pairs [pairs]
(var promote false)
(def t @{})
(each p pairs
(def k (vnth p 0)) (def v (vnth p 1))
(if (or (table? k) (array? k) (nil? k) (nil? v))
(set promote true)
(put t k v))) # scalar keys: last-wins
(if (or promote (> (length t) map-array-threshold))
(phm-from-pairs pairs)
(table/to-struct t)))
(defn- into-conj [to items]
(cond
(or (phm? to) (struct? to) (and (table? to) (get to :jolt/deftype)))
# into onto an existing phm stays a phm: accumulate its entries + the new
# [k v] items and bulk-build the HAMT ONCE (phm-from-pairs, last-value-wins),
# instead of a phm-assoc per element. jolt-5vsp collections.
(phm? to)
(let [acc (array)]
(each e (phm-entries to) (array/push acc e))
(each item items (array/push acc [(vnth item 0) (vnth item 1)]))
(phm-from-pairs acc))
# into onto a plain map struct (e.g. the {} literal): same one-pass build,
# but keep the small-scalar-map-stays-a-struct rule (bulk-map-from-pairs) so
# the representation matches the incremental assoc-onto-a-struct path.
(and (struct? to) (nil? (get to :jolt/type)))
(let [acc (array)]
(each e (pairs to) (array/push acc e))
(each item items (array/push acc [(vnth item 0) (vnth item 1)]))
(bulk-map-from-pairs acc))
# Other map-likes (sorted maps, deftype records): fold core-assoc.
(or (struct? to) (and (table? to) (get to :jolt/deftype)))
(do (var result to)
(each item items (set result (core-assoc result (vnth item 0) (vnth item 1))))
result)
@ -437,7 +471,13 @@
(do (each x items (array/push to x)) to) # vector: append
(do (var result (array/slice to)) (each x items (array/insert result 0 x)) result)) # list: prepend
(tuple? to) (tuple/slice (tuple ;(array/concat (array/slice to) (array/slice items))))
# everything else conj-able (sets, sorted colls): fold conj — previously
# hash-set target: accumulate existing members + new items and bulk-build the
# backing HAMT ONCE (phs-from-seq), instead of a phs-conj per element.
(set? to) (let [acc (array)]
(each x (phs-seq to) (array/push acc x))
(each x items (array/push acc x))
(phs-from-seq acc))
# everything else conj-able (sorted colls): fold conj — previously
# this fell through to `to` unchanged, silently dropping all elements
# ((into #{} [:a :b]) was #{}, jolt-h86)
(do (var result to) (each x items (set result (core-conj result x))) result)))

View file

@ -427,8 +427,12 @@
(def result
(case (t :kind)
:vector (make-vec (t :arr))
:set (do (var s (make-phs)) (each [_ e] (pairs (t :tbl)) (set s (phs-conj s e))) s)
:map (do (var m (make-phm)) (each [_ pair] (pairs (t :tbl)) (set m (phm-assoc m (in pair 0) (in pair 1)))) m)))
# The transient already deduped into a native table; bulk-build the
# persistent value ONCE (bottom-up HAMT) instead of folding a phm-assoc
# per entry. This is the lever behind every transient-based builder
# (frequencies/group-by/set/into) — jolt-5vsp collections.
:set (phs-from-seq (values (t :tbl)))
:map (phm-from-pairs (values (t :tbl)))))
# Invalidate: any further bang op (or a second persistent!) now throws.
(put t :jolt/persistent true)
result)

View file

@ -297,10 +297,83 @@
(defn phm-count [m] (m :cnt))
# --- bulk bottom-up build (jolt-5vsp collections) ---------------------------
# Build the HAMT in one pass from a native array of entries, instead of n
# incremental phm-assoc calls (each of which rebuilt the O(log32 n) path with
# fresh arrays AND allocated a fresh map wrapper). The structure produced is
# IDENTICAL to the incremental one: the trie shape is a function of the key set
# (hash-partitioned, with the same bin<=16 / array-node>=17 promotion threshold
# at each level), so nth/lookup/assoc/without/each read it unchanged. Only a
# hash-collision node's internal order is insertion-dependent, and we preserve
# insertion order there too. Validated against phm-assoc across the size and
# branching boundaries (see the throwaway script in the PR).
#
# Entries are @[h key val] triples (h = khash key). build-node takes a non-empty
# group all destined for the same parent slot and returns its node.
(defn- all-same-hash? [entries]
(def h0 (in (in entries 0) 0))
(var same true) (var i 1) (def L (length entries))
(while (< i L) (when (not= (in (in entries i) 0) h0) (set same false) (break)) (++ i))
same)
(defn- build-node [entries shift]
(if (and (> (length entries) 1) (all-same-hash? entries))
# all keys share a full 32-bit hash -> collision node (insertion order)
(let [arr (array)]
(each e entries (array/push arr (in e 1)) (array/push arr (in e 2)))
[:hcn (in (in entries 0) 0) (length entries) arr])
# partition by the 5-bit mask at this level; iterate slots 0..31 ascending
# so the bin arr / array-node slots land in canonical (bidx) order.
(let [groups @{}]
(each e entries
(def m (mask (in e 0) shift))
(if-let [g (in groups m)] (array/push g e) (put groups m @[e])))
(def occupied (array))
(var i 0) (while (< i 32) (when (in groups i) (array/push occupied i)) (++ i))
(def s (length occupied))
(if (>= s 17)
# array-node: every occupied slot is a sub-node (a lone key becomes its
# own single-key bin node at shift+5, matching the expand path).
(let [slots (array/new-filled 32 nil)]
(each m occupied (put slots m (build-node (in groups m) (+ shift 5))))
[:an s slots])
# bitmap-indexed node: lone key -> leaf pair, group -> nil + sub-node.
(let [arr (array)]
(var bitmap 0)
(each m occupied
(def g (in groups m))
(set bitmap (bor bitmap (blshift 1 m)))
(if (= 1 (length g))
(do (array/push arr (in (in g 0) 1)) (array/push arr (in (in g 0) 2)))
(do (array/push arr nil) (array/push arr (build-node g (+ shift 5))))))
[:bin bitmap arr])))))
(defn phm-from-pairs [pairs &opt meta]
"Bulk-build a phm from an indexed collection of native [k v] pairs. Duplicate
keys follow assoc semantics (last value wins, first-seen key object kept). The
caller must pass native tuples/arrays (phm stays free of the value layer)."
(default meta nil)
(def entries @[])
(def seen @{}) # canon-key -> index into entries (dedup)
(var has-nil false) (var nil-val nil)
(each p pairs
(def k (in p 0)) (def v (in p 1))
(if (nil? k)
(do (set has-nil true) (set nil-val v))
(let [c (ck k)]
(if-let [idx (in seen c)]
(put (in entries idx) 2 v) # last value wins
(do (put seen c (length entries))
(array/push entries @[(khash k) k v]))))))
(def cnt (+ (length entries) (if has-nil 1 0)))
(def root (if (= 0 (length entries)) nil (build-node entries 0)))
(mk cnt root has-nil nil-val meta))
(defn make-phm [&opt kvs]
(default kvs nil)
(var m (mk 0 nil false nil nil))
(when kvs
(var i 0) (def n (length kvs))
(while (< i n) (set m (phm-assoc m (kvs i) (kvs (+ i 1)))) (+= i 2)))
m)
(if (or (nil? kvs) (= 0 (length kvs)))
(mk 0 nil false nil nil)
# pair up the flat [k0 v0 k1 v1 ...] array and bulk-build
(let [pairs (array) n (length kvs)]
(var i 0) (while (< i n) (array/push pairs [(in kvs i) (in kvs (+ i 1))]) (+= i 2))
(phm-from-pairs pairs))))

View file

@ -14,11 +14,19 @@
[x]
(and (table? x) (= :jolt/set (x :jolt/type))))
(defn phs-from-seq [xs]
"Bulk-build a PersistentHashSet from an indexed collection of members. Members
back a phm as keys mapped to true, so the bulk HAMT builder (phm-from-pairs,
which dedups by canonical key) gives set semantics in one pass instead of an
immutable phm-assoc per element."
(def pairs @[])
(each x xs (array/push pairs [x true]))
(def m (phm-from-pairs pairs))
@{:jolt/type :jolt/set :phm m :cnt (phm-count m)})
(defn make-phs [& xs]
"Create a PersistentHashSet from items."
(var m (make-phm))
(each x xs (set m (phm-assoc m x true)))
@{:jolt/type :jolt/set :phm m :cnt (phm-count m)})
(phs-from-seq xs))
(defn phs-conj [s & xs]
(var m (s :phm))

View file

@ -187,3 +187,24 @@
["assoc-in deep get" "9" "(get-in (assoc-in {} [:a :b :c] 9) [:a :b :c])"]
["seq over assoc-nil map" ":a" "(ffirst (seq (assoc nil :a 1)))"]
["keys of assoc-nil map" "[:a]" "(vec (keys (assoc nil :a 1)))"])
# into {} / frequencies / group-by bulk-build the HAMT in one pass from a native
# pairs/table accumulator (phm-from-pairs), instead of an assoc per element
# (jolt-5vsp collections). Cross the bin(<=16)/array-node(>=17) promotion and a
# size that grows the trie a level; check dedup (last-wins), nil keys, and that
# assoc/dissoc after a bulk build still land. The structure is identical to the
# incremental builder (validated in the PR), so reads agree.
(defspec "map / bulk build boundaries"
["into = incr at 17" "true" "(= (into {} (map (fn [i] [i (* i 2)]) (range 17))) (reduce (fn [m p] (assoc m (first p) (second p))) {} (map (fn [i] [i (* i 2)]) (range 17))))"]
["into = incr at 1000" "true" "(= (into {} (map (fn [i] [i (* i 2)]) (range 1000))) (reduce (fn [m p] (assoc m (first p) (second p))) {} (map (fn [i] [i (* i 2)]) (range 1000))))"]
["into count 1000" "1000" "(count (into {} (map (fn [i] [i i]) (range 1000))))"]
["into reads back" "999" "(get (into {} (map (fn [i] [i (* i 3)]) (range 1000))) 333)"]
["into onto non-empty" "9" "(get (into {:a 1} [[:a 9] [:b 2]]) :a)"]
["into dup last wins" "9" "(get (into {} [[:k 1] [:k 9]]) :k)"]
["into nil key" ":x" "(get (into {} [[nil :x] [:a 1]]) nil)"]
["assoc after bulk" "7" "(get (assoc (into {} (map (fn [i] [i i]) (range 100))) :new 7) :new)"]
["dissoc after bulk" "nil" "(get (dissoc (into {} (map (fn [i] [i i]) (range 100))) 50) 50)"]
["frequencies count" "3" "(get (frequencies [1 2 2 1 2 1]) 1)"]
["frequencies coll-key" "2" "(get (frequencies [[1 2] [1 2] [3 4]]) [1 2])"]
["group-by bucket" "[1 3 5]" "(get (group-by odd? (range 1 6)) true)"]
["hash-map bulk = incr" "true" "(= (apply hash-map (mapcat (fn [i] [i i]) (range 50))) (reduce (fn [m i] (assoc m i i)) {} (range 50)))"])

View file

@ -56,3 +56,18 @@
["vector is not" "false" "(set? [1])"]
["coll? still true" "true" "(coll? (sorted-set 1))"]
["ifn? sorted-set" "true" "(ifn? (sorted-set 1))"])
# set / into #{} bulk-build the backing HAMT in one pass (phs-from-seq), instead
# of a phs-conj per element (jolt-5vsp collections). Cross the promotion
# boundary, check dedup, collection members, and conj after a bulk build.
(defspec "set / bulk build boundaries"
["set dedup count" "3" "(count (set [1 1 2 3 3 2]))"]
["set big count" "1000" "(count (set (range 1000)))"]
["into #{} count" "500" "(count (into #{} (range 500)))"]
["into #{} onto base" "3" "(count (into #{:a} [:a :b :c]))"]
["set contains" "true" "(contains? (set (range 1000)) 777)"]
["set missing" "false" "(contains? (set (range 1000)) 5000)"]
["set coll members" "true" "(contains? (set [[1 2] [3 4]]) [1 2])"]
["conj after bulk" "true" "(contains? (conj (set (range 100)) :x) :x)"]
["disj after bulk" "false" "(contains? (disj (set (range 100)) 50) 50)"]
["set = literal" "true" "(= #{0 1 2} (set (range 3)))"])