It seems like youâre asking about the tradeoff between:
- Implementation complexity of HAMT (over shallow copy-on-write)
- Processing and memory savings when using HAMT (over shallow copy-on-write)
Iâve often thought about this question myself.
processing savings
I would guess that there were some early benchmarks of Clojureâs HAMT implementations that showed that 8 was the optimal number to cross-over at.
I am not an optimization expert. I only dabble. But I believe it has to do with memory caches. The HAMT is very cache-aware, but canât really help at small sizes. 8 k/vs is about where the graphs intersect, and so Clojure will convert an array (with linear search of up to 8 keys) to a HAMT of 9 keys.
(Linear search works faster over a small array because of the cache. Essentially, a cache miss is more expensive than almost anything you can do within the cache, even a dumb linear search.)
However, once the array starts getting larger than the cache, youâre better off having a trie. A linear search over a large array requires linear cache misses. A trie requires logarithmic cache misses.
Hereâs something to worry about now: if the 8-k-v mark was correct in 2007, is it still correct today, with our current hardware? Does it make sense on different hardware?
I think this same linearity applies to copying as well. Copying is a linear operation. You have to copy pointers to all keys and values to copy a hash map. Being able to copy a logarithmic subset is much faster for large collections.
memory performance
Even if copying were fast, it still takes more memory to copy a larger data structure. The same linear/logarithmic tradeoff applies here.
size distribution
@Yehonathan_Sharvit mentions that most hash maps are less than 10 keys. I havenât measured, but that seems reasonable. Maybe even 90% are below 10 keys, and 99% are below 20 keys. Surely copying a 20 key map is very fast. Is the added complexity necessary for the last 1%? Thatâs a good question and probably needs testing on real systems. One possibility to consider is that the big ones are so much bigger than the median that copying them starts to dominate the runtime.
reference distribution
Something not mentioned in the OP is keeping references to older copies. I guess that, given how we use Clojure today, 99%+ of âcopiesâ of our hash maps do not have references to them. They are immediately available for garbage collection. That leaves 1% that will hang around. Does that 1% justify the complexity? Again, we must consider if the ones that do hang around a long time are also really big.
Evidence
I think itâs important that for most applications, Clojure is fast enough. That is, the choices made at the beginning seem to work. Thatâs a serious factor in considering whether Clojure made the right tradeoff.
What would we be thinking about if Clojure didnât have HAMTs and it wasnât sufficient? Weâd probably have a lot of discussions about keeping collections smaller (nesting them more) and avoiding the immutable ones altogether for certain large cases. If we do talk about avoiding immutable data structures, itâs for all cases, not just the large ones. The fact that we donât talk about those mitigations for performance reasons is another mark for Clojureâs choices (though inconclusive).
I think we should also consider ClojureScriptâs implementation. It started out with basic copy-on-write versions, but they were replaced later when they implemented HAMTs. Presumably, they implemented them for a good reason.
Mitigations
Letâs imagine Clojure didnât have HAMTs. A simpler implementation of immutable collections would be to wrap Javaâs HashMap in ImmutableMap and make full copies on every change. What are some things we could do to improve the performance?
Batching
I wrote about one thing in Grokking Simplicity, page 270, called withArrayCopy()
. Itâs a higher-order function that implements a copy-on-write discipline for arrays. It lets you batch operations. Instead of doing 100 immutable modifications in a row, you would make one copy, modify it in a mutable way 100 times, then wrap it up in ImmutableMap at the end. You only wind up making 1 copy instead of 100. Hereâs the JS version from Grokking Simplicity:
function withArrayCopy(array, modify) {
var copy = array.slice(); // make a shallow copy
modify(copy);
return copy;
}
Immer does something much cooler, but with the same âbatchingâ idea. It âproxiesâ the object you pass it and records all of the operations on it. Then it makes a copy of anything that had a method called on it and replays the operations on the copy. This lets it work on nested data where you need to copy everything on the path to the thing that changes. This is good for JS because itâs awkward to use immutable structures. The syntax makes mutation very convenient.
More structure
In OO textbooks, they often talk about modeling sets using polymorphism. You have one class that is IntegerSet
that contains all integers. And you have FilterSet
that can filter out certain elements. And you have IntersectionSet
and UnionSet
that combine different sets. None of these sets are storing values. They logically represent a set using a predicate or by deferring to other sets.
In Clojure, we actually build the set. Thereâs no way to make an infinite set of all integers. A union of two sets makes a new set with the elements of the other two. What Iâm suggesting is to perhaps be smarter about how collections are constructed, probably passing that responsibility onto the programmer. So, if you just need to check for containment, thereâs no reason to realize a new union set. You could just make a small object that points to the two sets to be combined. Containment check is fast (worst case is you check both sets). This opens the door for lots of optimizations, at the cost of exploding choices for the programmer.
The example was about sets but it could be extended to other collections as well.
Conclusion
People used to think all the copying would be really expensive. Is it? People copy all the time now. Strings are immutable. Theyâre copied. And the stuff I see in JS with destructuring and immediately restructuring makes me mad. Thatâs just copying, too. Does waiting for network requests dominate the runtime anyway? Maybe it doesnât even matter anymore. Until it does.
I canât help but think that Clojure did get it right, at least for 2007 when it was first released. The computer I was running at that time at work may have had 512 MB of memory. People did fret about copying. And Clojure was fine.