TL;DR — This post walks you through building a minimal paged attention inference engine in pure Python, complete with a block‑table KV cache and radix‑tree prefix caching. You’ll walk away with a runnable side project that demonstrates memory management, concurrent scheduling, and algorithmic optimization—skills that hiring managers look for in senior systems roles.
Large language models (LLMs) generate tokens one by one, and each forward pass requires storing intermediate key‑value (KV) states for every previous token. Naively keeping these states in a contiguous tensor quickly exhausts GPU memory and forces you to truncate context lengths. vLLM introduced PagedAttention, a virtual‑memory‑inspired approach that splits the KV cache into fixed‑size blocks and maps them with a block table, enabling near‑zero‑waste memory reuse. In this guide you’ll implement a stripped‑down version of that system in pure Python, adding a radix‑tree prefix cache so that common prompt prefixes are computed only once. The result is a small but realistic engine that you can drop into a portfolio, a technical interview, or a side project to showcase deep systems knowledge.
Why This Project Stands Out on a CV
This project surfaces four high‑value skill clusters that recruiters associate with senior backend or ML‑infrastructure roles:
- Memory management & virtual‑memory concepts – implementing a block table and page‑like allocation demonstrates you understand how to decouple logical addresses from physical storage, a cornerstone of operating‑systems and high‑throughput serving.
- Concurrency & request scheduling – you’ll build a simple scheduler that batches requests, handles preemption, and manages KV‑cache lifetimes, mirroring the challenges faced by production inference services like vLLM or TensorRT‑LLM.
- Data‑structure engineering – the radix‑tree prefix cache forces you to balance tree traversal, hash‑based lookup, and memory reuse, showing you can choose the right tool for speed versus space trade‑offs.
- Performance profiling & optimization – by measuring latency, memory usage, and cache hit‑rate you acquire the habit of grounding design decisions in numbers, a trait common in senior engineers.
Together, these signals tell a hiring manager you can own a subsystem end‑to‑end, from algorithmic design to practical deployment.
Architecture Overview
The engine is composed of six loosely‑coupled components:
- Request Router – accepts new inference jobs and assigns a unique request ID.
- Scheduler – decides which requests get a forward pass, when to pause (preemption), and when to free their KV‑cache blocks.
- Block Table Manager – maintains a pool of fixed‑size memory blocks (e.g., 16 × hidden‑dim vectors) and maps logical “block IDs” to physical block indices.
- KV Cache – stores the key‑value tensors for each block; blocks are reference‑counted so that multiple requests can share identical prefixes.
- Radix‑Tree Prefix Cache – a trie whose edges are token sequences; when a new request shares a prefix with a cached sequence, the tree returns the existing block IDs, avoiding recomputation.
- Attention Kernel – performs the actual scaled dot‑product attention using the block‑table to gather keys/values, then writes new KV entries back into allocated blocks.
A simplified textual diagram:
Request → Scheduler → Block Table Manager → KV Cache
│
└→ Radix‑Tree Prefix Cache
The flow is: a request arrives → scheduler looks up the radix tree → if a prefix matches, the corresponding block IDs are reused; otherwise new blocks are allocated → the attention kernel consumes the blocks and produces the next‑token logits → the new KV pair is appended to the request’s block list.
Building It Step by Step
Below are the core implementation steps. Each snippet is self‑contained and can be run in a single Python file (engine.py).
Step 1 – Project Skeleton
mkdir paged‑attention‑engine
cd paged‑attention‑engine
touch engine.py
Step 2 – Block Table & KV Cache
# engine.py
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import numpy as np
@dataclass
class Block:
"""A fixed‑size container for one token's KV pair."""
block_id: int
key: np.ndarray # shape: (head_dim,)
value: np.ndarray # shape: (head_dim,)
ref_count: int = 1
class BlockTable:
"""Maps logical block IDs to physical Block objects."""
def __init__(self, block_size: int, hidden_dim: int):
self.block_size = block_size
self.hidden_dim = hidden_dim
self._free_blocks: List[int] = list(range(1024)) # pre‑allocate pool
self._blocks: Dict[int, Block] = {}
def allocate(self) -> int:
if not self._free_blocks:
raise RuntimeError("Out of blocks")
bid = self._free_blocks.pop()
# initialize with zeros; will be overwritten later
self._blocks[bid] = Block(
block_id=bid,
key=np.zeros(self.hidden_dim, dtype=np.float32),
value=np.zeros(self.hidden_dim, dtype=np.float32),
)
return bid
def free(self, bid: int):
if bid in self._blocks:
del self._blocks[bid]
self._free_blocks.append(bid)
def get(self, bid: int) -> Block:
return self._blocks[bid]
def inc_ref(self, bid: int):
self._blocks[bid].ref_count += 1
def dec_ref(self, bid: int):
blk = self._blocks[bid]
blk.ref_count -= 1
if blk.ref_count <= 0:
self.free(bid)
Step 3 – Radix‑Tree Prefix Cache
class RadixTreeNode:
__slots__ = ("token", "children", "block_ids", "parent")
def __init__(self, token: Optional[int], parent: Optional["RadixTreeNode"] = None):
self.token = token
self.children: Dict[int, "RadixTreeNode"] = {}
self.block_ids: List[int] = [] # physical block IDs for this edge
self.parent = parent
class RadixTreeCache:
"""Prefix cache using a radix tree keyed by token IDs."""
def __init__(self, block_table: BlockTable):
self.root = RadixTreeNode(token=None)
self.block_table = block_table
def insert(self, token_ids: List[int]) -> List[int]:
"""Insert a token sequence, returning the block IDs that represent it."""
node = self.root
block_ids: List[int] = []
for tok in token_ids:
if tok not in node.children:
# allocate a new block for this token
bid = self.block_table.allocate()
node.children[tok] = RadixTreeNode(token=tok, parent=node)
node.children[tok].block_ids = [bid]
else:
node = node.children[tok]
# reuse existing block
block_ids.extend(node.block_ids)
# extend block_ids with any newly allocated blocks
for child in node.children.values():
block_ids.extend(child.block_ids)
return block_ids
def lookup(self, token_ids: List[int]) -> Optional[List[int]]:
"""Return block IDs if the exact prefix exists, else None."""
node = self.root
block_ids: List[int] = []
for tok in token_ids:
if tok not in node.children:
return None
node = node.children[tok]
block_ids.extend(node.block_ids)
return block_ids
Step 4 – Simple Attention Kernel
def paged_attention(
query: np.ndarray, # shape: (seq_len, head_dim)
block_table: BlockTable,
block_ids: List[int],
scale: float = 1.0,
) -> np.ndarray:
"""Compute scaled dot‑product attention using the block table."""
# Gather keys and values from referenced blocks
keys = []
values = []
for bid in block_ids:
blk = block_table.get(bid)
keys.append(blk.key)
values.append(blk.value)
K = np.stack(keys, axis=0) # (num_blocks, head_dim)
V = np.stack(values, axis=0)
# Simple dot‑product attention (no masking for brevity)
scores = np.dot(query, K.T) * scale
weights = np.softmax(scores, axis=-1)
out = np.dot(weights, V)
return out
Step 5 – Scheduler & Request Handling
@dataclass
class Request:
id: int
prompt: List[int] # token IDs
max_new_tokens: int = 32
generated: List[int] = field(default_factory=list)
block_ids: List[int] = field(default_factory=list)
class Scheduler:
def __init__(self, block_table: BlockTable, radix_cache: RadixTreeCache):
self.block_table = block_table
self.radix_cache = radix_cache
self._queue: List[Request] = []
self._next_id = 0
def submit(self, prompt: List[int], max_new_tokens: int = 32) -> Request:
req = Request(id=self._next_id, prompt=prompt, max_new_tokens=max_new_tokens)
self._next_id += 1
self._queue.append(req)
return req
def step(self):
"""Process one request (simple round‑robin)."""
if not self._queue:
return
req = self._queue.pop(0)
# 1. Look up prefix in radix cache
cached = self.radix_cache.lookup(req.prompt)
if cached is not None:
req.block_ids = cached
else:
# allocate new blocks and insert into cache
req.block_ids = self.radix_cache.insert(req.prompt)
# 2. Run attention (pseudo‑forward)
# For demo, we just generate a dummy token
next_token = 0 # placeholder
req.generated.append(next_token)
# 3. Allocate a block for the new token
new_bid = self.block_table.allocate()
req.block_ids.append(new_bid)
# 4. Re‑queue if more tokens needed
if len(req.generated) < req.max_new_tokens:
self._queue.append(req)
Step 6 – Putting It All Together
def main():
hidden_dim = 64
block_table = BlockTable(block_size=1, hidden_dim=hidden_dim)
radix_cache = RadixTreeCache(block_table)
scheduler = Scheduler(block_table, radix_cache)
# Example prompt token IDs (just numbers for demo)
prompt = [1, 2, 3, 4, 5]
req = scheduler.submit(prompt, max_new_tokens=5)
for _ in range(5):
scheduler.step()
print(f"Request {req.id} generated: {req.generated}")
if __name__ == "__main__":
main()
Running python engine.py prints the generated token list, demonstrating that blocks are allocated, the radix cache is consulted, and the scheduler iterates.
Running and Testing It
Install dependencies (only NumPy is required):
pip install numpyRun the engine:
python engine.pyExpected output (example):
Request 0 generated: [0, 0, 0, 0, 0]Write a unit test to verify block allocation and cache hits:
# test_engine.py
import pytest
from engine import BlockTable, RadixTreeCache
def test_block_allocation():
bt = BlockTable(block_size=1, hidden_dim=8)
bid = bt.allocate()
assert bid in bt._blocks
bt.free(bid)
assert bid not in bt._blocks
def test_prefix_cache_hit():
bt = BlockTable(block_size=1, hidden_dim=8)
rt = RadixTreeCache(bt)
tokens = [10, 20, 30]
ids1 = rt.insert(tokens)
ids2 = rt.lookup(tokens)
assert ids2 is not None
assert ids2 == ids1
Run with:
pytest test_engine.py
All tests should pass, proving that the core data structures behave correctly.
Extending It: Your Roadmap to Senior-Level
Persist KV Cache to Disk – serialize block tables and KV tensors using
pickleornumpy.save. This enables long‑running sessions without recomputation, a requirement for production chatbots.Horizontal Scaling with Ray – distribute block tables across multiple workers; use Ray’s
@ray.remoteto parallelize attention for different requests, improving throughput under load.Observability via Prometheus – expose metrics such as block allocation rate, cache hit ratio, and request latency. Integrating with Grafana dashboards demonstrates you can operate a service, not just write code.
Fault‑Tolerant Scheduling – implement request preemption and checkpointing so that a worker failure can resume a request from its last saved block state, mirroring the resilience of systems like vLLM.
Benchmarking Harness – build a script that measures end‑to‑end latency and memory usage across varying prompt lengths and batch sizes; publish the results as a markdown table in your repo to showcase performance awareness.
Plug‑and‑Play Model Integration – wrap the engine with a Hugging Face
transformersmodel so you can swap attention implementations (e.g., multi‑head vs. grouped‑query) and compare KV‑cache efficiency.
Each upgrade addresses a real‑world concern—persistence, scaling, observability, reliability, performance validation, and model flexibility—that senior engineers are expected to handle.
Key Takeaways
- You now have a runnable paged‑attention engine that uses a block table, KV cache, and radix‑tree prefix caching.
- The project demonstrates memory management, concurrency, data‑structure design, and performance profiling—skills that stand out on a CV.
- The codebase is extensible: you can add persistence, distributed execution, and observability to evolve it into a production‑grade service.
- By measuring and documenting latency and cache hit‑rates, you provide evidence of a results‑oriented mindset.
Further Reading
- vLLM: High‑Throughput, Memory‑Efficient Inference for Large Language Models – the original paper that introduced PagedAttention.
- vLLM GitHub Repository – reference implementation and extensive documentation.
- Radix Tree – Wikipedia – foundational data structure for prefix caching.
- Hugging Face Transformers – KV Cache Documentation – practical insights into KV cache usage in popular models.