Implementing Reagent Test Case for JS Framework Benchmark
As part of preparation for Reagent 0.8 release, and inspired by this comparison, one thing I did was to implement Reagent test case for Stefan Krause's immensely popular JS web framework benchmark project.
This post documents the work that was required to add Reagent to the project, hopefully helping others to add other ClojureScript libraries there. I'll go through some of the choices I made writing the test code, and the open questions I have about Reagent performance.
Building ClojureScriptLink to Building ClojureScript
As this was the first ClojureScript library added to the project, some work was required to get Lein working with the Node -based build pipeline.
I decided to just use Leiningen and Cljsbuild, as this is what Reagent also
uses. Because the benchmark is JavaScript -based, the scripts use npm
to
download deps and run the build. To make downloading Lein as simple as possible,
I created lein-bin, taking example from sbt-bin, which provides few lines of
JS wrapper code which will download correct Lein script depending on the operating system.
Once the app is built, Webdriver-ts
is used to run and benchmark the code.
Reagent Test ApplicationLink to Reagent Test Application
The test application needs just needs to render a table with rows with ID and random label, and provide a few buttons to update the data on the table. I based the test application on the React version. Similar to that, I wrote one file which provides functions to manage the data:
(ns demo.utils)
(def adjectives ["pretty", "large", ...])
(def colours ["red", "yellow", ...])
(def nouns ["table", "chair", ...])
(defrecord Data [id label])
(defn build-data [id-atom count]
(repeatedly count (fn []
(->Data (swap! id-atom inc)
(str (rand-nth adjectives) " "
(rand-nth colours) " "
(rand-nth nouns))))))
The first function generates provided number of new rows. The ID for each row is retrieved from an atom, that keeps track of the last used number. Data is stored as records, which should be a bit faster than hash maps, to compare and to retrieve properties.
(defn add [data id-atom]
(into data (build-data id-atom 1000)))
The second function is for adding 1000 new rows to existing list. The important
bit here is that build-data
returns a lazy seq, instead of vector, so
it isn't realized unnecessarily before into
.
(defn update-some [data]
(reduce
(fn [data index]
(let [row (get data index)
s (str (:label row) " !!!")]
(assoc data index (assoc row :label s))))
data
(range 0 (count data) 10)))
One test case is updating every 10th element in the list. The data is stored
in a vector, and the vector elements can be updated using assoc
. To only
modify the necessary elements, reduce
can be used with range
. Instead of
using update
or update-in
, using assoc
directly is faster.
(defn swap-rows [data]
(if (> (count data) 998)
(-> data
(assoc 1 (get data 998))
(assoc 998 (get data 1)))
data))
Another test case is swapping two rows in the list. This is easy to do with Cljs, with persistent data structures we don't even need the temporary value used in JS.
(defn delete-row [data id]
(vec (remove #(identical? id (:id %)) data)))
To remove a single item from the list, I tested several approaches, but it looks
like just simple remove
and casting back to vector is the fastest, even
though it has to always go through the whole list. Using identical?
should be a tiny bit faster than =
, which calls some additional checks before
calling identical?
.
On the component side, I implemented two components, main
which holds the state on two atoms,
and row
which is used to render each table row:
(defn row [data selected? on-click on-delete]
[:tr
{:class (if selected? "danger")}
[:td.col-md-1 (:id data)]
[:td.col-md-4
[:a {:on-click (fn [e] (on-click (:id data)))}
(:label data)]]
[:td.col-md-1
[:a {:on-click (fn [e] (on-delete (:id data)))}
[:span.glyphicon.glyphicon-remove
{:aria-hidden "true"}]]]
[:td.col-md-6]])
Compared to React implementation, I chose to use multiple function parameters, instead of single properties map containing all the values. This should save some time on map construction and destructuring, but I don't have any proper stats about this. The precision of this benchmark wasn't quite enough to see differences between such changes, so this would need to be investigated further using Benchmark.js and/or Chrome performance profiler.
Another thing I'd like to know, is how expensive it is to create click event handler functions each time this function is called.
The main
component is quite long, as it renders all the buttons for test
cases, and implements click handlers for those, but the important part is
the local state:
data (r/atom [])
selected (r/atom nil)
And the loop to render the table rows:
(let [s @selected]
(for [d @data]
^{:key (:id d)}
[row
d
(identical? (:id d) s)
select
delete]))
Similar to remove row function, this also uses identical?
to check if the
row is active.
ResultsLink to Results
The results are now online. I have included results for Reagent along with comparison to vanilla JS and React here:
Duration in milliseconds ± standard deviation (Slowdown = Duration / Fastest)
Name | vanillajs-keyed | react-v16.1.0-keyed | reagent-v0.8-keyed |
---|---|---|---|
create rows Duration for creating 1000 rows after the page loaded. | 137.89.9 (1.0) | 201.212.1 (1.5) | 248.89.7 (1.8) |
replace all rows Duration for updating all 1000 rows of the table (with 5 warmup iterations). | 155.75.4 (1.0) | 169.04.3 (1.1) | 190.911.5 (1.2) |
partial update Time to update the text of every 10th row (with 5 warmup iterations) for a table with 10k rows. | 76.54.8 (1.0) | 90.93.3 (1.2) | 131.020.4 (1.7) |
select row Duration to highlight a row in response to a click on the row. (with 5 warmup iterations). | 10.83.5 (1.0) | 12.44.1 (1.0) | 13.15.2 (1.0) |
swap rows Time to swap 2 rows on a 1K table. (with 5 warmup iterations). | 18.34.6 (1.0) | 121.84.2 (6.7) | 128.68.2 (7.0) |
remove row Duration to remove a row. (with 5 warmup iterations). | 43.11.6 (1.0) | 51.52.0 (1.2) | 59.04.6 (1.4) |
create many rows Duration to create 10,000 rows | 1,374.533.3 (1.0) | 2,033.732.0 (1.5) | 2,179.367.2 (1.6) |
append rows to large table Duration for adding 1000 rows on a table of 10,000 rows. | 217.47.3 (1.0) | 271.89.9 (1.3) | 345.826.3 (1.6) |
clear rows Duration to clear the table filled with 10.000 rows. | 177.110.2 (1.0) | 224.46.0 (1.3) | 221.511.5 (1.3) |
slowdown geometric mean | 1.00 | 1.49 | 1.69 |
The result for each performance test is quite similar to React, with small overhead, which is excepted. Comparison to React with Immutable.js would be interesting, to see how much of the overhead is due to ClojureScript data structures, and which is due to JS emitted by ClojureScript compiler.
One can also note that the standard deviation in some test cases is quite large, even much larger than with React, especially with partial update case. One guess is that persistent data structures and garbage collection might affect this.
Startup metrics
Name | vanillajs-keyed | react-v16.1.0-keyed | reagent-v0.8-keyed |
---|---|---|---|
consistently interactive a pessimistic TTI - when the CPU and network are both definitely very idle. (no more CPU tasks over 50ms) | 38.82.9 (1.0) | 58.41.1 (1.5) | 115.22.8 (3.0) |
script bootup time the total ms required to parse/compile/evaluate all the page's scripts | 4.00.3 (1.0) | 22.10.6 (1.4) | 36.92.0 (2.3) |
main thread work cost total amount of time spent doing work on the main thread. includes style/layout/etc. | 162.12.6 (1.0) | 176.92.3 (1.1) | 176.93.6 (1.1) |
total byte weight network transfer cost (post-compression) of all the resources loaded into the page. | 163,967.00.0 (1.0) | 263,076.00.0 (1.6) | 409,515.00.0 (2.5) |
On startup speed and output size, there is a bit bigger difference to React. One thing that should improve this a bit in the future, is when React can be used as Node module, which will allow Closure to optimize it.
Memory allocation in MBs ± standard deviation
Name | vanillajs-keyed | react-v16.1.0-keyed | reagent-v0.8-keyed |
---|---|---|---|
ready memory Memory usage after page load. | 3.00.1 (1.0) | 3.70.1 (1.2) | 4.10.1 (1.4) |
run memory Memory usage after adding 1000 rows. | 3.70.1 (1.0) | 7.60.0 (2.1) | 8.50.0 (2.3) |
update eatch 10th row for 1k rows (5 cycles) Memory usage after clicking update every 10th row 5 times | 3.70.1 (1.0) | 8.50.0 (2.3) | 9.90.0 (2.7) |
replace 1k rows (5 cycles) Memory usage after clicking create 1000 rows 5 times | 3.60.1 (1.0) | 9.00.0 (2.5) | 10.40.0 (2.9) |
creating/clearing 1k rows (5 cycles) Memory usage after creating and clearing 1000 rows 5 times | 3.20.0 (1.0) | 4.70.0 (1.5) | 5.50.0 (1.7) |
Memory allocation results look... normal?
SummaryLink to Summary
I think the results are quite good. This simple benchmark mostly tests the rendering speed, where ClojureScript overhead will be obvious. Real world applications should benefit from persistent data structures, which should negate the overhead.
I haven't really given much thought to Reagent performance with some of the recent Reagent changes. Based on what I learned with this benchmark, I have some ideas on how to test Reagent performance in the future.
In addition to improving Reagent performance by optimizing the implementation, it should be also possible to improve the render performance by using alternative Hiccup compiler, like Hicada, which compiles the Hiccup forms into React calls during macro compilation, instead of interpreting them on runtime.
Reagent 0.8 will be released soon when everything is ready. I'm waiting for ClojureScript release with a fix
which will prevent regression when using foreign-libs on Node target.