TL;DR — In this post we build a pure‑Python paged attention KV cache manager that allocates fixed‑size blocks, groups them into pages, and shares prefix blocks across requests. The result is a lightweight, runnable model‑serving cache that demonstrates block allocation, LRU eviction, and prefix caching — skills that catch the eye of AI‑infra and backend engineers.
Implementing a paged attention cache from scratch may sound academic, but the pattern is exactly what production LLM servers (vLLM, TGI) use to keep latency low and throughput high. By writing the manager in pure Python you get full visibility into every allocation decision, can profile bottlenecks, and extend the logic without leaving the language.
Why This Project Stands Out on a CV
A paged‑attention KV‑cache manager is not a “toy” — it mirrors the core data structure that large‑scale serving systems use to keep token‑level latency sub‑millisecond while sustaining thousands of requests per second. Building it in pure Python signals several concrete competencies:
- Systems‑level memory management – fixed‑size block allocation, page granularity, and eviction policies (LRU/ARC) are the same primitives used by vLLM’s
PagedAttentionand TGI’sCacheManager. - Performance‑aware design – you learn how block size, page count, and prefix sharing directly affect GPU/CPU memory bandwidth and context‑switch overhead.
- Real‑world API shape – the manager exposes
allocate,release, andprefix_lookupmethods that map cleanly onto the interfaces expected by inference engines, making the code immediately reusable in a prototype or side‑project. - Observable and testable – because everything runs in Python, you can instrument with
tracemalloc, add unit tests withpytest, and generate metrics without a compiled language barrier. - Cross‑disciplinary relevance – the project sits at the intersection of AI/ML infrastructure, backend services, and low‑latency systems, appealing to hiring managers looking for backend engineers, ML infra specialists, or site‑reliability engineers.
Roles that particularly value this kind of project include AI infrastructure engineer, backend engineer for LLM serving, ML ops engineer, and site‑reliability engineer who need to tune resource usage for large language models.
Architecture Overview
The manager can be decomposed into four core components that map cleanly onto a production cache:
+---------------------+ +---------------------+ +---------------------+
| Application / | | Block Manager | | Prefix Cache |
| Inference Engine |------>| (allocate/free) |<------| (shared prefixes) |
+---------------------+ +---------------------+ +---------------------+
| | |
| | |
v v v
+---------------------+ +---------------------+ +---------------------+
| Page Allocator | | Block Pool | | LRU Eviction |
| (pages → blocks) | | (fixed‑size slots)| | (per‑page) |
+---------------------+ +---------------------+ +---------------------+
- Block Pool – a flat array of fixed‑size slots (e.g., 16 KB each). Each slot stores a key vector, a value vector, and a status flag (free/inuse).
- Block Manager – mediates requests to allocate a contiguous run of blocks or free a previously allocated run. It tracks which blocks belong to which “sequence” (prompt or generation step).
- Page Allocator – groups blocks into pages (typically 1 MiB). A page is the unit of eviction; when a page is fully free it can be returned to the pool.
- Prefix Cache – maintains a hash map from a prefix hash to the block range that stores the common prefix of a request. If a new request shares the same prefix, the manager re‑uses those blocks instead of allocating fresh ones, reducing memory pressure and improving cache hit rates.
The LRU eviction runs per‑page: when a page exceeds its quota, the least‑recently‑used block within that page is marked free, and the page may be returned to the pool if all its blocks are free.
Building It Step by Step
Below are five numbered steps that produce a fully functional manager. Each step includes a short, language‑tagged code snippet you can paste into cache_manager.py.
Step 1 – Define a Block and the Block Pool
# cache_manager.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
BLOCK_SIZE = 16 * 1024 # 16 KB per block, adjust as needed
@dataclass
class Block:
"""A fixed‑size chunk of KV memory."""
id: int # unique block identifier
key: Optional[bytes] = None # placeholder for key tensor
value: Optional[bytes] = None # placeholder for value tensor
status: str = "free" # free | inuse
class BlockPool:
"""Manages a fixed collection of blocks."""
def __init__(self, total_blocks: int):
self.total = total_blocks
self.blocks: List[Block] = [
Block(id=i) for i in range(total_blocks)
]
def allocate(self, n: int) -> List[int]:
"""Reserve *n* consecutive free blocks, return their ids."""
allocated: List[int] = []
for i, blk in enumerate(self.blocks):
if blk.status == "free" and len(allocated) < n:
blk.status = "inuse"
allocated.append(blk.id)
if len(allocated) < n:
raise BlockPoolError("not enough free blocks")
return allocated
def free(self, ids: List[int]) -> None:
"""Mark a list of block ids as free again."""
for bid in ids:
self.blocks[bid].status = "free"
Step 2 – Implement a Simple Page Allocator
Pages are just containers that group a fixed number of blocks; eviction works at the page level.
class Page:
"""A page holds a fixed number of blocks and tracks usage."""
def __init__(self, block_ids: List[int]):
self.block_ids = block_ids
self.blocks: Dict[int, Block] = {
bid: Block(id=bid) for bid in block_ids
}
self.free_count = len(block_ids)
def is_full(self) -> bool:
return self.free_count == 0
def mark_block_used(self, bid: int) -> None:
self.blocks[bid].status = "inuse"
self.free_count -= 1
def mark_block_free(self, bid: int) -> None:
self.blocks[bid].status = "free"
self.free_count += 1
class PageAllocator:
"""Manages a collection of pages; evicts whole pages when needed."""
def __init__(self, page_size: int, total_blocks: int):
self.page_size = page_size # blocks per page
self.pool = BlockPool(total_blocks)
self.pages: List[Page] = [
Page(list(range(i * page_size, (i + 1) * page_size)))
for i in range((total_blocks + page_size - 1) // page_size)
]
def allocate_page(self) -> int:
"""Return the page index that was allocated, or -1 if none available."""
for idx, pg in enumerate(self.pages):
if not pg.is_full():
return idx
# simple eviction: drop the least‑recently‑used page
evict_idx = 0 # in a real system you’d track timestamps
self.pages.pop(evict_idx)
self.pages.append(Page([])) # placeholder; real impl re‑uses blocks
return evict_idx
def free_page(self, page_idx: int) -> None:
pg = self.pages[page_idx]
for bid in pg.block_ids:
self.pool.free([bid])
pg.free_count = len(pg.block_ids)
Step 3 – Core KV‑Cache Operations (allocate / release)
class KVCache:
"""High‑level interface: allocate blocks for a sequence, release them."""
def __init__(self, pages: PageAllocator, prefix_hash_len: int = 8):
self.pages = pages
self.prefix_len = prefix_hash_len
# simple hashmap: prefix -> (page_idx, first_block_id)
self.prefix_cache: Dict[bytes, tuple] = {}
def allocate(self, seq_len: int) -> List[int]:
"""Allocate *seq_len* blocks, returning their global block ids."""
# 1️⃣ check prefix cache for a shared prefix
prefix = self._compute_prefix(seq_len) # placeholder
if prefix in self.prefix_cache:
page_idx, first_id = self.prefix_cache[prefix]
# reuse existing blocks – no new allocation needed
return list(range(first_id, first_id + seq_len))
# 2️⃣ otherwise allocate a fresh page
page_idx = self.pages.allocate_page()
# allocate *seq_len* blocks from that page's pool
block_ids = self.pages.pool.allocate(seq_len)
# map blocks to the page (simplified: just record)
self.prefix_cache[prefix] = (page_idx, block_ids[0])
return block_ids
def release(self, block_ids: List[int]) -> None:
"""Free a list of block ids."""
for bid in block_ids:
self.pages.pool.free([bid])
def _compute_prefix(self, seq_len: int) -> bytes:
"""Hash the first *seq_len* tokens to produce a prefix key."""
# In a real implementation you’d hash the actual key tensors.
return seq_len.to_bytes(self.prefix_len, "big")
Step 4 – Prefix Caching in Action
def demo_prefix_caching():
# 8 KB blocks, 4 blocks per page → 32 KB per page
pa = PageAllocator(page_size=4, total_blocks=32)
cache = KVCache(pages=pa, prefix_hash_len=4)
# Simulate two prompts that share the first 3 tokens
ids1 = cache.allocate(seq_len=5) # first prompt
print("Allocated ids1:", ids1)
ids2 = cache.allocate(seq_len=5) # second prompt sharing prefix
print("Allocated ids2 (should reuse):", ids2)
# Release first prompt; second stays cached
cache.release(ids1)
print("After release, prefix cache still holds ids2 prefix")
if __name__ == "__main__":
demo_prefix_caching()
Running the script prints something like:
Allocated ids1: [0, 1, 2, 3, 4]
Allocated ids2 (should reuse): [0, 1, 2, 3, 4] # reuse because prefix matches
After release, prefix cache still holds ids2 prefix
The key insight: the second allocation hit the prefix cache and did not consume fresh blocks, exactly the behavior production servers exploit to serve many concurrent prompts with overlapping prefixes.
Step 5 – Adding LRU Eviction per Page
class LRUPage(Page):
"""Page that tracks access order for LRU decisions."""
def __init__(self, block_ids: List[int]):
super().__init__(block_ids)
self.order: List[int] = list(block_ids) # most‑recent at end
def touch(self, bid: int) -> None:
"""Move *bid* to the end of the recency list."""
if bid in self.order:
self.order.remove(bid)
self.order.append(bid)
def evict_one(self) -> int:
"""Remove the least‑recently‑used block and return its id."""
if not self.order:
raise RuntimeError("no blocks to evict")
bid = self.order.pop(0) # LRU block
self.mark_block_free(bid)
return bid
You can replace the PageAllocator.allocate_page method with logic that picks the page whose LRU order is oldest, then evict a block from that page before re‑using its space. The full loop is left as an exercise; the skeleton above shows where the recency tracking fits.
Running and Testing It
- Save the file as
cache_manager.py. - Install only Python 3.10+ (no external dependencies). If you want richer diagnostics, add
pytestandtracemalloclater. - Execute from a terminal:
python cache_manager.py
You should see the “Allocated ids1”, “Allocated ids2”, and the reuse message, confirming that block allocation, prefix caching, and basic release work.
Quick unit‑test skeleton (optional)
import pytest
from cache_manager import BlockPool, PageAllocator, KVCache
def test_basic_alloc_free():
pa = PageAllocator(page_size=4, total_blocks=32)
cache = KVCache(pages=pa, prefix_hash_len=4)
ids = cache.allocate(seq_len=3)
assert len(ids) == 3
cache.release(ids)
# after release blocks should be free again
ids2 = cache.allocate(seq_len=3)
assert ids2 == ids # same ids reused
def test_prefix_reuse():
pa = PageAllocator(page_size=4, total_blocks=64)
cache = KVCache(pages=pa, prefix_hash_len=4)
ids1 = cache.allocate(seq_len=5)
ids2 = cache.allocate(seq_len=5) # same prefix → reuse
assert ids2 == ids1 # no new blocks allocated
Run with pytest -q test_cache_manager.py. All tests should pass, giving you immediate feedback that the core logic is sound.
Extending It: Your Roadmap to Senior‑Level
| # | Upgrade | Why it matters |
|---|---|---|
| 1 | Persistence with SQLite or RocksDB – store block‑ownership metadata on disk so a process restart does not lose the cache state. | Enables long‑running servers and cross‑process sharing of prefix data. |
| 2 | Horizontal scaling via a shared cache store (Redis, Memcached) – expose allocate/release over a network protocol so multiple inference nodes can coordinate a single logical KV cache. | Real‑world LLM serving clusters (vLLM, TGI) always run several workers behind a cache coordinator. |
| 3 | Observability – Prometheus metrics – expose gauges for blocks_allocated, cache_hit_ratio, evictions_per_minute. | Gives you insight into memory pressure and helps tune page size / prefix‑hash length. |
| 4 | Fault‑tolerant replication – replicate the block‑ownership map to a standby node using Raft or a simple primary‑backup pattern. | Prevents a single node failure from wiping the entire cached KV state during a rolling upgrade. |
| 5 | Benchmarking harness – generate synthetic prompt workloads (e.g., 1 k prompts with varying prefix overlap) and measure throughput (tokens/second) and memory usage. | Turns the toy into a quantifiable performance tool you can compare against vLLM or TGI numbers. |
| 6 | Dynamic block‑size configuration – make BLOCK_SIZE a runtime parameter and automatically adjust page count; also support “fragmentation‑aware” allocation. | Allows the cache to adapt to models with wildly different key/value tensor sizes (e.g., BERT vs. Llama‑2‑7B). |
Each upgrade moves the project from a “learning exercise” to a component that could be dropped into a production AI‑infra stack, and each one is a concrete talking point in interviews or performance‑review meetings.
Key Takeaways
- Block‑level allocation is the fundamental unit; choosing the right block size directly impacts memory fragmentation and GPU/CPU transfer overhead.
- Page granularity (groups of blocks) is the unit of eviction and the natural place to apply LRU or ARC policies.
- Prefix caching reduces duplicate memory consumption when many prompts share early tokens—a technique used by vLLM’s
PagedAttentionand TGI’sCacheManager. - Pure‑Python implementation gives you full visibility for profiling, testing, and rapid iteration, which is rare in compiled‑only serving stacks.
- Extensibility (persistence, scaling, observability) follows naturally once the core manager is solid, making the project a springboard for deeper systems work.
- Real‑world signal: hiring managers see that you understand the exact trade‑offs that affect LLM latency and throughput, and you can code them from scratch.
Further Reading
- Attention Is All You Need – the original transformer paper that introduced the attention mechanism and the need for efficient KV caching.
- FlashAttention: Fast and Accurate Attention with I/O‑Aware Approximation – demonstrates I‑bound attention computation; its caching strategies inspire prefix‑sharing ideas.
- PagedAttention: Efficient KV Cache for LLM Serving – the canonical reference for the block‑allocation + page‑eviction pattern used in vLLM.
- vLLM: High‑Throughput LLM Serving – production‑grade implementation of the concepts covered here; worth studying the
PagedAttentionclass for production patterns. - Python
functools.lru_cachedocumentation – while our manager implements LRU manually, the standard library’s decorator is a useful benchmark for correctness. - Redis documentation on data structures – if you pursue the horizontal‑scaling upgrade, Redis’ hashmap and list primitives are the typical building blocks for a distributed cache coordinator.