merge and merge-with?
Observations: Clojure's merge is difficult to beat for hashmaps containing up to 100 key/value pairs. For larger hashmaps, custom
implementations using reduce-kv and transients offer 30-40% shorter evaluation times.
clojure.core/merge ultimately delegates to conj buried within a
first/next recursive implementation. Also, merge carries with it the variadic machinery to handle more than two hashmaps,
some of which could be nil. This is an elegant, general implementation, but perhaps not as efficient as could be if we're in a
situation where we know we'll always have exactly two non-nil hashmaps. One example of such a situation is building up an output
hashmap in a multi-threaded transducing process.
We can mix and match tactics involving transients, transducers, etc., to see if we can harvest any noticeable performance improvement. So let's run some benchmarks to see if any particular tactic performs objectively faster than the others. Benchmarks are defined here. We'll run benchmarks varying
conj, into, transient, etc.).
merge-with functions (basic addition versus an extended series of arithmetic operations).
As always with JVM benchmarks, don't stare too long at any one data point. Just get a sense for overall trends and the relative magnitudes. Single-digit percentage differences probably aren't significant.
Overall, we make three broad observations.
For merging smallish hashmaps, Clojure's core merge is difficult to beat. We see the best improvement using
transduce and conj, giving 7-14% shorter completion times.
For merging larger hashmaps, we obtain 30-50% shorter times with a straightforward implementation using reduce-kv and transients.
No custom implementation beats merge-with for small hashmaps.
A custom implementation of merge-with using reduce-kv offers 10-20% shorter times.
A two-layer merge using reduce-kv outperforms the community's deep-merge by 35-50%.
Within the limits of these benchmarks, it does appear that we can write a trio of hashmap merging utilities that complete their tasks in noticeably shorter times. The Smoosh library provides that trio as a dependency.
These benchmarks measure the performance of deep merge variants: functions that combine two hashmaps with descendant hashmaps associated to
their top-level keys. These particular tests involve merging only two layers deep, although most implementations would operate to an arbitrary depth.
Clojure does not provide a deep-merge function, but CLJ-1468 (gold
diamonds), a long-standing, un-accepted proposed implementation, serves as a handy baseline. We also tested several third-party variants.
A straightforward implementation using reduce-kv and transients, named merge-merge below, performed the best (i.e.,
shortest times), being nearly twice as fast as the baseline deep-merge, particularly for large hashmaps containing one-million elements.
Note: Logarithmic axes.
| arg, n | |||||||
|---|---|---|---|---|---|---|---|
| version | 1 | 10 | 100 | 1000 | 10000 | 100000 | 1000000 |
| clj-deep-merge | 1.4e-04±2.3e-06 | 1.7e-04±5.5e-06 | 4.5e-04±5.2e-06 | 3.7e-03±6.5e-05 | 3.5e-02±8.6e-04 | 3.7e-01±5.1e-02 | 4.3e+00±2.5e-01 |
| deep-merge-CLJ-1468 | 1.3e-04±7.4e-07 | 1.5e-04±7.6e-07 | 4.0e-04±2.7e-06 | 3.2e-03±9.5e-05 | 3.0e-02±1.7e-04 | 3.2e-01±5.2e-02 | 3.8e+00±3.1e-01 |
| kitchensink | 1.3e-04±1.7e-06 | 1.5e-04±8.8e-07 | 3.9e-04±2.0e-06 | 2.9e-03±2.5e-05 | 3.0e-02±2.3e-04 | 3.1e-01±5.3e-02 | 3.7e+00±2.5e-01 |
| medley | 1.3e-04±8.3e-07 | 1.5e-04±1.1e-06 | 3.2e-04±1.2e-06 | 2.3e-03±1.6e-05 | 2.2e-02±2.2e-04 | 2.2e-01±3.5e-03 | 2.9e+00±2.2e-01 |
| merge-merge | 1.4e-04±3.6e-06 | 1.5e-04±2.6e-06 | 2.6e-04±1.2e-06 | 1.5e-03±1.4e-05 | 1.5e-02±1.8e-04 | 1.4e-01±4.3e-04 | 2.2e+00±2.4e-01 |
| useful | 1.3e-04±2.5e-06 | 1.6e-04±2.9e-06 | 4.0e-04±3.9e-05 | 2.9e-03±4.9e-04 | 2.7e-02±1.3e-04 | 2.7e-01±3.1e-03 | 3.4e+00±2.7e-01 |
Using the same cohort of merge tactics as the small hashmaps benchmarks, we tested input hashmaps ranging up to
one-million key/value pairs. In general, we see that larger hashmaps require longer to merge. Most tactics perform similarly up to one-hundred
key/value pairs. For inputs larger than that, a handful of alternative implementations notably beat Clojure's merge. In broad
strokes, merge functions built with reduce-kv along with transients can provide nearly a 50% reduction in evaluation time.
Note that the log-log axes may visually diminish the differences. Tap the 'Show details' button to see the recorded data, particularly the rightmost column displaying the evaluation times for merging a pair of one-million element hashmaps.
We'll consider twelve merging tactics, some pulled in from third-party libraries. There's
nothing terribly special about any particular implementation. They're each just a different riff on combining standard Clojure functions
(reduce, conj, assoc, etc.) in different ways.
We see that execution times are very weakly correlated with input hashmap size up to sixteen elements. We can also see a small bump around five
key/value pairs, where the final output requires promoting an arraymap to a hashmap. Clojure's core merge (gold diamonds) performed
about the middle of the pack. A handful of implementations are able to perform mildly better (i.e., lower times by <10%).
These benchmarks extend the merging-with tests to hashmaps up to one-million elements. Unlike with the
smaller hashmaps, where the results are fairly indistinguishable, the merge-with variants perform noticeably different with larger
hashmaps.
For example, the implementation using reduce-kv (green triangles) offers 10-20% shorter evaluation times, with the improvement
increasing as the inputs grow larger.
| arg, n | |||||||
|---|---|---|---|---|---|---|---|
| version | 1 | 10 | 100 | 1000 | 10000 | 100000 | 1000000 |
| core-merge-with | 1.3e-04±8.0e-07 | 1.3e-04±5.8e-07 | 1.6e-04±2.4e-06 | 4.8e-04±2.4e-06 | 4.1e-03±3.6e-05 | 4.2e-02±3.1e-04 | 6.2e-01±8.0e-02 |
| reduce-assoc-merge-with | 1.3e-04±6.5e-07 | 1.3e-04±9.1e-07 | 1.6e-04±2.3e-06 | 4.5e-04±1.7e-06 | 3.8e-03±1.6e-05 | 3.9e-02±2.5e-04 | 5.3e-01±6.5e-02 |
| reduce-assoc-transient-merge-with | 1.3e-04±1.1e-06 | 1.4e-04±5.6e-07 | 1.7e-04±9.3e-07 | 5.8e-04±4.9e-06 | 4.8e-03±9.3e-05 | 4.9e-02±4.8e-04 | 5.7e-01±5.4e-03 |
| reduce-kv-assoc-merge-with | 1.3e-04±3.5e-07 | 1.3e-04±2.7e-07 | 1.5e-04±5.6e-07 | 4.1e-04±1.0e-06 | 3.6e-03±2.1e-05 | 3.6e-02±1.5e-04 | 4.9e-01±7.0e-02 |
| reduce-kv-assoc-transient-merge-with | 1.4e-04±3.5e-06 | 1.4e-04±8.5e-07 | 1.7e-04±1.4e-06 | 5.3e-04±2.2e-06 | 4.3e-03±1.3e-04 | 4.3e-02±2.9e-04 | 5.2e-01±2.0e-03 |
These benchmarks measure merging small hashmaps (sixteen elements or fewer) using merge-with behavior, simple addition. Clojure's
implementation is pretty much the fastest, but all variants cluster within a few percent. But see what
happens when the hashmaps are larger.
These are the same benchmarks, merging with eighteen mathematical operations, but with hashmaps containing
up to one-million elements. As with the simple addition tests, the implementation using
reduce-kv offers 10-20% performance improvement (i.e., lower times) compared Clojure's built-in merge-with. The
performance improvement increases as the inputs grow larger.
| arg, n | |||||||
|---|---|---|---|---|---|---|---|
| version | 1 | 10 | 100 | 1000 | 10000 | 100000 | 1000000 |
| core-merge-with | 1.3e-04±7.5e-07 | 1.3e-04±6.6e-07 | 1.8e-04±2.1e-07 | 6.4e-04±9.1e-06 | 5.8e-03±1.2e-05 | 5.8e-02±2.4e-04 | 7.8e-01±7.1e-02 |
| reduce-assoc-merge-with | 1.3e-04±8.1e-07 | 1.3e-04±1.1e-06 | 1.7e-04±2.5e-06 | 6.1e-04±2.0e-06 | 5.3e-03±9.0e-05 | 5.4e-02±1.9e-04 | 6.9e-01±6.2e-02 |
| reduce-assoc-transient-merge-with | 1.3e-04±1.2e-06 | 1.4e-04±8.1e-07 | 1.9e-04±1.0e-06 | 7.2e-04±4.4e-06 | 6.1e-03±1.2e-04 | 6.4e-02±3.6e-04 | 7.2e-01±4.3e-03 |
| reduce-kv-assoc-merge-with | 1.3e-04±1.1e-06 | 1.4e-04±5.8e-07 | 1.7e-04±3.9e-06 | 5.7e-04±1.5e-05 | 5.2e-03±2.7e-05 | 5.0e-02±1.6e-04 | 6.5e-01±7.8e-02 |
| reduce-kv-assoc-transient-merge-with | 1.4e-04±2.4e-06 | 1.5e-04±2.1e-06 | 1.9e-04±1.6e-06 | 7.1e-04±2.1e-05 | 6.0e-03±1.8e-04 | 5.8e-02±4.4e-04 | 7.2e-01±1.4e-02 |
These benchmarks explore the performance implications of more expensive value combining. Similar to the earlier ones, but instead of a single addition operation, the values are merged with a series of eighteen mathematical operations, such as cosine, natural logarithm, etc.
As before, Clojure's core merge-with is among the best performers, but all variants perform similarly in absolute terms.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |