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

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:
- No allocations, since everything fits in the stack-allocated
TinyVec. - Linear search, which for small N is faster than hash lookups.
- It's cache-friendly, because all the data is contiguous in memory.
Heap-backed scalability (after the spill)
Once it goes past the inline capacity, the cache spills on its own:
TinyVecdoes its automatic spill and all entries move to heap storage.- The hash map index gets allocated, so lookups are O(1).
- The linked list stays as it is and keeps the LRU ordering.
- 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:
- For small N (typically N ≤ 32), a linear search beats a hash table.
- Stack allocation is faster than heap allocation when the workload is predictable.
- Contiguous memory keeps the cache happy, and chasing pointers around doesn't.
- 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:
- Support larger sizes
- Optimise pop() and find_key_index() further()
- Have optinal stronger guards against spill.