cljs-dev

borkdude 2025-10-13T16:52:12.206209Z

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

borkdude 2025-10-13T16:52:50.641139Z

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

dnolen 2025-10-13T17:41:09.882169Z

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

borkdude 2025-10-13T18:17:10.342049Z

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

borkdude 2025-10-13T18:18:10.097979Z

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

borkdude 2025-10-13T18:20:20.882599Z

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)

dnolen 2025-10-13T18:23:17.721789Z

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

borkdude 2025-10-13T18:27:08.754119Z

ah I found this commit: https://github.com/clojure/clojurescript/commit/6b251ba9e46c5fff647229d771f4c8f55989a6ef

dnolen 2025-10-13T18:30:00.042479Z

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

dnolen 2025-10-13T18:30:28.046969Z

prettty sure this was reverted because changing to + broke stuff

borkdude 2025-10-13T18:32:31.296859Z

ah darn, you're right:

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

dnolen 2025-10-13T18:32:48.480649Z

yeah

borkdude 2025-10-13T18:33:41.784359Z

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

borkdude 2025-10-13T18:34:27.409629Z

when passed only strings, I mean

dnolen 2025-10-13T18:39:45.187309Z

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

borkdude 2025-10-13T18:42:51.606959Z

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

borkdude 2025-10-13T18:44:28.061739Z

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

borkdude 2025-10-13T18:44:34.532349Z

this is just by avoiding [].join

borkdude 2025-10-13T18:45:24.076949Z

maybe not as spectacular as I thought initially

borkdude 2025-10-13T18:45:29.236369Z

but still significant

dnolen 2025-10-13T19:12:18.544829Z

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.

borkdude 2025-10-13T19:12:57.279409Z

we need to do this for null / undefined

borkdude 2025-10-13T19:13:21.447969Z

so the 1-arity calls to str are still fine

borkdude 2025-10-13T19:14:32.713789Z

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

borkdude 2025-10-13T19:15:21.301029Z

which is basically what this thing here is doing: https://github.com/borkdude/cljs-str/blob/main/src/borkdude/cljs_str.clj

borkdude 2025-10-13T19:16:41.748229Z

?? is a helper function that basically does what the 1-arity function of str does: https://github.com/borkdude/cljs-str/blob/f8536f5dccdac633004054bd51ee493e5f14a327/src/borkdude/cljs_str.cljs#L11

borkdude 2025-10-13T19:22:36.631979Z

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

borkdude 2025-10-13T19:23:51.095859Z

(> 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$$);
  }

borkdude 2025-10-13T19:25:23.358819Z

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

borkdude 2025-10-13T19:25:33.256179Z

it did inline my own helper function

borkdude 2025-10-13T19:27:35.196769Z

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!

borkdude 2025-10-13T19:27:55.128689Z

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

dnolen 2025-10-13T23:17:21.995509Z

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

๐Ÿ‘ 1
borkdude 2025-10-14T08:41:44.175269Z

ok, I'll make an issue + patch

borkdude 2025-10-14T09:30:53.161969Z

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

borkdude 2025-10-14T09:37:07.102969Z

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?

dnolen 2025-10-14T09:37:37.416049Z

yes, ^js doesn't say anything about nullability

borkdude 2025-10-14T09:37:55.850929Z

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

dnolen 2025-10-14T09:38:26.565589Z

cannot, everything in regular JS is nullable.

borkdude 2025-10-14T09:38:37.049409Z

๐Ÿ‘

borkdude 2025-10-14T09:38:41.499679Z

dude, I hope I didn't wake you up

dnolen 2025-10-14T09:38:53.196759Z

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

borkdude 2025-10-14T09:39:35.661019Z

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

borkdude 2025-10-14T09:40:34.875709Z

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

borkdude 2025-10-14T09:42:35.053409Z

oh yes, it would because of the array.join

dnolen 2025-10-14T09:43:25.064359Z

that one is about optimizing str as a compiler pass

borkdude 2025-10-14T09:47:03.764299Z

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)))))

borkdude 2025-10-14T09:54:07.264459Z

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

borkdude 2025-10-14T09:55:05.356839Z

I'll just add one just in case

borkdude 2025-10-14T10:08:07.295439Z

@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)))))))

borkdude 2025-10-14T10:08:27.778319Z

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

borkdude 2025-10-14T10:21:08.011379Z

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

borkdude 2025-10-14T10:22:51.311519Z

oh I see:

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

borkdude 2025-10-14T10:25:34.930889Z

fixed

borkdude 2025-10-14T10:29:41.673179Z

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

borkdude 2025-10-14T10:30:03.434399Z

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

dnolen 2025-10-14T10:38:35.246919Z

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

borkdude 2025-10-14T10:42:49.837089Z

any close example you can refer me to?

borkdude 2025-10-14T10:44:51.105489Z

I see, build-api-test probably?

dnolen 2025-10-14T10:48:21.635669Z

yeah

borkdude 2025-10-14T10:59:33.642409Z

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

borkdude 2025-10-14T11:03:05.399669Z

figured it out in a command line REPL

borkdude 2025-10-14T11:10:15.207139Z

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

Roman Liutikov 2025-10-13T20:32:19.267339Z

@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

dnolen 2025-10-13T23:21:28.100339Z

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

Roman Liutikov 2025-10-14T09:09:50.430109Z

Done https://clojure.atlassian.net/browse/CLJS-3453

dnolen 2025-10-14T09:39:11.528409Z

Thanks!