Designing and implementing v0.1.0 of tiny-lru

A small-then-spill LRU cache, as fast as stack-based LRUs but scaling like heap-backed ones

Blog image

Background

LRU caches are a fundamental data structure. Rust has a few LRU crates, but they make you pick a side: either stack storage, which is great for small caches, or heap storage, which is what you want for large ones. Nobody really did both.

The tinyvec crate already solved this well for vectors. It starts with an inline array and "spills" to the heap when needed, so small collections, the common case, are fast and large ones, the edge case, still work.

tiny-lru reuses that tried-and-tested trick for an LRU cache. For small caches it never allocates, and lookups are just a linear search with good cache locality. Once it goes past capacity it moves to heap-backed storage with O(1) operations, without the caller noticing.

Design philosophy (small first, scale second)

The idea behind tiny-lru is pretty simple: make the common case fast first, then deal with the edge cases.

For very small working sets, entries live inline on the stack in a fixed-capacity array, so lookups and updates are fast, never touch the allocator, and keep really good cache locality. Once the inline capacity is exceeded, it spills into a heap-backed LRU (hash map + linked list), which keeps operations O(1) at larger sizes.

I didn't want to give up any speed on small caches, or have it suddenly get much slower on bigger workloads. It's a tinyvec-style hybrid LRU, built for tiny hot paths (embedded, HFT, real-time) but also for unbounded growth when you need it.

The two-tier architecture

Stack-first performance (before the spill)

While the cache is small (≤ N entries), everything happens on the stack:

struct TinyLru<K, V, const N: usize> {
    // Unified node storage; starts inline, spills to heap as capacity grows
    store: tinyvec::TinyVec<[Entry<K, V>; N]>,
    
    // Current number of live items
    size: u16,
    
    // LRU linkage heads (indices into `store`)
    head: u16, // LRU index
    tail: u16, // MRU index
    
    // Key → index map. Lazily allocated ONLY on first spill
    index: Option<hashbrown::HashMap<K, u16>>,
    
    capacity: u16,
}

What that gets you:

Heap-backed scalability (after the spill)

Once it goes past the inline capacity, the cache spills on its own:

  1. TinyVec does its automatic spill and all entries move to heap storage.
  2. The hash map index gets allocated, so lookups are O(1).
  3. The linked list stays as it is and keeps the LRU ordering.
  4. The API stays the same, so the user doesn't have to change anything.

Basic v0.1.0 API

The API is meant to be a drop-in replacement for the usual LRU caches:

// Core operations
push(key, value)                    // Insert or update; promotes on hit
pop() -> Option<(K, V)>             // Remove and return the LRU entry
get(&mut self, key: &K) -> Option<&V>        // Lookup with promotion
get_mut(&mut self, key: &K) -> Option<&mut V> // Mutable lookup with promotion
peek(&self, key: &K) -> Option<&V>  // Lookup without promotion
remove(&mut self, key: &K) -> Option<(K, V)> // Remove by key

// Capacity management
set_capacity(&mut self, new_cap: u16) // Adjust total capacity
capacity() -> u16
len() -> u16
is_empty() -> bool

// Spill management
is_spilled() -> bool    // Check if using heap storage
can_unspill() -> bool   // Check if unspill is possible
unspill() -> bool       // Move back to inline storage

The AoS vs SoA experiment (and why I abandoned it)

Initially, I considered a Struct of Arrays (SoA) approach so I could use SIMD:

struct TinyLruSoA<K, V, const N: usize> {
    hashes: TinyVec<[u64; N]>,        // Pre-computed hashes for SIMD
    keys: TinyVec<[K; N]>,            // Actual keys
    values: TinyVec<[V; N]>,          // Values
    next: TinyVec<[u16; N]>,          // DLL next pointers
    prev: TinyVec<[u16; N]>,          // DLL prev pointers
    // ...
}

The theory was that separate arrays would let me do SIMD hash comparisons and use the cache better.

In practice it was actually slower. The overhead of maintaining separate arrays, plus the complexity of the SIMD code, outweighed the benefits it had on paper. Sometimes the simple solution really is the best one.

Performance results

I benchmarked tiny-lru against the major Rust LRU implementations across different cache sizes.

Pre-spill performance (small cache sizes)

Performance comparison showing relative speed (higher numbers = slower). tiny-lru is the baseline (1.00).

Push

Implementation 2 4 8 16 32
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00
const-lru 2.00 2.39 2.68 2.90 2.65
lru-rs 6.96 7.22 6.02 5.49 3.67
schnellru 2.36 6.42 7.84 7.66 5.61

Pop

Implementation 2 4 8 16 32
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00
const-lru 2.58 3.76 3.53 3.84 5.17
lru-rs 14.05 9.33 7.10 4.13 3.58
schnellru 1.15 1.31 1.33 1.11 0.83

Peek

Implementation 2 4 8 16 32
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00
const-lru 1.90 2.37 2.03 1.56 1.16
lru-rs 3.58 4.29 3.47 2.25 1.12
schnellru 1.16 1.60 1.60 1.20 0.77

Get

Implementation 2 4 8 16 32
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00
const-lru 2.00 3.02 2.80 1.77 1.30
lru-rs 3.11 3.08 2.56 1.46 0.77
schnellru 0.98 1.14 1.21 0.90 0.60

The results are great-ish. tiny-lru is consistently faster across small sizes. Although as N increases traditional lru's start catching up.

Post-spill performance (large cache sizes)

For larger caches, tiny-lru stays competitive:

Peek

Implementation 100 200 500 1000 2000 5000 10000
lru-rs 2.11 2.26 2.22 2.18 2.15 1.71 1.46
schnellru 2.59 2.43 1.54 1.60 1.62 1.71 1.70
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00 1.00 1.00

Get

Implementation 100 200 500 1000 2000 5000 10000
lru-rs 1.64 1.60 1.56 1.27 1.16 1.07 0.89
schnellru 1.49 1.28 0.86 0.83 0.82 0.82 0.83
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00 1.00 1.00

Put

Implementation 100 200 500 1000 2000 5000 10000
lru-rs 8.17 1.73 1.54 1.51 1.34 1.34 1.36
schnellru 1.09 1.33 1.66 1.74 1.64 1.53 1.56
tiny-lru 👍 1.00 1.00 1.00 1.00 1.00 1.00 1.00

Keeping it simple paid off

The numbers back up the "simple is better" idea:

  1. For small N (typically N ≤ 32), a linear search beats a hash table.
  2. Stack allocation is faster than heap allocation when the workload is predictable.
  3. Contiguous memory keeps the cache happy, and chasing pointers around doesn't.
  4. Having two tiers works better for mixed workloads than a one-size-fits-all design.

Future work

v0.1.0 is good enough for now, but there's still stuff I want to do: