cljs-dev 2025-10-13

@dnolen any interest in speeding up str using a macro? I see around 300x performance gains with a pretty basic macro ๐Ÿงต

See https://clojurians.slack.com/archives/C06MAR553/p1760361991301469 for details The only downside I see right now is that users can't patch str calls at runtime if we optimize it using a macro but that's a very small price to pay

@borkdude str already has a macro variant - so your version is just inlining constants into the string?

Avoiding [..].join('') is where the most perf gain comes from, i.e. not allocating an array. Emitting '' + .. + ... seems to be much more optimizable for JS engines Since CLJS already has a macro, I think we can make that macro emit these calls Note that you still need to coalesce null / undefined to an empty string which is what cljs.core.str.cljs$core$IFn$_invoke$arity$1(~{})" is for, which is still fine since that's fast

and yeah for some values like string literals (which are pretty common in str) we can directly inline those

Note that since str is already using [..].join('') it didn't even need to call the single arities of str I think since [..].join('') already ignores null/undefined. But avoiding it all together is still faster (in my benchmarks)

hrm, I thought we switched to [].join('') to avoid some edge cases ...

I think one problem is that + will use .valueOf but [].join('') will use .toString if both are present

prettty sure this was reverted because changing to + broke stuff

ah darn, you're right:

cljs.user=> (def x #js {:valueOf (fn [] "dude") :toString (fn [] "string")})
#'cljs.user/x
cljs.user=> (let [+ +] (+ "" x))
"dude"

but since the macro is already using the 1-arity of str, + will do the right thing I think

when passed only strings, I mean

I suppose we could check the arguments to see they are all strings for a fast path.

I think a common case is strings intermingled with values. Also avoiding the [].join helps. Test function:

(fn [x y] (str x 1 2 nil edge-case-obj (+ 1 2 3) true false "multi

line string with `backticks`" y))
Here's the results I get with a large number of iterations:
[], (f1 1 :foo), 100000000 runs, 7998 msecs
[], (f2 1 :foo), 100000000 runs, 32035 msecs
Still a 4x speedup. I'll remove the inlining of constant values to see what happens

[], (f1 1 :foo), 100000000 runs, 11057 msecs
[], (f2 1 :foo), 100000000 runs, 32097 msecs

this is just by avoiding [].join

maybe not as spectacular as I thought initially

but still significant

I'm thinking we can just always call .toString explicitly and then + - i.e. stop relying on coercion, it's not something we need to do.

we need to do this for null / undefined

so the 1-arity calls to str are still fine

so 1-arity str calls on each arg (except maybe for constants) + + is a good improvement imo

I think I could just replace that with str in my macro, it should not make a a difference, but I still observe a difference when advanced compiling it, maybe due to inlining or so. when using str as the 1-arity helper function

[], (f1 1 :foo), 100000000 runs, 18159 msecs

(> 2x slower). it does call the 1-arity version directly. This is with pseudonames:

f1 function $f1_21852$$($x$jscomp$656$$, $y$jscomp$275$$) {
    return "" + $cljs$core$str$$.$cljs$core$IFn$_invoke$arity$1$($x$jscomp$656$$) + 1 + 2 + $cljs$core$str$$.$cljs$core$IFn$_invoke$arity$1$($borkdude$cljs_str_test$edge_case_obj$$) + $cljs$core$str$$.$cljs$core$IFn$_invoke$arity$1$(6) + !0 + !1 + "multi\n\nline string with `backticks`" + $cljs$core$str$$.$cljs$core$IFn$_invoke$arity$1$($y$jscomp$275$$);
  }

so perhaps for speed it makes sense to have a public str1 function or so which closure can inline when needed

it did inline my own helper function

hmm, no, even without inlining my helper function is still faster. what's going on...

(defn ?? [x]
  (if (nil? x) "" (.toString x)))
vs
([x] (if (nil? x)
         ""
         (.join #js [x] ""))
Ah there we got it, it's the array join again!

I think we could just optimize that by using .toString there?

that's what I was saying above, .toString always

๐Ÿ‘ 1

ok, I'll make an issue + patch

One question @dnolen: does the ^js tag imply that the argument is non-nil?

I have this macro now for the patch. Note, the first few helpers were already available and used.

(core/defn- compatible? [inferred-tag allowed-tags]
  (if (set? inferred-tag)
    (clojure.set/subset? inferred-tag allowed-tags)
    (contains? allowed-tags inferred-tag)))

(defn inferred-tag [env form]
  (cljs.analyzer/infer-tag env
    (cljs.analyzer/no-warn (cljs.analyzer/analyze env form))))

(core/defn- string-expr [e]
  (vary-meta e assoc :tag 'string))

(core/defmacro str
  [& xs]
  (core/let [interpolate (core/fn [x]
                           (if-let [tag (inferred-tag &env x)]
                             (core/cond
                               (compatible? tag '#{clj-nil})
                               nil
                               (compatible? tag '#{string keyword boolean number})
                               ["+~{}" x]
                               :else
                               ["+~{}.toString()" x])
                             ;; in CLJS patch, we use str 1-arity
                             ["+~{}" (list `?? x)]))
             strs+args (keep interpolate xs)
             strs (string/join (map first strs+args))]
    (string-expr (list* 'js* (core/str "\"\"" strs) (map second strs+args)))))
I'm doubting the
:else
["+~{}.toString()" x]
part. Here the CLJS analyzer inferred one (or more) types and I was assuming we could skip the 1-arity call to str here, but the value may still be nil maybe with a ^js tag?

yes, ^js doesn't say anything about nullability

can I use the inferred tag to say at least something about nullability, or not reliably?

cannot, everything in regular JS is nullable.

dude, I hope I didn't wake you up

haha, no I am often a early riser ๐Ÿ™‚

so I guess we could still check at compile time if the value is non-null and use that, else we go via str 1 arity

now I am beginning to doubt the original macro too. if the inferred tag is a string, but it could still be null, the macro wouldn't work for null strings

oh yes, it would because of the array.join

that one is about optimizing str as a compiler pass

Will do. I will for now do the simple macro here:

(defn compile-time-constant? [x]
  (or (string? x)
      (keyword? x)
      (boolean? x)
      (number? x)))

(core/defmacro str
  [& xs]
  (core/let [interpolate (core/fn [x]
                           (core/cond
                             (typed-expr? &env x '#{clj-nil})
                             nil
                             (compile-time-constant? x)
                             ["+~{}" x]
                             :else
                             ;; Note: can't assume non-nil despite tag here, so we go though str 1-arity
                             ["+~{}" (list `?? x)]))
             strs+args (keep interpolate xs)
             strs (string/join (map first strs+args))]
    (string-expr (list* 'js* (core/str "\"\"" strs) (map second strs+args)))))

Are there any tests for str, e.g. the valueOf edge case?

I'll just add one just in case

@dnolen one more question. I have this test but I don't know if testing compiled output is normally done like this in the test suite?

(deftest test-cljs-3452
  (let [obj #js {:valueOf (fn [] "dude")
                 :toString (fn [] "correct")}
        str-fn (fn [x y]
                 (str x obj y "\"foobar\"" 1 :foo nil))]
    (testing "object is stringified using toString"
      (is (= "correct6\"foobar\"1:foo" (str-fn nil (+ 1 2 3)))))
    (testing "only six string concats, compile time nil is ignored"
      (is (= 6 (re-seq #"[\+]" (str str-fn)))))
    (testing "only three 1-arity str calls, compile time constants are optimized"
      (is (= 3 (re-seq #"1\(.*?\)" (str str-fn)))))))

Also breaks in advanced tests. If you have some advice how to do this better, let me know

hmm interesting test breakage.

(js/console.log (str (fn [] (str 138))))
(js/console.log (str 138))
(js/console.log (. (str 138) -length))
(js/console.log (aget (str 138) "length"))
function(){return"138"}
138
undefined
undefined

oh I see:

(. (str 138) -length)
now compiles to:
""+(138).length
I guess we need to insert one more level of parens

OK, all tests are passing now, except for those 2 "check if it compiles to the expected format" tests since those break in advanced. https://github.com/borkdude/clojurescript/actions/runs/18493477632/job/52692254449

I'll wait for you to follow up on those and then I'll send a patch

@borkdude you need build time tests for those, that's not so hard to do

any close example you can refer me to?

I see, build-api-test probably?

is there a way I can run this single build test from the commandline?

figured it out in a command line REPL

@dnolen Alright, patch done. I feel pretty good about this one! :) https://clojure.atlassian.net/browse/CLJS-3452

@dnolen I had this idea to improve equiv performance for vectors by skipping iterator interface and iterating directly through JS arrays that bake persistent vector. Seeing up to ~30% improvement for small and large vectors

(def v1 (vec (range 1e3)))
(def v2 (vec (range 1e3)))

(simple-benchmark [] (= v1 v2) 1e3)

(def v3 (vec (range 1e6)))
(def v4 (vec (range 1e6)))

(simple-benchmark [] (= v3 v4) 1e3)

(def v5 (vec (range 1e2)))
(def v6 (vec (range 1e2)))

(simple-benchmark [] (= v5 v6) 1e7)

== master
Node/V8
[], (= v1 v2), 1000 runs, 10 msecs
[], (= v3 v4), 1000 runs, 5876 msecs
[], (= v5 v6), 10000000 runs, 6207 msecs

Bun/JSC
[], (= v1 v2), 1000 runs, 10 msecs
[], (= v3 v4), 1000 runs, 2470 msecs
[], (= v5 v6), 10000000 runs, 3115 msecs

== after the patch
Node/V8
[], (= v1 v2), 1000 runs, 7 msecs
[], (= v3 v4), 1000 runs, 4561 msecs
[], (= v5 v6), 10000000 runs, 4617 msecs

Bun/JSC
[], (= v1 v2), 1000 runs, 5 msecs
[], (= v3 v4), 1000 runs, 2025 msecs
[], (= v5 v6), 10000000 runs, 2677 msecs

seems like a nice idea, can you attach this patch to a JIRA ticket?