cljs-dev

borkdude 2025-12-04T17:27:12.453649Z

@dnolen Would it be incorrect to use Math.max(...xs) for (max 1 2 3 4)? I ran into someone doing this for Advent of Code: (max [1 2] [1 3]) (not literally, but implicitly) and it happened to work in CLJS but not in squint, since squint uses Math.max for max.... I wonder if that's somehow incorrect dialect-wise. I know (max [1 2] [1 3]) shouldn't be supported but still wondering about it. JS seems to convert the non-number via .valueOf or .toString and then compares

borkdude 2025-12-04T17:31:40.554089Z

cljs.user=> (max #js {:valueOf (fn [] (prn :value-of))} #js {:toString (fn [] (prn :to-string))} #js {:toString (fn [] (prn :to-string)) :valueOf (fn [] (prn :value-of))}
       #_=>      )
:value-of
:to-string
:to-string
:value-of
#js {:toString #object[toString], :valueOf #object[valueOf]}

👀 1
dnolen 2025-12-04T19:02:45.644709Z

max is only for numbers

👍 1
borkdude 2025-12-05T16:05:48.642559Z

Any reason (max 1 2 3) couldn't expand into Math.max(1,2,3) instead of:

cljs.user=> (str (fn [] (max 1 2 3)))
"function (){\nvar x__10606__auto__ = (function (){var x__10606__auto__ = (1);\nvar y__10607__auto__ = (2);\nreturn ((x__10606__auto__ > y__10607__auto__) ? x__10606__auto__ : y__10607__auto__);\n})();\nvar y__10607__auto__ = (3);\nreturn ((x__10606__auto__ > y__10607__auto__) ? x__10606__auto__ : y__10607__auto__);\n}"
It seem like less bloat.

dnolen 2025-12-05T16:24:43.026499Z

Does it handle NaN like Clojure?

borkdude 2025-12-05T16:25:29.196429Z

cljs.user=> (js/Math.max 1 js/NaN)
##NaN

2025-12-05T16:26:51.764989Z

note that cljs has odd behavior related to NaN and non-numbers, such as nil and (max "x" "y") (https://github.com/jank-lang/clojure-test-suite/blob/27bdda5969fe625d1498ee95f5ba28b64374694e/test/clojure/core_test/max.cljc)

borkdude 2025-12-05T16:27:18.939139Z

that is testing the implementation, not the contract per docstring: max is only for numbers as @dnolen said above

borkdude 2025-12-05T16:27:32.340999Z

a typical case of overtesting

2025-12-05T16:28:12.156119Z

given clojure and clojurescript's backwards compatibility goals, i think it's worth mentioning, even if i'm fine with changing the implementation

borkdude 2025-12-05T16:28:52.534319Z

Note that I started this thread as questioning that, but the docstring is clear about it. Preserving backwards compatibility about something that is against the docstring is not something that Clojure/Script should have to limit itself to

👍 1
2025-12-05T16:29:29.228689Z

i agree

borkdude 2025-12-05T16:29:57.520889Z

(@dnolen if you would like an issue + patch for Math.max, let me know)

borkdude 2025-12-05T18:36:34.720249Z

@dnolen quite a perf boost too:

cljs.user=> (simple-benchmark [] (max 1 2 3) 100000000)
[], (max 1 2 3), 100000000 runs, 4696 msecs
nil
cljs.user=> (simple-benchmark [] (max 1 2 3) 100000000)
[], (max 1 2 3), 100000000 runs, 4606 msecs
nil
cljs.user=> (simple-benchmark [] (js/Math.max 1 2 3) 100000000)
[], (js/Math.max 1 2 3), 100000000 runs, 43 msecs
nil
cljs.user=> (simple-benchmark [] (js/Math.max 1 2 3) 100000000)
[], (js/Math.max 1 2 3), 100000000 runs, 45 msecs
[f (fn* [p1__656# p2__657# p3__658#] (max p1__656# p2__657# p3__658#))], (f 1 2 3), 100000000 runs, 5114 msecs
nil
cljs.user=> (simple-benchmark [f #(js/Math.max %1 %2 %3)] (f 1 2 3) 100000000)
[f (fn* [p1__661# p2__662# p3__663#] (js/Math.max p1__661# p2__662# p3__663#))], (f 1 2 3), 100000000 runs, 408 msecs
nil

dnolen 2025-12-05T18:47:33.685489Z

Probably can't benchmark this directly unfortunately, it will become a no op

borkdude 2025-12-05T18:56:33.841759Z

ah I see, the recent CLJS has removed the macroexpansion logic to just call the runtime function:

cljs.user=> (str (fn [] (max 1 ##NaN)))
"function (){\nreturn cljs.core.max.call(null,(1),NaN);\n}"

borkdude 2025-12-05T18:57:02.774119Z

I think this came up in the dialects meeting :)

dnolen 2025-12-05T18:57:51.715869Z

it will be extremely hard to measure a call to Math.max, VMs can see that nothing is happening

dnolen 2025-12-05T18:58:08.289379Z

easier to see if you swap it in the persistent data structure code

borkdude 2025-12-05T18:59:15.777929Z

I was assuming the macroexpansion logic was still in there, hadn't seen this change yet (out of habit I use plk a lot)

dnolen 2025-12-05T18:59:21.644049Z

if it does handle all cases and it is fast(er) there than unchecked-max|min - then that would be a obvious win

borkdude 2025-12-05T19:00:28.987909Z

unhecked-max/min is basically the old version of max, right

dnolen 2025-12-05T19:00:33.561889Z

yes

dnolen 2025-12-05T19:01:29.917519Z

there are other repercussion here though, returns Infinity if no params

borkdude 2025-12-05T19:01:58.563009Z

do you see others than that?

dnolen 2025-12-05T19:02:01.085079Z

also it coerces to NaN

dnolen 2025-12-05T19:02:10.177139Z

so different set of footguns than before

dnolen 2025-12-05T19:02:43.449009Z

this needs more careful thought than may appear

borkdude 2025-12-05T19:02:50.304969Z

agreed

borkdude 2025-12-05T19:05:22.017219Z

this looks like a legit benchmark right?

cljs.user=> (let [obj #js {:res 0}] (simple-benchmark [] (set! (.-res obj) (+ (.-res obj) (js/Math.max 1 2 3))) 100000000) obj)
[], (set! (.-res obj) (+ (.-res obj) (js/Math.max 1 2 3))), 100000000 runs, 99 msecs
#js {:res 300000000}

borkdude 2025-12-05T19:05:46.428429Z

cljs.user=> (let [obj #js {:res 0}] (simple-benchmark [] (set! (.-res obj) (+ (.-res obj) (max 1 2 3))) 100000000) obj)
[], (set! (.-res obj) (+ (.-res obj) (max 1 2 3))), 100000000 runs, 5030 msecs
#js {:res 300000000}

borkdude 2025-12-05T19:07:50.333309Z

cljs.user=> (max)
WARNING: Wrong number of args (0) passed to cljs.core/max at line 1 
nil
This isn't valid/undefined so we can ignore that case probably. We could make a check, but it's undefined I'd say. In Clojure it crashes > The largest of the given numbers. Returns NaN if any of the parameters is or is converted into NaN. How is this incompatible with cljs.core/max?

dnolen 2025-12-05T19:11:12.458559Z

I don't know what happens when you pass it non-numbers

dnolen 2025-12-05T19:11:47.464639Z

Basically it would be good to know exactly what happens now and what will happen later.

dnolen 2025-12-05T19:11:54.311319Z

defeating VM optimization is really hard

dnolen 2025-12-05T19:12:11.000999Z

nothing but testing something completely non-trivial is going to tell us anything.

borkdude 2025-12-05T19:12:12.020989Z

cljs.user=> (js/Math.max "a" "b")
##NaN
cljs.user=> (max "a" "b")
"a"
But max is not defined for non-numbers according to docstring

dnolen 2025-12-05T19:13:01.823459Z

it doesn't matter, it all needs to be written down - changing stuff is super annoying even for undefined stuff. I'm not going to look at ticket w/o knowing exactly all the repercussions - especially if it's not particular high value.

borkdude 2025-12-05T19:13:40.428759Z

sure

dnolen 2025-12-05T19:14:37.763089Z

this way if we do - it we can enumerate all the changed expectations in changes.md, even if it was undefined.

dnolen 2025-12-05T19:16:26.380759Z

that said, I am liking the general direction of the perf numbers - seems like this is a optimization path

dnolen 2025-12-05T19:17:04.384719Z

probably similar but what happens to perf if you pass a string. I suspect no change - but what would be a good thing to see

dnolen 2025-12-05T19:17:24.595609Z

in the old days weird stuff could trigger deoptimization in suprising ways

borkdude 2025-12-05T19:18:53.749499Z

> probably similar but what happens to perf if you pass a string didn't catch this one. string is undefined for max, so what does perf matter?

dnolen 2025-12-05T19:24:08.049209Z

just curious to see what happens, Math.max is not historically that good - one thing is that Math.max is about Double, but in many situations users may want to operate on 32bit integer.

borkdude 2025-12-05T19:25:05.298919Z

they give different results so I think perf isn't relevant probably in that case? you get a different result slower or faster

dnolen 2025-12-05T19:28:31.034069Z

it's not quite so simple as that - for example if bad code triggers deopt this could in theory be avoided w/ checks - at least before deopt could be contagious

dnolen 2025-12-05T19:30:14.388439Z

I'm asking a bunch of questions because we never used Math.max therefore we don't have much information about what will happen. For example I know that the old (max (int a) (int b)) would generate optimal code, some comparisons on 32bit ints that's it

dnolen 2025-12-05T19:30:36.822109Z

Math.max is a black box, so all these questions

borkdude 2025-12-05T19:31:35.910499Z

my question was largely based on the assumption that the old macro stuff was still in place and it seemed to generate more boilerplate than necessary in comparison to Math.max. So I basically asked a bunch of questions to learn more. Why was Math.max never targeted, it existed since JS 1.0? Maybe there was a reason?

dnolen 2025-12-05T19:32:15.347079Z

It was slow before

dnolen 2025-12-05T19:32:31.006429Z

not a little bit slow, like really slow

borkdude 2025-12-05T19:32:35.387659Z

gotcha

dnolen 2025-12-05T19:33:01.495879Z

thus all my questions, it could be everything is resolved and that would be cool

borkdude 2025-12-05T19:34:02.326329Z

it would break programs relying on the undefined behavior. although Rich stressed that "undefined" is really important to communicate and the docstring says nums, I don't know if I want to put in the effort of writing an issue+patch if breaking programs that rely on undefined behavior is a concern

dnolen 2025-12-05T19:35:46.338719Z

that's a reasonable too, changing low level stuff is really a pain now.

dnolen 2025-12-05T19:37:00.918259Z

Again that why I was suggesting less trivial test w/ the persistent data structures - is Math.max faster than unchecked-max in a real program

dnolen 2025-12-05T19:37:21.294549Z

if there's some gold to mine - then we're not just looking at costs

borkdude 2025-12-05T19:38:04.245729Z

I didn't get what you mean with "less trivial test w/ the persistent data structures" - can you explain?

dnolen 2025-12-05T19:38:37.838059Z

every single op actually matters for PersistentVector for example.

dnolen 2025-12-05T19:39:17.723959Z

everytime we removed a check, or collapsed a complicated test into logical operators we saw some gain

dnolen 2025-12-05T19:39:46.377449Z

(int n) etc matter since that coerces to 32bit so the JS VM knows what to do

borkdude 2025-12-05T19:40:31.950399Z

what's the relation between max and PersistentVector?

dnolen 2025-12-05T19:40:43.651469Z

because we use it internally

dnolen 2025-12-05T19:40:55.722809Z

that's why we split into unchecked-max

dnolen 2025-12-05T19:41:27.774189Z

But if Math.max does everything including the check plus being as fast or faster - then that's a win

dnolen 2025-12-05T19:41:45.760189Z

as demonstrated in something the VM cannot handwave away

borkdude 2025-12-05T19:42:10.668989Z

got it. so assoc on vector with index uses unchecked-max, so if this becomes lots faster, this will be relevant

dnolen 2025-12-05T19:42:17.220059Z

yeap

dnolen 2025-12-05T19:42:54.592039Z

and then we have some real value to offer to offset any undefined changes

borkdude 2025-12-05T19:44:25.956669Z

how do I know I'm not looking at some no-oppy thing here?

cljs.user=> (simple-benchmark [a [1]] (assoc a 1 2) 100000000)
[a [1]], (assoc a 1 2), 100000000 runs, 3095 msecs

dnolen 2025-12-05T19:44:58.007179Z

the VM won't no-op persistent data structure code it's just too complicated

borkdude 2025-12-05T19:45:06.660829Z

with Math.max:

cljs.user=> (simple-benchmark [a [1]] (assoc a 1 2) 100000000)
[a [1]], (assoc a 1 2), 100000000 runs, 2356 msecs

dnolen 2025-12-05T19:45:12.298119Z

whoa

dnolen 2025-12-05T19:45:18.153689Z

well there you go 🙂

dnolen 2025-12-05T19:45:56.353759Z

nice! patch welcome, it would be nice to enumerate the undefined behaior in the ticket

borkdude 2025-12-05T19:46:04.908769Z

(oops, the index should have been 0, let me double check that)

borkdude 2025-12-05T19:47:14.813589Z

yeah sorry that changes things. Math.max:

cljs.user=> (simple-benchmark [a [1]] (assoc a 0 2) 100000000)
[a [1]], (assoc a 0 2), 100000000 runs, 2454 msecs
old:
cljs.user=> (simple-benchmark [a [1]] (assoc a 0 2) 100000000)
[a [1]], (assoc a 0 2), 100000000 runs, 2193 msecs

dnolen 2025-12-05T19:47:21.633089Z

the other thing in the ticket would be to remove unchecked-min/max and just use this uniformly

dnolen 2025-12-05T19:47:29.838319Z

10% is still great

borkdude 2025-12-05T19:47:33.878479Z

it's slower

dnolen 2025-12-05T19:47:45.153869Z

oh hah 🙂

dnolen 2025-12-05T19:47:56.422669Z

right that's what I was worried about

dnolen 2025-12-05T19:48:07.355049Z

you have to be careful w/ trivial benchmarks

borkdude 2025-12-05T19:48:11.942499Z

now I get equal numbers roughly, so probably max didn't matter here much

borkdude 2025-12-05T19:48:28.260169Z

I messed up the index, should've been 0

borkdude 2025-12-05T19:48:35.049919Z

for assoc-in on [1]

dnolen 2025-12-05T19:48:44.148909Z

oh

dnolen 2025-12-05T19:48:51.627149Z

but in this case we want to go lower level

dnolen 2025-12-05T19:49:08.616329Z

(-assoc ^cljs.core/IAssoc a ...)

borkdude 2025-12-05T19:49:14.436769Z

👍

dnolen 2025-12-05T19:49:29.308679Z

or whatever the protocol is for -assoc

borkdude 2025-12-05T19:51:15.438259Z

Math.max:

cljs.user=> (simple-benchmark [a [1]] (-assoc ^IAssoc a 0 2) 1000000000)
[a [1]], (-assoc a 0 2), 1000000000 runs, 10468 msecs
unchecked-max:
cljs.user=> (simple-benchmark [a [1]] (-assoc ^IAssoc a 0 2) 1000000000)
[a [1]], (-assoc a 0 2), 1000000000 runs, 14062 msecs

borkdude 2025-12-05T19:51:20.896329Z

so we do see quite a gain here

dnolen 2025-12-05T19:51:41.424399Z

that's pretty good

dnolen 2025-12-05T19:52:16.634949Z

so ticket great, it would be great to eliminate unchecked-min/max and use this instead uniformly

dnolen 2025-12-05T19:53:09.166059Z

different VMs behave differently so it would be good to collect those other numbers in the ticket, I'm happy to add those to ticket later if you have a patch

borkdude 2025-12-05T19:53:28.754239Z

is it possible with -re node to change the underlying VM, e.g. bun?

dnolen 2025-12-05T19:53:57.578949Z

no people have asked for that, might even be a patch but would need to apply that

dnolen 2025-12-05T19:54:05.894229Z

bun seems popular enough now so not against it.

borkdude 2025-12-05T19:54:32.508249Z

then we can test v8 (node) and JSC (bun). others that are important these days?

dnolen 2025-12-05T19:54:50.140139Z

Firefox, those are the 3

dnolen 2025-12-05T19:55:07.199319Z

I suspect it'll be good, in the past we would avoid anything that slowed another engine down

borkdude 2025-12-05T19:55:16.895609Z

I could also spit out an advanced compiled program and run it in a browser console

dnolen 2025-12-05T19:55:21.199749Z

yeap

borkdude 2025-12-05T19:56:00.961979Z

ok will write up the ticket+patch soon

borkdude 2025-12-05T19:57:00.836939Z

hopefully tomorrow

🎸 1
dnolen 2025-12-05T20:01:58.465189Z

It would be interesting to see how we compare to Clojure JVM after this change for fun - at one point I recall it being w/in 1.5X of JVM Clojure for PVec for some ops

borkdude 2025-12-05T20:04:21.532969Z

user=> (time (let [a [1]] (dotimes [i 1000000000] (assoc a 0 2))))
"Elapsed time: 4027.172625 msecs"
(clojure 1.10, JVM 25)

borkdude 2025-12-05T20:04:46.308829Z

(similar in 1.12.3)

dnolen 2025-12-05T21:25:27.077999Z

to be clear I meant JSC

borkdude 2025-12-06T10:55:15.286629Z

@dnolen I did benchmarks in chrome, firefox, node, bun but unfortunately I don't see any meaningful difference

borkdude 2025-12-06T10:56:01.205599Z

My method was:

(ns entry)
(simple-benchmark [a [1]] (-assoc ^IAssoc a 0 2) 1000000000)
clj -M -m cljs.main --compile-opts '{:target :nodejs :optimizations :advanced}' -o out/main.js -c entry
and remove the shebang in the output, + replace global with globalThis so I could run it in a browser as well

borkdude 2025-12-06T10:56:46.343469Z

having said this, max may still be slower than unchecked-max of course

borkdude 2025-12-06T10:57:50.242659Z

and I noticed that unchecked-max with more than two args falls back on max

cljs.user=> (str (fn [] (unchecked-max 1 2 3)))
"function (){\nreturn Math.max((1),(2),(3));\n}"

dnolen 2025-12-06T13:37:27.037699Z

There is a weird thing sometimes about advanced - try :simple with :static-fns true

dnolen 2025-12-06T13:37:57.871279Z

oh and :optimize-constants true

dnolen 2025-12-06T13:39:30.746729Z

it might not make a difference, but sometimes advanced compilation can slow things down a bit

borkdude 2025-12-06T13:41:37.831009Z

that's weird. with your suggested options:

$ node out/main.js
[a [1]], (-assoc a 0 2), 1000000000 runs, 14911 msecs
a lot slower. but when I remove all options:
$ clj -M -m cljs.main --compile-opts '{:target :nodejs #_#_:optimizations :simple #_#_:static-fns true #_#_:optimize-constants true}' -o out/main.js -c entry
$ node out/main.js
[a [1]], (-assoc a 0 2), 1000000000 runs, 2910 msecs

borkdude 2025-12-06T13:42:50.853429Z

with only advanced:

node out/main.js
[a [1]], (-assoc a 0 2), 1000000000 runs, 3296 msecs

dnolen 2025-12-06T13:44:33.532469Z

hrm that seems really weird, feel free to make a patch + issue anyway. It would be good to capture the work so far. Also, I can try to reproduce these results on my machine

borkdude 2025-12-06T13:44:56.289479Z

ok

borkdude 2025-12-06T13:45:59.605979Z

do you want to preserve "unchecked-min/max" after patch or can they go since they were only introduced last release?

borkdude 2025-12-06T13:51:19.386349Z

I mean, rename back to min/max, the macros

borkdude 2025-12-06T13:51:32.884269Z

doesn't matter, I'll preserve them

borkdude 2025-12-06T13:52:02.171499Z

can rename them later if you want, small effort

borkdude 2025-12-06T14:12:32.017869Z

hmm, it's better to rename unchecked-max to max so we get the benefit of turning (max 1 2) into a direct Math.max call

borkdude 2025-12-06T14:12:42.292489Z

we can preserve the old name as well if you want

borkdude 2025-12-06T14:14:05.351769Z

https://clojure.atlassian.net/browse/CLJS-3467

borkdude 2025-12-06T14:14:09.155669Z

patch included

borkdude 2025-12-06T14:16:42.350759Z

at least all core tests work

dnolen 2025-12-06T16:41:09.630399Z

Don't need to preserve those names

dnolen 2025-12-06T16:41:13.342779Z

thanks!

borkdude 2025-12-06T16:42:47.912179Z

I didn't do benchmarks on new max vs old max. I suspect that will be faster. Now we only benchmarked old unchecked vs new

👍🏽 1