clojure 2025-08-12

“fix” for (transient #{}) / conj! behavior to match conj when existing set items have metadata, thoughts? 🧵

👀 1

(defn conj-conj!
  "conj! that matches the behavior of conj in terms of handling transients."
  ;TODO measure performance cost
  ([] (transient []))
  ([coll] coll)
  ([^clojure.lang.ITransientCollection coll x]
   (if (contains? coll x)
     coll
     (.conj coll x))))

;; conj! an item with metadata vs regular conj problem showcase 
(comment
  (let [set-debug (fn [s msg]
                    (set! *print-meta* true)
                    (println msg)
                    (clojure.pprint/pprint
                      {:item-from-set      (s [:a])
                       :first-of-set       (first s)
                       :seq-of-set         (seq s)
                       :meta-of-first-of-s (meta (first s))})
                    (println))
        v1        (with-meta [:a] {:n 0})
        v2        (with-meta [:a] {:n -42})
        ;conj! problem
        s         (-> (transient #{}) (conj! v1) (conj! v2))
        bad-set   (persistent! s)
        ;conj-conj! fix
        s         (-> (transient #{}) (conj-conj! v1) (conj-conj! v2))
        good-set  (persistent! s)]
    (set-debug bad-set "bad set")
    (set-debug good-set "good set, maybe?")))

bad set
{:item-from-set ^{:n -42} [:a],
 :first-of-set ^{:n 0} [:a],
 :seq-of-set (^{:n 0} [:a]),
 :meta-of-first-of-s {:n 0}}

good set, maybe?
{:item-from-set ^{:n 0} [:a],
 :first-of-set ^{:n 0} [:a],
 :seq-of-set (^{:n 0} [:a]),
 :meta-of-first-of-s {:n 0}}

Seems to “work”; the biggest unknown is the performance cost of the “read before every transient conj! “; i.e., the worst-case scenario is if this performs same/worse than regular conj on a #{} ; In any case, it should generate less garbage collector pressure, as far as my understanding of the immutable persistent data structure goes. Whether that’s significant depends on… many things.

(if (contains? coll x) For sets there's a couple different ways to do this if performance does end up being poor (e.g. (get coll x) , (coll x), etc.) wouldn't expect performance to be that notably different but probably worth benchmarking them all

Sets can contain nil so (get coll x) is not the same as (contains? coll x)

👍🏻 1

Also, (get coll false) will be false (present) or nil (not present) but (contains? coll false) will be true (present) or false (not present).

👍🏻 1

@seancorfield yeah good call

Would there be any benefit to calling (. clojure.lang.RT (contains coll key)) directly or does that not matter due to contains? being marked :static in its metadata?

Because of stuff like JIT optimization, the only real way to tell for these sorts of minor performance tweaks is to benchmark them with a wide range of inputs. There's also the question of whether you want to address this for JVM Clojure only or also for ClojureScript et al.

👍 1
➕ 2

@seancorfield for sure; my main motivation was to match the behavior of conj which seems to work on the surface; Performance is a more tricky topic with a ton of context specifics, I agree.

@emma.griffin as far as I know, invoke on a coll (#{:a} :a) can be typically ever-so-slightly faster than get . Probably a few nanoseconds?

In my experience though, that’s probably the very last place to look for performance gains… Chasing those is almost certainly a red herring, and I’ve been down those rabbit holes at times for an hour or two.

👍🏻 1

looks like it was a really fast slot. and then perhaps the idea was the most pervasive slots we have like this are mapentries and so seems like vector just impelements that?

❯ clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.8.0-RC3"}}}'
Downloading: org/clojure/clojure/1.8.0-RC3/clojure-1.8.0-RC3.pom from central
Downloading: org/clojure/clojure/1.8.0-RC3/clojure-1.8.0-RC3.jar from central
Clojure 1.8.0-RC3
user=> (.getKey [:a :b])
:a
user=> (key [:a :b])
:a

❯ clj
Clojure 1.12.0
user=> (.getKey [:a :b])
Execution error (IllegalArgumentException) at user/eval1 (REPL:1).
No matching field found: getKey for class clojure.lang.PersistentVector
user=> (key [:a :b])
Execution error (ClassCastException) at user/eval3 (REPL:1).
class clojure.lang.PersistentVector cannot be cast to class java.util.Map$Entry (clojure.lang.PersistentVector is in unnamed module of loader 'app'; java.util.Map$Entry is in module java.base of loader 'bootstrap')
user=>

There might be contemporaneous conversation still on another forum. The comment at the top says "/ rich 7/16/15 / // proposed by Zach Tellman" reminds me of a time when Zach had demonstrated generating tuple or array classes of various sizes (for speed) and Rich, crediting it, had tried more of a one-size-fits-all and eventually concluded that either way was too big a risk of pettifogging call sites and basically the JIT technology of the time was not ripe for such things.

i saw the commit was 10 years ago so i was checking clojure versions 1.2 and didn’t think that it was in a 1.8 beta 😞

oh yeah. zach has some cool libraries. one about tuples which seems to be direct lineage here. and another about different wrapper classes that you can set values in

Alex Miller (Clojure team) 2025-08-13T01:08:55.576619Z

This was an idea Zach tellman had to make fixed size small vectors for optimized perf. Rich did his version of it and while it had a lot of benefits it also made every vector callsite polymorphic instead of monomorphic which foils a lot of hot spot optimization, so Rich rolled it back

💡 1
Alex Miller (Clojure team) 2025-08-13T01:09:38.126969Z

Looked great in simple benchmarks but very murky in reality

✔️ 1
Alex Miller (Clojure team) 2025-08-13T01:11:04.733099Z

That said, that story changes a lot over time

2

Thanks for the explanation!

🙌 1

Java has two sized specialized list impls, List12 and ListN

💡 2

Clojure's Tuple had like 6... Hotspot only optimizes for at maximum 2 targets

user=> (class (java.util.List/of 1))
java.util.ImmutableCollections$List12
user=> (class (java.util.List/of 1 2 3 4))
java.util.ImmutableCollections$ListN

Thanks @ghadi! Any good reading on optimizing collection implementations for hotspot?

Write it the simplistic way and move on

having 1 impl is (in general) better than having many specialized ones (see channels in core.async. There's just one)

👍🏻 1