A cache stores data so that future requests can be served faster. The idea shows up at every level of computing, from the SRAM caches built into a CPU to the in-memory key-value stores that sit in front of a database.
This series is about the software kind. Since a cache is a key-value store, it’s typically backed by a hash table, which gives us lookups in constant time (\(O(1)\)) on average. That gets us fast reads, but a cache has a finite amount of space, so what happens when it fills up?
The answer is a cache replacement policy, an algorithm to decide which entry to evict. The policy is one of the biggest factors in how often you get a cache hit, and so largely determines whether the cache is worth having at all.
Over the next few articles we’ll implement several of these policies in Haskell and weigh their trade-offs, starting with LRU, or least recently used.
This first post is all about building a mutable LRU cache out of IORefs and a hand-rolled doubly linked list. A follow-up will put it through its paces with property-based tests using QuickCheck.
Why not follow along with the source code?
LRU Cache Behaviour #
Let’s define the behaviour of an LRU cache.
- Adding elements.
- Adding a new entry puts its value at the head of the recency list, evicting the entry at the tail if the cache is already full.
- Adding a duplicate entry overwrites its value and moves it to the head of the recency list.
- Getting elements.
- Getting an entry moves it to the head of the recency list.
- Getting an entry not in the cache returns nothing and leaves ordering untouched.
- Deleting elements.
- Deleting an element in the cache removes it from both the recency list and the map.
- Deleting an element not in the cache is a no-op.
Architecture #
As I’ve mentioned, our LRU will be backed by a hash table. It will map keys to nodes in a doubly linked list, with the nodes holding the data.
The doubly linked list will model the recency ordering of our elements, making it easy to change the order of arbitrary nodes without having to constantly traverse the list, as would be the case with a singly linked list.
This is the standard approach to implementing an LRU cache in an imperative language such as C++ or Java. But Haskell is a pure language. Side effects are tracked within the type system rather than implicitly, so to perform the pointer manipulation required to adjust the recency list, we’ll make extensive use of IORef.
Idiomatic Haskell wouldn’t approach this problem in this way. An idiomatic solution would likely make use of something like a priority-search queue such as Data.OrdPSQ, trading \(O(1)\) for \(O(\log{n})\) ops, but getting persistence and thread-safety for free.
Fine, but we’re all about learning new things, so we’ll explore IO-based mutation.
IORef Primer
#
Since the whole implementation leans on IORef, what actually is it?
An IORef a is a mutable box holding a value of type a, the closest thing Haskell has to a re-assignable variable, or to a pointer in C. Reading or writing one is a side effect, so every operation lives in IO:
newIORef :: a -> IO (IORef a) -- create a box holding an initial value
readIORef :: IORef a -> IO a -- read the current contents
writeIORef :: IORef a -> a -> IO () -- overwrite the contents
modifyIORef' :: IORef a -> (a -> a) -> IO () -- update the contents by applying a functionmodifyIORef' (with trailing ') is the strict variant of modifyIORef. modifyIORef (with no ') stores an unevaluated thunk, so doesn’t actually compute a new value until something forces it. modifyIORef' forces the new value as it’s written, so nothing piles up.
Let’s see a quick example. We’ll create a box, overwrite it, bump it, then read it back:
example :: IO ()
example = do
ref <- newIORef (0 :: Int) -- ref holds 0
writeIORef ref 42 -- ref now holds 42
modifyIORef' ref (+ 1) -- ref now holds 43
x <- readIORef ref
print x -- prints 43With that down, let’s start building!
Implementation #
Node #
We’ll start by implementing a Node datatype to hold data and maintain pointers to neighbours.
module Nodes (Node (value)) where
import Data.IORef (IORef)
data Node a = Node
{ prev, next :: IORef (Maybe (Node a)),
value :: IORef a
}We account for a Node possibly having no neighbours by wrapping prev and next in Maybe, and since we want to move Nodes around and change their value, the prev, next, and value fields are wrapped in IORef.
Neighbours #
It’d be a good idea to keep prev and next hidden from users, to avoid pointer-mutation bugs. We’ll add a Neighbours datatype so users of Nodes.hs can read a node’s neighbouring nodes without touching the mutable references.
module Nodes (Node (value), Neighbours (before, after)) where
import Data.IORef (IORef)
data Node a = Node
{ prev, next :: IORef (Maybe (Node a)),
value :: IORef a
}
data Neighbours a = Neighbours {before, after :: Maybe (Node a)}We’ll export the before and after fields from Neighbours (which are immutable), and not the prev and next fields from Node (which are mutable).
As for the Node datatype, we’ll only export the value field since our cache may need to overwrite it.
Next we’ll add some helper functions.
Node Helper Functions #
Given:
- a value a, create a new
Node a. - a
Noden, return itsNeighbours. - a
Noden and the head of a linked list l, make n the new head of l. - a
Noden already in the list, and the head of a linked list l, move n to the head of l. - a
Noden, delete it, bridgingprevandnext.
Here’s the complete module:
|
|
Visualising Rewrites #
moveToHead and dropNode each rewrite several pointers at once, so they’re easier to follow as a picture than as a nest of case expressions. In the diagrams below, HEAD and TAIL are the references the cache owns and everything between them is the node chain.
moveToHead - move C to the front
before: HEAD -> [A] <-> [B] <-> [C] <-> [D] <- TAIL
after: HEAD -> [C] <-> [A] <-> [B] <-> [D] <- TAIL
^ ^ ^
([C] moved to head) ([B] and [D] now bridged)dropNode - remove a middle node C
before: HEAD -> [A] <-> [B] <-> [C] <-> [D] <- TAIL
after: HEAD -> [A] <-> [B] <-> [D] <- TAIL ([C] discarded)
^ ^
([B] and [D] now bridged)dropNode as eviction - remove the tail D
before: HEAD -> [A] <-> [B] <-> [C] <-> [D] <- TAIL
after: HEAD -> [A] <-> [B] <-> [C] <- TAIL ([D] evicted)Going back to the code, there are some things to clarify:
- Notice that none of the functions track the head of the list itself? They only rewire the
prevandnextpointers of the nodes they’re handed. That’s deliberate, since the head and tail of the list will be owned by the cache. Each helper does the \(O(1)\) pointer surgery, and the cache is responsible for updating its own head and tail references to match. - Two assumptions are baked into
addNode. First, it expectsnNodeto be a fresh node whoseprevisNothing. Second, its typeNode a -> Node a -> IO ()demands an existinglistHead, so it can’t seed an empty list. That very first insertion, where the new node becomes both head and tail, is a special case the cache will handle. - You might also wonder where the helper for evicting the least-recently-used element is. Well there isn’t one, since eviction is just
dropNodeapplied to the tail, which as I’ve mentioned, is a reference the cache will keep.
Cache #
Let’s define a Cache datatype.
Our Cache will have the following fields:
cap- the maximum capacity of the cache, defined at creation timelen- the number of elements currently in the cachecacheMap- the key/value store holding datalistHead- points to the current head of the recency listlistTail- points to the current tail of the recency list
module Cache (Cache) where
import Data.HashMap.Strict (HashMap)
import Data.IORef (IORef)
import Data.Word (Word8)
import Nodes (Node)
data Cache k v = Cache
{ cap :: Word8,
len :: IORef Word8,
cacheMap :: IORef (HashMap k (Node (k, v))),
listHead, listTail :: IORef (Maybe (Node (k, v)))
}Some explanatory comments:
capandlenareWord8meaning the cache can hold up to 255 elements. I’ve chosen this simply to keep things small. Note a cache withcap== 0 would hold nothing.listHeadandlistTailhold mutableNode (k, v). Why(k, v)? Well, when we drop a node, we need a way to link back to the key in theHashMap, so we can delete that too.
Now we’ll add some helper functions.
Cache Helper Functions #
Given:
- a capacity cap, create a new cache with that capacity.
- a cache c, a key k and value v, add k and v to c.
- a cache c and a key k, retrieve the value v associated with k.
- a cache c and a key k, delete k from c.
These functions will make up the public API, and so will be exported from the module.
We’ll also need some private internal functions to manage the ordering of nodes.
When:
- a value is retrieved from the cache, promote that node to the head of the recency list.
- a new key and value are added to the cache, the associated node should be pushed to the head of the recency list.
- the cache is full and a new key/value pair is added, the key, value and node at the tail of the recency list should be removed.
Here’s the complete implementation:
|
|
A few things worth drawing out from the code:
- As promised in the primer, every read-modify-write update goes through the strict
modifyIORef'.readIORefandwriteIORefare left un-strict for two reasons:readIORefhas nothing to force since it just hands back the current contents, andwriteIORefreplaces the box outright rather than deriving the new value from the old, so successive writes never nest. promote,evictLRU, anddeleteFromform a little hierarchy of pointer-patching.promoteonly ever moves a node up, so the sole endpoint it can disturb is the tail, and only when the node it’s promoting was sitting there.evictLRUonly ever removes the tail, so it always patches the tail.deleteFromis the general case: the node could be anywhere, so it inspects each end independently with its twowhenguards, and either, both, or neither may fire.- Both
evictLRUanddeleteFromread the node’sneighboursbefore callingdropNode. SincedropNoderewires theprevandnextpointers, in order to find the new head or tail, we have to snapshot them first. Snapshot, then mutate — the recurring shape of every operation here. deleteFromon a missing key is a no-op, and a hit keepslenand the map in lockstep becauseHM.deleteandsubtract 1always happen together.
Trying It Out #
Let’s take it for a spin.
|
|
The key moment is the _ <- getFrom cache "a" line. Had we not read "a" beforehand, it would have been the least-recently-used entry and "c" would have evicted it. That one getFrom is the difference between "a" surviving and "a" being dropped.
Invariants, and What’s Next #
Every operation above is careful to preserve a handful of structural invariants. Note that these operations aren’t atomic, so the cache assumes single-threaded use.
At rest, a well-formed cache should satisfy:
- Size agreement —
lenequals the number of entries incacheMap. - Capacity —
lennever exceedscap. - Endpoints — if the cache is non-empty,
listHeadandlistTailare bothJust, the head node has noprev, and the tail node has nonext; if it’s empty, both areNothing. - Reachability — following
nextfrom the head visits every node exactly once and lands on the tail, followingprevfrom the tail does the opposite. The nodes visited are exactly the values ofcacheMap, and each is stored under the key it holds.
Every one of them is tedious to check by hand and easy to break with a single stray pointer write. They’re exactly what you’d want a test to hammer on. There’s only one problem: none of the state you’d need to check them is reachable! len, cacheMap, listHead and listTail are all private, prev and next too, and that encapsulation is deliberate, as it stops us corrupting the list by accident.
So we have an interesting question to answer: how do you test a data structure whose state is hidden?
That’s where we’ll go next.