Skip to content
About
Main content

LRU, random and Clock on a cyclic loop

Simulate a modelNeeds Python

Problem

evicts the page unused for longest, which is right when recent use predicts future use. A loop over n pages with fewer than n frames is the opposite case: the page about to be needed is always the one just evicted, so LRU gets no hits at all, while random replacement keeps some of the loop by luck. A one-time scan is the other classic failure: it pushes every hot page out of an LRU pool. admits new pages to a probation first, and databases give sequential scans a small private ring of buffers.

Claim to test: on a loop over n pages with a pool of c < n frames, LRU gets exactly 0% hits, while random replacement degrades smoothly (about 81% at c = 0.9n). Write a small simulator, replay the same loop against LRU, random and Clock, for c from 1 to n = 100. Then reference a hot set of 50 pages, interrupt it with a one-time scan of 10,000 other pages, and measure the hot set after the scan under LRU, 2Q (with a ghost queue of identifiers) and a " for the scan" variant.

The question. What does each policy get on the loop, and how much of the hot set survives the scan under each?


Predict

After a large scan, what share of the hot set does 2Q keep in the pool?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    lru-random-and-clock-on-a.py123 linesDownload
    View the code
    lru-random-and-clock-on-a.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """LRU, random and Clock on a loop; then a scan.
    
    Part 1 replays a loop over n pages against a pool of c frames under LRU,
    random replacement and Clock, for c from 1 to n. Part 2 references a hot set
    of 50 pages, interrupts it with a one-time scan of 10,000 other pages, and
    measures the hot set's hit rate afterwards under LRU, 2Q (with a ghost queue
    of identifiers) and a "ring buffer for the scan" variant.
    """
    import argparse, json, random
    from collections import OrderedDict, deque
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--n", type=int, default=100, help="Pages in the loop.")
    p.add_argument("--steps", type=int, default=100_000, help="References per pool size.")
    p.add_argument("--hot", type=int, default=50, help="Part 2: hot pages.")
    p.add_argument("--scan", type=int, default=10_000, help="Part 2: pages in the one-time scan.")
    p.add_argument("--pool", type=int, default=100, help="Part 2: frames in the pool.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    
    def lru(n, c, steps):
        pool, hits = OrderedDict(), 0
        for t in range(steps):
            p = t % n
            if p in pool: hits += 1; pool.move_to_end(p)
            else:
                if len(pool) >= c: pool.popitem(last=False)                # evict least recent
                pool[p] = True
        return hits / steps
    
    def rand(n, c, steps):
        pool, where, hits = [], {}, 0
        for t in range(steps):
            p = t % n
            if p in where: hits += 1
            elif len(pool) < c: where[p] = len(pool); pool.append(p)
            else:
                i = random.randrange(c); del where[pool[i]]; pool[i] = p; where[p] = i
        return hits / steps
    
    def clock(n, c, steps):
        frames, ref, where, hand, hits = [None] * c, [False] * c, {}, 0, 0
        for t in range(steps):
            p = t % n
            if p in where: hits += 1; ref[where[p]] = True; continue
            while ref[hand]: ref[hand] = False; hand = (hand + 1) % c   # second chance
            if frames[hand] is not None: del where[frames[hand]]
            frames[hand] = p; where[p] = hand; ref[hand] = True; hand = (hand + 1) % c
        return hits / steps
    
    loop = []
    for c in sorted({1, *range(10, args.n + 1, 10), 90, 95, 99, args.n}):
        loop.append({"c": c, "LRU": lru(args.n, c, args.steps) * 100, "random": rand(args.n, c, args.steps) * 100,
                     "Clock": clock(args.n, c, args.steps) * 100})
    
    # Part 2 ─ a hot set, a one-time scan, the hot set again
    def trace():
        hot = [("h", i) for i in range(args.hot)]
        warm = [random.choice(hot) for _ in range(20 * args.hot)]
        scan = [("s", i) for i in range(args.scan)]
        after = [random.choice(hot) for _ in range(20 * args.hot)]
        return warm, scan, after
    
    class LRU:
        def __init__(s, c): s.c, s.d = c, OrderedDict()
        def get(s, p, scan=False):
            if p in s.d: s.d.move_to_end(p); return True
            if len(s.d) >= s.c: s.d.popitem(last=False)
            s.d[p] = True; return False
    
    class TwoQ:
        d = {}
        """2Q: new pages enter a FIFO (A1in); a page referenced again after leaving it (found in the ghost queue A1out) goes to the main LRU (Am)."""
        def __init__(s, c): s.kin, s.c = max(1, c // 4), c; s.a1, s.am, s.ghost = OrderedDict(), OrderedDict(), OrderedDict()
        def get(s, p, scan=False):
            if p in s.am: s.am.move_to_end(p); return True
            if p in s.a1: return True
            if p in s.ghost:
                del s.ghost[p]
                if len(s.am) >= s.c - s.kin: s.am.popitem(last=False)
                s.am[p] = True; return False
            if len(s.a1) >= s.kin:
                old, _ = s.a1.popitem(last=False); s.ghost[old] = True
                if len(s.ghost) > s.c: s.ghost.popitem(last=False)
            s.a1[p] = True; return False
    
    class Ring(LRU):
        """Scan pages go through a small private ring of frames and never enter the shared pool."""
        def __init__(s, c): super().__init__(c); s.ring = deque(maxlen=8)
        def get(s, p, scan=False):
            if scan:
                hit = p in s.ring
                if not hit: s.ring.append(p)
                return hit
            return super().get(p)
    
    scan_rows = []
    warm, scan, after = trace()
    for name, cls in (("LRU", LRU), ("2Q", TwoQ), ("ring buffer for the scan", Ring)):
        pool = cls(args.pool)
        for x in warm: pool.get(x)
        before = sum(pool.get(x) for x in warm) / len(warm)
        for x in scan: pool.get(x, scan=True)
        resident = sum(1 for i in range(args.hot) if ("h", i) in pool.d or ("h", i) in getattr(pool, "am", {}) or ("h", i) in getattr(pool, "a1", {}))
        scan_rows.append({"policy": name, "hot_hits_before_pct": before * 100, "hot_resident_after_pct": resident / args.hot * 100,
                          "hot_hits_after_pct": sum(pool.get(x) for x in after) / len(after) * 100})
    
    for r in loop:
        if r["c"] in (50, 80, 90, 99, 100): print(f"c={r['c']:>3}  LRU {r['LRU']:5.1f}%  random {r['random']:5.1f}%  Clock {r['Clock']:5.1f}%")
    for r in scan_rows:
        print(f"{r['policy']:<26} hot set: hits before the scan {r['hot_hits_before_pct']:5.1f}%, still resident after it {r['hot_resident_after_pct']:5.1f}%, hits over the next {20 * args.hot:,} references {r['hot_hits_after_pct']:5.1f}%")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"loop": loop, "scan": scan_rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run lru-random-and-clock-on-a.py
    uv run lru-random-and-clock-on-a.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n100 Pages in the loop.
--steps100,000 References per pool size.
--hot50 Part 2: hot pages.
--scan10,000 Part 2: pages in the one-time scan.
--pool100 Part 2: frames in the pool.
--seed0 Random seed.

Our run

Hit rate on a loop over 100 pagesApple M2 · 8 cores · 8 GB
LRURandomClock
Data table
Hit rate on a loop over 100 pages
Frames cLRURandomClock
1000
1000.0010
2000.6790
3003.960
40010.60
50020.20
60032.50
70046.50
80062.60
90080.60
95090.10
99097.90
10099.999.999.9
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

A hot set of 50 pages and a one-time scan of 10,000

A hot set of 50 pages and a one-time scan of 10,000
PolicyHot pages still residentHits over the next 1,000 references
LRU0%95%
2Q50%97.5%
ring buffer for the scan100%100%
Output · uv run lru-random-and-clock-on-a.py
c= 50  LRU   0.0%  random  20.2%  Clock   0.0%
c= 80  LRU   0.0%  random  62.6%  Clock   0.0%
c= 90  LRU   0.0%  random  80.6%  Clock   0.0%
c= 99  LRU   0.0%  random  97.9%  Clock   0.0%
c=100  LRU  99.9%  random  99.9%  Clock  99.9%
LRU                        hot set: hits before the scan 100.0%, still resident after it   0.0%, hits over the next 1,000 references  95.0%
2Q                         hot set: hits before the scan 100.0%, still resident after it  50.0%, hits over the next 1,000 references  97.5%
ring buffer for the scan   hot set: hits before the scan 100.0%, still resident after it 100.0%, hits over the next 1,000 references 100.0%

What we found

On the loop, LRU and Clock got 0% hits up to c = 99 and jumped at c = 100; random rose smoothly, reaching 80.6% at c = 90 as the claim says. After the scan, LRU kept 0% of the hot set, the scan ring all of it (100%), and 2Q only 50%: the hot pages that had not yet been promoted out of 2Q's probation queue were flushed by the scan like any other.


Read it

Every replacement policy is a bet on the access pattern, and each has a pattern that defeats it. LRU's is the loop slightly larger than the pool; its defence against scans has to come from outside the policy, by not admitting scan pages to the shared pool at all, which is what the ring does and what 2Q does only for pages that have proven themselves.