ARC: Adaptive Replacement Cache with Ghost Lists

2 min readDatabases & Storage

ARC maintains four lists (T1, T2, B1, B2) to adaptively balance between recency (LRU-like) and frequency (LFU-like). Ghost lists B1 and B2 track recently evicted pages, allowing ARC to learn from cache misses and adjust its partitioning parameter p. ZFS uses ARC as its default page cache algorithm.

cachingeviction-policydata-structures

The four-list structure

ARC maintains two real caches and two ghost caches:

| List | Contents | Size | |---|---|---| | T1 | Recently seen once (new pages) | p | | T2 | Seen at least twice (frequently used pages) | c - p | | B1 | Ghost: recently evicted from T1 (keys only, no data) | ≤ c | | B2 | Ghost: recently evicted from T2 (keys only, no data) | ≤ c |

c = cache capacity. p = adaptive parameter, the target size of T1. T1 + T2 together hold at most c pages. B1 and B2 hold keys only (no data), recording what was recently evicted.

Ghost lists enable ARC to adapt without psychic knowledge of future access patterns

ConceptCaching

When a page is requested and found in ghost list B1, it means: 'this was recently evicted from T1, and now it's being requested again — T1 was too small.' ARC increases p to grow T1. A B2 hit means T2 was too small — ARC decreases p to grow T2. Ghost lists are the feedback mechanism that makes ARC self-tuning.

Prerequisites

  • LRU cache
  • LFU cache
  • Cache eviction policies

Key Points

  • T1 hit or T2 hit: cache hit — move to T2 (promote to frequent).
  • B1 hit: recent T1 evictee requested again — increase p (T1 was too small).
  • B2 hit: recent T2 evictee requested again — decrease p (T2 was too small).
  • Complete miss: add to T1. Evict from T1 or T2 based on p.

Request handling logic

from collections import OrderedDict

class ARC:
    def __init__(self, cache_size):
        self.c = cache_size
        self.T1 = OrderedDict()   # recent: seen once
        self.B1 = OrderedDict()   # ghost of T1
        self.T2 = OrderedDict()   # frequent: seen 2+ times
        self.B2 = OrderedDict()   # ghost of T2
        self.p = 0                # target size of T1

    def request(self, key):
        # Case 1: Cache hit in T1 or T2
        if key in self.T1:
            del self.T1[key]
            self.T2[key] = True   # promote to frequent
            return True
        if key in self.T2:
            self.T2.move_to_end(key)
            return True

        # Case 2: Ghost hit in B1 — T1 was too small
        if key in self.B1:
            delta = len(self.B2) / max(1, len(self.B1))
            self.p = min(self.c, self.p + max(1, delta))
            self._replace(key)
            del self.B1[key]
            self.T2[key] = True
            return False

        # Case 3: Ghost hit in B2 — T2 was too small
        if key in self.B2:
            delta = len(self.B1) / max(1, len(self.B2))
            self.p = max(0, self.p - max(1, delta))
            self._replace(key)
            del self.B2[key]
            self.T2[key] = True
            return False

        # Case 4: Complete miss
        total = len(self.T1) + len(self.B1)
        if total >= self.c:
            if len(self.T1) < self.c:
                self.B1.popitem(last=False)  # evict from ghost
                self._replace(key)
            else:
                self.T1.popitem(last=False)  # evict from T1 directly
        elif len(self.T1) + len(self.T2) + len(self.B1) + len(self.B2) >= 2 * self.c:
            if self.B2:
                self.B2.popitem(last=False)
            self._replace(key)

        self.T1[key] = True   # new page enters T1
        return False

    def _replace(self, key):
        """Evict from T1 or T2 based on p."""
        if self.T1 and (len(self.T1) > self.p or (key in self.B2 and len(self.T1) == self.p)):
            # Evict from T1 → move to B1
            old_key, _ = self.T1.popitem(last=False)
            self.B1[old_key] = True
        else:
            # Evict from T2 → move to B2
            old_key, _ = self.T2.popitem(last=False)
            self.B2[old_key] = True

Adaptation examples

Starting with p = 0 (all space allocated to T2/frequent):

Workload shifts to new data access:

  • Many B1 ghost hits occur (recently evicted T1 pages being re-requested)
  • Each B1 hit increases p → T1 grows → more space for recently-seen pages
  • ARC adapts toward LRU behavior

Workload shifts to repeated access of stable hot set:

  • Many B2 ghost hits occur (stable pages being re-requested after eviction)
  • Each B2 hit decreases p → T2 grows → more space for frequent pages
  • ARC adapts toward LFU behavior
📝ZFS ARC: ARC in production at scale

ZFS (OpenZFS, used in FreeBSD, Solaris, TrueNAS) uses ARC as its page cache with modifications:

  • Prefetch list: pages loaded by sequential read-ahead go to a prefetch sublist of T1. Ghost tracking is disabled for prefetch pages — scan pages don't poison B1.
  • L2ARC: a second-level ARC backed by SSD. When pages are evicted from ARC (DRAM), they migrate to L2ARC (SSD) before hitting disk. The ghost lists for L2ARC are maintained separately.
  • MFU vs MRU: ZFS calls T2 "MFU" (most frequently used) and T1 "MRU" (most recently used) — same concept, different naming.

ZFS ARC is well-studied in production. On a database server with 128GB RAM, ZFS ARC effectively uses most available RAM (beyond OS kernel needs) and adapts to mixed sequential + random I/O workloads automatically.

Check ZFS ARC stats on Linux:

cat /proc/spl/kstat/zfs/arcstats | grep -E "^(c |size|hits|misses|mfu_hits|mru_hits)"

An ARC cache starts with p=0 (all allocation toward T2). A workload begins accessing many new, unique pages that aren't in T2. After a while, p increases significantly. What triggered this, and what does it mean for eviction behavior?

medium

Initial state: T2 is large (p=0 means T1 target is 0). New pages accessed are unique and not in any list. Ghost list B1 starts growing as new T1 pages are evicted.

  • AB2 ghost hits triggered p to increase — pages evicted from T2 were being requested again
    Incorrect.B2 hits decrease p (signaling T2 was too small). B1 hits increase p (signaling T1 was too small). For new unique page access, pages enter T1 and are quickly evicted to B1. Re-requests of those evicted pages hit B1 and increase p.
  • BB1 ghost hits triggered p to increase — pages recently evicted from T1 were being requested again, signaling T1 was too small
    Correct!When new unique pages enter T1 and are quickly evicted (because p=0 means T1 has no target allocation), they move to ghost list B1. If those pages are subsequently requested, it's a B1 hit — meaning ARC evicted them too early. ARC responds by increasing p, growing T1's target allocation. As p increases, T1 can retain more recently-seen pages before evicting them to B1. ARC is adapting from LFU behavior (p=0, all T2) toward LRU behavior (larger p, more T1 space) as the workload shifts to new page access.
  • CT1 size exceeding c directly causes p to increase
    Incorrect.p is only adjusted on B1 hits (increase) and B2 hits (decrease). T1 size doesn't directly affect p.
  • DARC increases p automatically on every cache miss, regardless of ghost list hits
    Incorrect.p only changes when ghost lists are hit. A complete miss (not in any list) doesn't change p.

Hint:Which ghost list hit increases p, and what does a hit in that list mean about T1's size?