Back to Blog

Understand KV Cache Step by Step

A hands-on walk through KV caching in LLMs: token caching, eviction, LRU, paged blocks, prefix reuse, preemption, scheduling, and a unified engine.

A KV cache stores key/value tensors in memory so a model never has to recompute them for tokens it has already seen. In an LLM this is the difference between a fast response and a painfully slow one. Let us build the idea up step by step, starting from a plain dictionary and ending with a small production-style engine.

Step 1: Caching basics

A cache is just a dictionary: you store an expensive result under a key, and the next time you look it up you return the stored value instead of recomputing it.

cache = {}

def compute_square(x):
    return x * x

def get_value(x):
    if x in cache:
        return cache[x]          # cache hit
    value = compute_square(x)    # cache miss: compute once
    cache[x] = value
    return value

get_value(2)  # computes -> 4
get_value(2)  # cached   -> 4

Step 2: Token (KV) cache

In an LLM the key is the sequence of token ids. For each new token we append it and look up the cache for the full prefix. Lists are not hashable, so we use a tuple as the cache key.

cache = {}

def compute_kv(token_ids):
    return {
        "K": [t * 0.1 for t in token_ids],
        "V": [t * 0.2 for t in token_ids],
    }

def get_kv(token_ids):
    key = tuple(token_ids)       # lists cannot be dict keys
    if key in cache:
        return cache[key]
    value = compute_kv(token_ids)
    cache[key] = value
    return value

get_kv([1, 2, 3])
# {"K": [0.1, 0.2, 0.3], "V": [0.2, 0.4, 0.6]}

Step 3: Eviction

GPU memory is finite. When the cache is full we must evict an entry to make room for a new one. The simplest policy drops the oldest inserted entry.

cache = {}
MAX_SIZE = 3

def compute_kv(token_ids):
    return {
        "K": [t * 0.1 for t in token_ids],
        "V": [t * 0.2 for t in token_ids],
    }

def get_kv(token_ids):
    key = tuple(token_ids)
    if key in cache:
        return cache[key]
    if len(cache) >= MAX_SIZE:          # full: make room first
        cache.pop(next(iter(cache)))   # drop the oldest entry
    cache[key] = compute_kv(token_ids)
    return cache[key]

Step 4: LRU cache

A smarter policy is least-recently-used (LRU): keep track of access order and evict the entry that was used longest ago. An OrderedDict makes this clean.

from collections import OrderedDict

cache = OrderedDict()
MAX_SIZE = 3

def compute_kv(token_ids):
    return {
        "K": [t * 0.1 for t in token_ids],
        "V": [t * 0.2 for t in token_ids],
    }

def get_kv(token_ids):
    key = tuple(token_ids)
    if key in cache:
        cache.move_to_end(key)         # mark as most-recently used
        return cache[key]
    if len(cache) >= MAX_SIZE:
        cache.popitem(last=False)      # evict least-recently used
    cache[key] = compute_kv(token_ids)
    return cache[key]

Step 5: Blocks (PagedAttention)

Instead of caching a whole sequence as one blob, we split memory into fixed-size blocks (pages). Each block holds the K/V for N tokens, and a sequence is stored as a list of block ids. This is the core idea behind PagedAttention.

BLOCK_SIZE = 4
NUM_BLOCKS = 7

memory = [None] * NUM_BLOCKS          # None = free slot
free_list = list(range(NUM_BLOCKS))
sequences = {}                        # seq_id -> [block_id, ...]

def allocate_block(chunk):
    if not free_list:
        return None                   # out of memory
    block_id = free_list.pop(0)
    memory[block_id] = chunk
    return block_id

def free_block(block_id):
    memory[block_id] = None
    free_list.append(block_id)

def store_sequence(seq_id, token_ids):
    blocks = []
    for i in range(0, len(token_ids), BLOCK_SIZE):
        block_id = allocate_block(token_ids[i:i + BLOCK_SIZE])
        if block_id is None:
            return None
        blocks.append(block_id)
    sequences[seq_id] = blocks
    return blocks

def free_sequence(seq_id):
    for block_id in sequences.pop(seq_id, []):
        free_block(block_id)

Step 6: Prefix reuse

Two requests that share a prefix (for example, the same system prompt) can point to the same physical block. We hash a block's token content and return an existing block on a hit, tracking how many sequences reference it.

BLOCK_SIZE = 4
NUM_BLOCKS = 7

memory = {}                 # block_id -> {"tokens", "ref_count"}
free_list = list(range(NUM_BLOCKS))
prefix_cache = {}           # hash(tokens) -> block_id

def alloc_block(tokens):
    h = hash(tuple(tokens))
    if h in prefix_cache:                    # identical block already exists
        block_id = prefix_cache[h]
        memory[block_id]["ref_count"] += 1   # share it
        return block_id
    block_id = free_list.pop(0)
    memory[block_id] = {"tokens": list(tokens), "ref_count": 1}
    prefix_cache[h] = block_id
    return block_id

def release_block(block_id):
    memory[block_id]["ref_count"] -= 1
    if memory[block_id]["ref_count"] == 0:   # no users left
        h = hash(tuple(memory[block_id]["tokens"]))
        prefix_cache.pop(h, None)
        free_list.append(block_id)
        del memory[block_id]

def store_seq(tokens):
    blocks = []
    for i in range(0, len(tokens), BLOCK_SIZE):
        blocks.append(alloc_block(tokens[i:i + BLOCK_SIZE]))
    return blocks

shared = [1, 2, 3, 4]                      # same system prompt for both requests
a = store_seq(shared + [10, 11, 12, 13])
b = store_seq(shared + [20, 21, 22, 23])   # reuses the shared prefix block

for block_id in a:
    release_block(block_id)
for block_id in b:
    release_block(block_id)

Step 7: Preemption

When a high-priority request arrives and there is no free block, we cannot simply refuse it. Instead we preempt a lower-priority running request: save its tokens to CPU memory, free its GPU blocks, serve the new request, and restore it later.

BLOCK_SIZE = 4
NUM_BLOCKS = 6

memory = {}                       # block_id -> tokens
free_list = list(range(NUM_BLOCKS))
cpu_swap = {}                     # req_id -> tokens (saved on CPU)
running = {}                      # req_id -> {tokens, blocks, priority}

def blocks_needed(tokens):
    return -(-len(tokens) // BLOCK_SIZE)   # ceil division

def alloc_block(chunk):
    block_id = free_list.pop(0)
    memory[block_id] = chunk
    return block_id

def free_block(block_id):
    memory.pop(block_id, None)
    free_list.append(block_id)

def admit(req_id, tokens, priority):
    if len(free_list) < blocks_needed(tokens):
        return False
    blocks = [alloc_block(tokens[i:i + BLOCK_SIZE])
              for i in range(0, len(tokens), BLOCK_SIZE)]
    running[req_id] = {"tokens": tokens, "blocks": blocks, "priority": priority}
    return True

def preempt_lowest():
    victim = min(running, key=lambda r: running[r]["priority"])
    req = running.pop(victim)
    cpu_swap[victim] = req["tokens"]       # save progress to CPU
    for block_id in req["blocks"]:
        free_block(block_id)

def admit_with_preemption(req_id, tokens, priority):
    while not admit(req_id, tokens, priority):
        if not running:
            raise MemoryError("cannot fit request")
        preempt_lowest()
    return True

def restore(req_id, priority):
    tokens = cpu_swap.pop(req_id)
    admit_with_preemption(req_id, tokens, priority)

admit("req-A", list(range(8)), priority=3)            # low  prio, 2 blocks
admit("req-B", list(range(8)), priority=5)            # mid  prio, 2 blocks
admit("req-C", list(range(8)), priority=7)            # high prio, 2 blocks -> full
admit_with_preemption("req-D", list(range(8)), priority=9)  # preempts req-A

Step 8: Scheduler

The scheduler runs in iterations. Each step it restores swapped requests, admits waiting requests in priority order, and preempts the lowest-priority runner when something more important is still waiting.

import heapq

BLOCK_SIZE = 4
NUM_BLOCKS = 6

free_list = list(range(NUM_BLOCKS))
waiting = []     # max-heap via negative priority: (-priority, req_id, tokens)
running = {}     # req_id -> {blocks, tokens, priority}
swapped = {}     # req_id -> {tokens, priority}

def blocks_needed(tokens):
    return -(-len(tokens) // BLOCK_SIZE)

def try_alloc(tokens):
    n = blocks_needed(tokens)
    if len(free_list) < n:
        return None
    return [free_list.pop(0) for _ in range(n)]

def release(blocks):
    free_list.extend(blocks)

def enqueue(req_id, tokens, priority):
    heapq.heappush(waiting, (-priority, req_id, tokens))

def schedule_step():
    # 1. restore swapped requests first (they already did work)
    for req_id in list(swapped):
        blocks = try_alloc(swapped[req_id]["tokens"])
        if blocks:
            running[req_id] = {"blocks": blocks, **swapped.pop(req_id)}

    # 2. admit waiting requests in priority order
    leftover = []
    while waiting:
        neg_prio, req_id, tokens = heapq.heappop(waiting)
        blocks = try_alloc(tokens)
        if blocks:
            running[req_id] = {"blocks": blocks, "tokens": tokens, "priority": -neg_prio}
        else:
            leftover.append((neg_prio, req_id, tokens))
            break                  # lower-priority requests will not fit either
    for item in leftover:
        heapq.heappush(waiting, item)

    # 3. still waiting? preempt the lowest-priority runner
    if waiting and running:
        victim_id = min(running, key=lambda r: running[r]["priority"])
        victim = running.pop(victim_id)
        release(victim["blocks"])
        swapped[victim_id] = {"tokens": victim["tokens"], "priority": victim["priority"]}

enqueue("A", list(range(8)), priority=3)
enqueue("B", list(range(8)), priority=5)
enqueue("C", list(range(8)), priority=7)
schedule_step()                            # admits C, B, A by priority

enqueue("D", list(range(8)), priority=10)  # urgent request arrives
schedule_step()                            # preempts the lowest-priority runner

Step 9: Pressure loop

Rather than reacting only when memory runs out, a production system watches free-block watermarks continuously and takes graduated action: evict cold prefixes, then stop admitting, then preempt. Each threshold triggers a different response.

class MemoryPressureMonitor:
    HIGH_WATER = 0.40    # above: healthy, admit freely
    LOW_WATER  = 0.20    # below: stop new admissions
    CRIT_WATER = 0.10    # below: start preempting

    def __init__(self, total):
        self.total = total
        self.free = total

    def use(self, n):
        self.free = max(0, self.free - n)

    def release(self, n):
        self.free = min(self.total, self.free + n)

    @property
    def ratio(self):
        return self.free / self.total

    @property
    def level(self):
        r = self.ratio
        if r >= self.HIGH_WATER:
            return "OK"
        if r >= self.LOW_WATER:
            return "SOFT"
        if r >= self.CRIT_WATER:
            return "HARD"
        return "CRITICAL"

    def respond(self):
        if self.level == "OK":
            return "admit freely"
        if self.level == "SOFT":
            self.release(2)      # evict a few cold prefix blocks
            return "evict cold prefixes"
        if self.level == "HARD":
            self.release(4)      # stop admissions + evict aggressively
            return "stop admissions + evict"
        self.release(6)          # preempt running requests
        return "preempt now"

mon = MemoryPressureMonitor(20)
for n in [2, 2, 2, 2, 2, 2, 2, 2, 2]:
    mon.use(n)
    print(f"free={mon.free:2}/{mon.total} [{mon.level}] -> {mon.respond()}")

Step 10: The final system

Now we wire everything into a single engine: a paged block allocator, a prefix cache with reference counting, LRU eviction of cold prefixes, preemption of low-priority requests, and pressure-aware scheduling.

from collections import OrderedDict
import heapq

BLOCK_SIZE = 4
NUM_BLOCKS = 16

class KVCacheEngine:
    """PagedAttention-style KV cache with prefix reuse, LRU eviction,
    preemption, and memory-pressure-aware scheduling."""

    HIGH_WATER = 0.40

    def __init__(self, num_blocks):
        self.total = num_blocks
        self.memory = {}                  # block_id -> {tokens, ref_count}
        self.free_list = list(range(num_blocks))
        self.prefix_lru = OrderedDict()   # hash -> block_id (LRU order)
        self.running = {}
        self.swapped = {}
        self.waiting = []
        self.stats = {"hits": 0, "misses": 0, "evictions": 0, "preemptions": 0}

    @property
    def free_ratio(self):
        return len(self.free_list) / self.total

    def _alloc_raw(self, tokens):
        if not self.free_list:
            return None
        block_id = self.free_list.pop(0)
        self.memory[block_id] = {"tokens": list(tokens), "ref_count": 1}
        return block_id

    def _free_raw(self, block_id):
        del self.memory[block_id]
        self.free_list.append(block_id)

    def get_or_alloc_block(self, tokens):
        h = hash(tuple(tokens))
        if h in self.prefix_lru:                  # prefix hit -> share block
            block_id = self.prefix_lru[h]
            self.prefix_lru.move_to_end(h)        # mark as hot
            self.memory[block_id]["ref_count"] += 1
            self.stats["hits"] += 1
            return block_id
        block_id = self._alloc_raw(tokens)        # miss -> new block
        if block_id is None:
            return None
        self.prefix_lru[h] = block_id
        self.stats["misses"] += 1
        return block_id

    def release_block(self, block_id):
        self.memory[block_id]["ref_count"] -= 1
        if self.memory[block_id]["ref_count"] == 0:
            h = hash(tuple(self.memory[block_id]["tokens"]))
            self.prefix_lru.pop(h, None)
            self._free_raw(block_id)

    def evict_cold_prefix(self):
        """Evict the LRU prefix block that has no active users."""
        for h, block_id in self.prefix_lru.items():
            if self.memory[block_id]["ref_count"] == 1:
                self.prefix_lru.pop(h)
                self._free_raw(block_id)
                self.stats["evictions"] += 1
                return True
        return False

    def alloc_sequence(self, tokens):
        blocks = []
        for i in range(0, len(tokens), BLOCK_SIZE):
            block_id = self.get_or_alloc_block(tokens[i:i + BLOCK_SIZE])
            if block_id is None:
                return None
            blocks.append(block_id)
        return blocks

    def relieve_pressure(self):
        if self.free_ratio >= self.HIGH_WATER:
            return "OK"
        if self.evict_cold_prefix():
            return "EVICTED"
        if self.running:
            victim = min(self.running, key=lambda r: self.running[r]["priority"])
            req = self.running.pop(victim)
            for block_id in req["blocks"]:
                self.release_block(block_id)
            self.swapped[victim] = {"tokens": req["tokens"], "priority": req["priority"]}
            self.stats["preemptions"] += 1
            return f"PREEMPTED {victim}"
        return "OOM"

    def enqueue(self, req_id, tokens, priority=5):
        heapq.heappush(self.waiting, (-priority, req_id, tokens))

    def schedule(self):
        while self.free_ratio < self.HIGH_WATER:
            if self.relieve_pressure() == "OOM":
                break

        for req_id in list(self.swapped):          # restore swapped first
            blocks = self.alloc_sequence(self.swapped[req_id]["tokens"])
            if blocks:
                self.running[req_id] = {"blocks": blocks, **self.swapped.pop(req_id)}

        leftover = []
        while self.waiting:
            neg_prio, req_id, tokens = heapq.heappop(self.waiting)
            blocks = self.alloc_sequence(tokens)
            if blocks:
                self.running[req_id] = {"blocks": blocks, "tokens": tokens, "priority": -neg_prio}
            else:
                leftover.append((neg_prio, req_id, tokens))
                break
        for item in leftover:
            heapq.heappush(self.waiting, item)

    def finish(self, req_id):
        req = self.running.pop(req_id, None)
        if req:
            for block_id in req["blocks"]:
                self.release_block(block_id)

    def status(self):
        s = self.stats
        total = s["hits"] + s["misses"]
        hit_rate = s["hits"] / total if total else 0
        print(f"free={len(self.free_list)}/{self.total} "
              f"running={list(self.running)} swapped={list(self.swapped)} "
              f"waiting={len(self.waiting)}")
        print(f"hits={s['hits']} misses={s['misses']} "
              f"evictions={s['evictions']} preemptions={s['preemptions']} "
              f"hit_rate={hit_rate:.0%}")

engine = KVCacheEngine(NUM_BLOCKS)
shared = list(range(5))

engine.enqueue("A", shared + [10, 11, 12, 13], priority=3)
engine.enqueue("B", shared + [20, 21, 22, 23], priority=6)
engine.enqueue("C", shared + [30, 31, 32, 33], priority=8)
engine.enqueue("D", list(range(16)),          priority=9)  # big request

engine.schedule(); engine.status()
engine.finish("A")
engine.schedule(); engine.status()
engine.finish("B"); engine.finish("C")
engine.schedule(); engine.status()

Key takeaways

  • A KV cache trades memory for compute by reusing already-computed key/value tensors.
  • Paging into fixed-size blocks avoids fragmentation and enables prefix sharing.
  • Reference counting lets multiple requests safely share the same prefix blocks.
  • LRU eviction, preemption, and watermark-based pressure handling keep the system stable under load.