pv: build the vector trie bottom-up, not n incremental conjs (jolt-5vsp) (#153)

pv-from-indexed (behind vec/mapv/filterv/into-a-vector) built a pvec by calling
pv-conj once per element — each call allocated a fresh pvec wrapper and copied
the up-to-32 tail tuple, so building an n-vector was O(n) allocations + tail
copies. Replace it with a single bottom-up trie construction: chunk the elements
into 32-wide value leaves, group nodes 32-wide up to the root, split off the
tail. The structure is identical to the incremental one — tail-offset(n) =
((n-1)>>5)<<5 is exactly the trie/tail boundary, so nth/conj/assoc/seq read it
unchanged (validated against the old builder across the size boundaries).

into-a-vector likewise stops doing a persistent pv-conj per element: it
accumulates into a native array and bulk-builds once (the transient-style path).

Measured (50k): vec 211 -> 6 ms (~36x), into [] 197 -> 15 ms (~13x). mapv is
unchanged here — it's bottlenecked on lazy map realization, not the build.

The map/set builders (into {}, frequencies, group-by, set — all HAMT-backed)
need the same bulk treatment and are a separate follow-up. Gate: conformance x3,
full suite, new bulk-boundary rows in vectors-spec.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-16 21:31:26 +00:00 committed by GitHub
parent b771908e5b
commit 43e5426601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 51 additions and 6 deletions

View file

@ -427,7 +427,12 @@
(do (var result to)
(each item items (set result (core-assoc result (vnth item 0) (vnth item 1))))
result)
(pvec? to) (do (var result to) (each x items (set result (pv-conj result x))) result)
# Accumulate into a native array and bulk-build the pvec ONCE (pv-from-indexed),
# instead of an immutable pv-conj per element (each allocating a wrapper +
# copying the tail). This is the transient-style fast path for into-a-vector.
(pvec? to) (let [arr (array/slice (pv->array to))]
(each x items (array/push arr x))
(make-vec arr))
(array? to) (if mutable?
(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

View file

@ -156,9 +156,34 @@
out)
(defn pv-from-indexed [xs]
# Build a pvec from any Janet-indexed collection (tuple/array).
(var pv EMPTY)
# Build a pvec from any Janet-indexed collection (tuple/array) by constructing
# the trie BOTTOM-UP in one pass, instead of n incremental pv-conj calls (each
# of which allocated a pv wrapper and copied the tail). The structure produced
# is identical to the incremental one — tail-offset(n) = ((n-1)>>5)<<5 is
# exactly the trie/tail split, so leaf-for/nth/conj/assoc all read it the same.
# This is the bulk path behind vec/mapv/into-a-vector (jolt-5vsp collections).
(def n (length xs))
(var i 0)
(while (< i n) (set pv (pv-conj pv (in xs i))) (++ i))
pv)
(if (<= n width)
# everything fits in the tail (matches EMPTY's shift)
(make-pv n bits empty-node (tuple/slice xs))
(let [tail-len (+ 1 (mod (- n 1) width))
trie-count (- n tail-len) # = tail-offset(n), a multiple of width
tail (tuple/slice xs trie-count n)]
# value leaves: full 32-element tuples over [0, trie-count)
(def leaves @[])
(var i 0)
(while (< i trie-count)
(array/push leaves (tuple/slice xs i (+ i width)))
(set i (+ i width)))
# group nodes 32-wide, bottom-up, until <=32 remain; that becomes the root
(var level leaves)
(var shift bits)
(while (> (length level) width)
(def parent @[])
(var j 0)
(while (< j (length level))
(array/push parent (tuple/slice level j (min (length level) (+ j width))))
(set j (+ j width)))
(set level parent)
(set shift (+ shift bits)))
(make-pv n shift (tuple/slice level) tail))))

View file

@ -52,3 +52,18 @@
["large vector nth" "1500" "(nth (vec (range 2000)) 1500)"]
["large vector count" "2000" "(count (vec (range 2000)))"]
["large conj immutable" "true" "(let [v (vec (range 1000)) w (conj v :end)] (and (= 1000 (count v)) (= 1001 (count w))))"])
# vec/into build the pvec trie BOTTOM-UP in one pass (pv-from-indexed, jolt-5vsp
# collections). Exercise the structure transitions: 32 (tail full), 33 (first
# trie leaf), 1024 (root full at shift 5), 1025 (root grows to shift 10) — and
# that a conj/assoc after a bulk build still lands and reads back.
(defspec "vector / bulk build boundaries"
["count at 1025" "1025" "(count (vec (range 1025)))"]
["into = vec at 1025" "true" "(= (vec (range 1025)) (into [] (range 1025)))"]
["nth at leaf boundary" "32" "(nth (vec (range 1025)) 32)"]
["nth at root boundary" "1024" "(nth (vec (range 1025)) 1024)"]
["vec=into at 33" "true" "(= (vec (range 33)) (into [] (range 33)))"]
["conj after bulk 1024" "1025" "(count (conj (vec (range 1024)) :x))"]
["conj-after reads back" ":x" "(nth (conj (vec (range 1024)) :x) 1024)"]
["assoc into bulk vec" "9" "(nth (assoc (vec (range 1025)) 1000 9) 1000)"]
["into onto non-empty" "[0 1 2 0 1]" "(into (vec (range 3)) (range 2))"])