logo
Published on

LRU

Authors
  • avatar
    Name
    Bowen Y
    Twitter

LRU

LRU Caching

The core idea revolves around using a mechanism to evict the data that's been least recently accessed when we run out of capacity. We generally use a combination of a hash map and a doubly linked list to achieve an optimal O(1) time complexity for both put and get operations.

Data Structures for LRU: Hash Map and Doubly Linked List

Let’s break down why the combination of a hash map and doubly linked list is so effective. The hash map provides constant-time access for cache hits, while the doubly linked list maintains the order of usage. Every time we access an element, we update its position in the linked list to represent its recent use. The tail of the list holds the least recently used item—perfect for easy eviction.

A practical issue you might run into, though, is balancing this cache when dealing with concurrent access. For example, let’s say you have multiple threads hitting this cache. How do you make sure the cache remains thread-safe without compromising on the efficiency that LRU is supposed to bring? A typical approach is using read-write locks or even implementing the LRU in a lock-free manner if you're after extreme performance.

Implementation using map and doubly linked list

class Node:
    def __init__(self, key: int, value: int):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}  # This will act as the hash map
        # Initialize the head and tail of the doubly linked list
        self.head = Node(0, 0)  # Dummy head
        self.tail = Node(0, 0)  # Dummy tail
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node: Node):
        """Remove a node from the doubly linked list."""
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node

    def _add(self, node: Node):
        """Add a node right after the head (most recently used position)."""
        next_node = self.head.next
        node.prev = self.head
        node.next = next_node
        self.head.next = node
        next_node.prev = node

    def get(self, key: int) -> int:
        if key in self.cache:
            node = self.cache[key]
            self._remove(node)  # Remove it from its current position
            self._add(node)     # Add it back to the front (most recently used)
            return node.value
        return -1  # Key not found

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            # Update the value and move it to the front
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add(node)
        else:
            if len(self.cache) >= self.capacity:
                # Remove the least recently used item from the cache and linked list
                lru_node = self.tail.prev
                self._remove(lru_node)
                del self.cache[lru_node.key]
            # Add the new key-value pair
            new_node = Node(key, value)
            self.cache[key] = new_node
            self._add(new_node)

Implementation using OrderedDict

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        # Move the key to the end to show it was recently used
        self.cache.move_to_end(key)
        return self.cache[key]

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            # Update the value
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            # Remove the first (least recently used) item
            self.cache.popitem(last=False)

Common Scenarios: Issues in Production Environments

  1. Cache Thrashing: One scenario where LRU can become problematic is cache thrashing. Suppose your cache size is significantly smaller than the working set of your data. You might end up evicting items that are re-requested almost immediately, resulting in frequent cache misses and a performance penalty. The LRU cache might start working against you, increasing latency rather than reducing it. A potential fix here is to use a hybrid approach like LRU-K, where you look at the K most recent uses rather than a single instance of usage to help the cache make better decisions.
  2. Hot Data: Another issue is hot data. In an LRU cache, if some data is accessed constantly while other entries only see sporadic hits, this can cause the cache to retain less useful information. One way to handle this is to introduce a segmented LRU (SLRU), where frequently accessed items are stored in a separate, higher-priority segment. This way, you minimize the risk of valuable data being evicted because of a burst in accesses elsewhere.

Performance Considerations: Memory Overhead and Latency

Memory overhead is something to bear in mind. If you have a massive cache with millions of entries, keeping a doubly linked list for maintaining order can introduce significant memory overhead. The linked list itself, with its pointers, can be quite hefty. In such cases, some developers opt for a segmented least recently used (SLRU) approach, where they divide their cache into multiple parts, thus limiting the linked list size per segment, or they use TinyLFU to augment the decision-making process with frequency counts.

Advanced Variants of LRU

While LRU is a popular and effective caching strategy, there are advanced variants that improve upon its basic approach to address specific use cases:

  • LRU-K
  • Segmented LRU (SLRU)
  • TinyLFU

Reference:

  1. https://redis.io/glossary/lru-cache/