@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 ...
ah I found this commit: https://github.com/clojure/clojurescript/commit/6b251ba9e46c5fff647229d771f4c8f55989a6ef
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"yeah
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 msecsthis 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
which is basically what this thing here is doing: https://github.com/borkdude/cljs-str/blob/main/src/borkdude/cljs_str.clj
?? 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
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?
Hmm: https://github.com/clojure/clojurescript/commit/1ba3811c49c9dd5d8d948f60d18799b1d4eab785
that's what I was saying above, .toString always
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
you don't have to do this but maybe link to this issue - https://clojure.atlassian.net/jira/software/c/projects/CLJS/issues/?jql=project%20%3D%20%22CLJS%22%20ORDER%20BY%20created%20DESC&selectedIssue=CLJS-3437
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
undefinedoh I see:
(. (str 138) -length)
now compiles to:
""+(138).length
I guess we need to insert one more level of parensfixed
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?
yeah
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 msecsseems like a nice idea, can you attach this patch to a JIRA ticket?
Thanks!