Collections · Lesson 17.11

LRU cache

Source
Put a slow lookup behind an LruCache<K, V> of three entries, and follow the hits, the misses and which entry each miss evicts when the cache is full.

A cache keeps answers that were slow to get — a file read from disk, a page fetched over the network, a row from a database — so asking again is quick. A hash map can hold them, but a hash map only grows. A cache that never forgets anything is a memory leak with good intentions, so a real cache needs a size limit and a rule for what to forget.

The usual rule is least recently used: when the cache is full, forget the entry nobody has asked for in the longest time. What was used a moment ago is likely to be wanted again soon; what has sat untouched is the best guess at what will not be missed. LruCache<K, V> is a map with that rule built in:

import Collections::{ CollectionError, EqualsInt32, HashInt32, LruCache };

Making a cache

A cache is made like a hash map, plus its capacity:

var cache = LruCache<int32, char8[..]>(allocator, 3, HashInt32, EqualsInt32);
ArgumentDoes
allocatorProvides the memory for the entries
3The most entries it keeps
HashInt32Turns a key into a number, as in a hash map
EqualsInt32Tells whether two keys are equal

The cache allocates as it fills. Once it holds its full three entries it never allocates again: a new entry reuses the room of the one it evicts.

Hit or miss

The pattern every cache is used with: look the key up; on a hit, use the cached value; on a miss, do the slow work and put the answer in for next time.

match cache.Get(id) {
    name? => {
        PrintLine("user {}  hit   {}", id, name);
        return name;
    },
    none => {}
}
let name = LoadName(id);
let evicted = cache.Put(id, name)?;

Get returns V?, none on a miss. Put returns K? ! CollectionError: it may need memory while the cache is filling, and when the cache is full it hands back the key it evicted, so the program can log it, or write the evicted value somewhere before it is lost.

Recency

Both Get and Put count as a use: they move the entry to the front of the recency order. The eviction always takes the entry at the back. Following the program's eight requests through a cache of three, newest first:

RequestResultOrder afterwards
1miss1
2miss2 1
3miss3 2 1
1hit1 3 2
4miss, evicts 24 1 3
2miss, evicts 32 4 1
1hit1 2 4
3miss, evicts 43 1 2

User 1, asked for often, never leaves; users 2, 3 and 4 take turns in the other two places. Six loads for eight requests is the price of a cache smaller than the set of keys being asked for.

flowchart LR
    get["`Get` or `Put`"] -- "moves the entry to" --> front["front:<br/>most recently used"]
    front --- mid["…"] --- back["back:<br/>least recently used"]
    full["`Put` of a new key<br/>into a full cache"] -- "evicts" --> back

Inside, the entries sit in a vector, linked in recency order, with a hash map from each key to its entry. So Get, Put and the eviction each cost about the same whatever the capacity.

Looking without using

Sometimes you want to look without changing the order — to print the cache, say, or to check before deciding. Three methods leave the order alone:

MethodReturnsDoes
Peek(key)V?the value, without counting as a use
Contains(key)boolwhether the key is cached
KeysByRecencyuintwrites the keys, newest first, into your slice

Because they do not change the cache, they work through a read-only &LruCache — while Get needs &var, since a lookup reorders the entries. Remove and Clear take entries out by hand.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// A cache keeps answers that were slow to get — a file read from disk, a page fetched over the
// network, a row from a database — so asking again is quick. But memory is finite, so a cache
// that only ever grows is a leak with good intentions. It needs a rule for what to forget.
//
// "Least recently used" is the usual rule: when the cache is full, forget the entry nobody has
// asked for in the longest time. Whatever was used a moment ago is likely to be used again soon;
// whatever has sat untouched is the best guess at what will not be missed.
//
// `LruCache<K, V>` is a map with that rule built in. It is made with a capacity, and the same
// two functions as a hash map to hash and compare keys. `Get` and `Put` both count as a use and
// move the entry to the front; `Put` into a full cache evicts the entry at the back and tells you
// which key it was. Once full, the cache reuses that entry's room, so it never allocates again.
import Allocator::{ Allocator, SystemAllocator };
import Collections::{ CollectionError, EqualsInt32, HashInt32, LruCache };
import Io::{ Print, PrintLine };

var Loads: int32 = 0;

// Stands in for the slow part: a database lookup of a user's name. It counts its calls.
func LoadName(id: int32) -> char8[..] {
    Loads = Loads + 1;
    return match id {
        1 => "ada",
        2 => "brian",
        3 => "carmen",
        4 => "dmitri",
        else => "unknown"
    };
}

// Answers from the cache when it can, and loads and caches the name when it cannot.
func Name(cache: &var LruCache<int32, char8[..]>, id: int32) -> char8[..] ! CollectionError {
    match cache.Get(id) {
        name? => {
            PrintLine("user {}  hit   {}", id, name);
            return name;
        },
        none => {}
    }
    let name = LoadName(id);
    let evicted = cache.Put(id, name)?;
    match evicted {
        old? => PrintLine("user {}  miss  {:<6}  (evicted user {})", id, name, old),
        none => PrintLine("user {}  miss  {}", id, name)
    }
    return name;
}

// Prints the keys from the most to the least recently used. It takes a read-only reference, so it
// cannot call `Get`: a lookup moves the entry to the front, which changes the cache.
//
//     let first = cache.Get(1);
//         error: cannot call 'Get' through immutable reference '&LruCache<int32, char8[..]>'
//
// `Peek` and `Contains` read without counting as a use, and work through `&`.
func ShowOrder(cache: &LruCache<int32, char8[..]>) {
    var keys: int32[3] = [0; 3];
    let count = cache.KeysByRecency(keys[..]);
    Print("recency:");
    for key in keys[..count] {
        Print(" {}", key);
    }
    PrintLine();
}

func Main() -> ! CollectionError {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    // Room for three names, keyed by `int32`.
    var cache = LruCache<int32, char8[..]>(allocator, 3, HashInt32, EqualsInt32);

    let requests: int32[8] = [1, 2, 3, 1, 4, 2, 1, 3];
    for id in requests[..] {
        Name(cache, id)?;
    }
    ShowOrder(cache);
    PrintLine("{} requests, {} loads", requests.length, Loads);

    // `Peek` reads without counting as a use, so the order does not change. `Contains` asks
    // without reading the value at all.
    PrintLine("peek 2: {}", cache.Peek(2) ?? "(not cached)");
    PrintLine("has 4: {}", cache.Contains(4));
    ShowOrder(cache);
}

Besides Io, its Rux.toml lists Allocator and Collections under [Dependencies].

Run it

cd Examples/Collections/LruCache
rux run
user 1  miss  ada
user 2  miss  brian
user 3  miss  carmen
user 1  hit   ada
user 4  miss  dmitri  (evicted user 2)
user 2  miss  brian   (evicted user 3)
user 1  hit   ada
user 3  miss  carmen  (evicted user 4)
recency: 3 1 2
8 requests, 6 loads
peek 2: brian
has 4: false
recency: 3 1 2

Common mistakes

Try it yourself

  1. Change the capacity to 4 and run again. How many loads now? Then try 2.
  2. Reorder the requests so that the cache of three never hits, and so that it misses only three times.
  3. After the requests, Remove(1) and print the order. Then Put user 4 back and see that nothing is evicted.
  4. Use the cache for something slow to compute: cache Fibonacci(n) worked out the slow recursive way, and count how many calls each request saves.

Learn more