So, if you want to beat JS performance in ClojureScript, you have to make use of what ClojureScript offers you – the REPL, the fast experimentation, and the wonderful experience of programming with data. So, this is a tale of a huge performance improvement that I’m currently working in Chlorine.
In Chlorine and Clover, when you evaluate something, the result will be rendered in a tree-like way at the console. This works quite well for Clojure, because UNREPL makes structures that are too big small, and you can query for more data. So far, so good.
In ClojureScript, Clojerl, Babashka, etc, things are not so good. Big data structures will be rendered fully on the view. They can lock or crash your editor, they can occupy all your memory, etc. The reason for that is that tree structures are hard – when you’re rendering the “parent”, you don’t know what the children will be. Currently, in Chlorine, rendering
(range 80000) locks my editor for 4 seconds until it calculates everything, layouts all data, etc… and I wanted to change this.
Reagent vs Helix vs React
I was always intrigued on how much performance hit I get when I use Reagent’s Hiccup data structure instead of React objects – after all, there must be some performance problems, right? After all, that’s the promise of Helix – to not pay this performance hit because you’re closer to what React wants.
batching.cljs hogging my performance, so the next move was easy – move away from Reagent and use Helix.
Except… that Helix uses some macros and I ended up doing the code in React. My testcase was simple: render a vector of 80000 elements and see how it would perform, obviously without all the bells and whistles that Chlorine offers today (otherwise this experiment would be waaay longer). And that’s where things get surprising: with Reagent, I was hitting 1800ms of scripting, and about 120ms of rendering. With React… 1650ms of scripting, and about 200ms of rendering. I decided to do more benchmarks and probably because of OS caching, warm-up, or whatever, the results got even closer, with Reagent sometimes performing better than React – but still too slow.
The conclusion should be obvious right? The problem is in React. But migrating away from React meant re-thinking the whole rendering structure, doing lots of interops, for something that I was not sure it would help. Sure, there’s Membrane, and while I do want to test it someday, to make a performance boost I would probably have to use Canvas and handle resizing and other issues by hand – not remotely close of what I wanted.
So, back to the beginning: I was probably doing the wrong thing. It’s like I’m a amateur boxing fighter trying to fight against the champion. Sure, I’ll loose all the time, but maybe I can change the game? And that’s exactly here that programming with pure data structures come handy: when you are programming in React, you have
React.Component instances. But in reagent, you have vectors. So, instead of
#ReactComponent [opaque-things-here], I have:
; For EDN like [1 2] [:div.cols "[" [:div.inline "1"] [:div.inline " "] [:div.inline "2"] "]"]
Again, the trouble is that I don’t know what will come in this child elements. So, something like this can also happen:
; For EDN like [1 [3 4] 2] [:div.cols "[" [:div.inline "1"] [:div.inline " "] [collection-element] [:div.inline " "] [:div.inline "2"] "]"]
The idea was to write a code to “join” elements that are the same, so instead of having 80k of the exact same element, I could have… one, if possible. So that’s what I did, and it did convert the first element to
[:div.cols "[" [:div.inline "1" " " "2"] "]"] for example. That meant something interesting: as we’re “flattening” elements, it does not makes sense anymore to render things as strings – I was originally doing this because it’s faster, but now rendering the right element is faster indeed. What this meant is that, with this change, the second element could become:
[:div.cols [:div.inline "[" "1" " "] [collection-element] [:div.inline " " "2"]]
So this essentially “combines” multiple elements into one. This means a HUGE performance gain: instead of thousands of elements being rendered, we can render essentially one or two in most cases. Sure, there are some situations when this is not possible: for example, when you have a big list of lists, and you open the elements of the list, you’ll have essentially LOTS of elements being rendered in the screen, and because of the “tree nature” of EDN, it’ll not be possible to combine them – but I’m not that worried with it because, by rendering the root in less time, I’m essentially cutting the rendering time by half.
So, let’s talk about numbers: the current implementation of rendering, in Chlorine, delays 4500ms to render a range with 80000 elements. In the new implementation of the renderer, I was doing some
r/cursor magic to create less “atoms” between elements. This made sense at the time, but the whole process became even slower. Another issue is that, to
r/cursor work, it needs every EDN to be associative (to implement
IAssociative protocol in ClojureScript) and there are whole elements that are NOT associative like
tagged-literal – translating to lots of wrapping and unwrapping, difficult code, and, seriously, LOTS of problems to get rendering to be customizable. Maybe if in the future I can find a way to make the process easier, I’ll go back to visit
r/cursor. In the end, the “new rendering code” was even slower than the old one: to render the same elements, it was consuming 7800ms… and that only got worse when I bumped up to 160000 elements (the editor simply locked – it refused to render).
The new code, combining similar elements, is rendering in 350ms. Yes – by combining elements, I am rendering the same data structure, on the same editor, with the same Reagent, in 7% of the fastest implementation I was able to write in the past.