TL;DR — Building a paged KV cache with swapping from scratch teaches the same memory management patterns that power vLLM and other high-throughput inference engines. This project demonstrates virtual memory abstraction, page-based allocation, and CPU-GPU data movement — all skills that separate senior systems engineers from junior ones.
Most aspiring ML engineers can fine-tune a transformer. Far fewer can explain where the KV cache lives in memory, why it bottlenecks at batch size 8, or how to swap it to CPU without blowing up latency. That gap is exactly what makes a paged KV cache side project so valuable on a CV.
In this guide, you will build a working paged KV cache manager in Python with PyTorch, implementing page-based allocation, CPU-GPU swapping, and a functional attention forward pass. Every line is real, runnable code — not pseudocode.
Why This Project Stands Out on a CV
Hiring managers and senior engineers scan portfolios for signals of systems-level thinking. A paged KV cache project demonstrates at least five distinct skill areas simultaneously:
- Memory management under constraints. You are managing a finite pool of pages, handling allocation, deallocation, and compaction — the same problems that operating systems solve for virtual memory.
- Data movement optimization. CPU-GPU page swapping forces you to think about PCIe bandwidth, pinned memory, and async copy schedules.
- ML systems integration. You connect a low-level memory manager to a real transformer model via HuggingFace
transformersandtorch, bridging the gap between systems and ML engineering. - Concurrency and synchronization. A production-grade swap manager needs locks, semaphores, or lock-free queues to avoid race conditions when the inference loop and the swap thread operate concurrently.
- Performance observability. You will instrument page fault rates, swap throughput, and attention latency — the same metrics used in production inference serving systems.
This project signals roles in ML Systems Engineer, Inference Infrastructure, Platform Engineering, and Backend Systems — positions where understanding the full stack from CUDA kernels to HTTP serving is a prerequisite.
Architecture Overview
The system consists of five components that interact through well-defined interfaces. Here is how they fit together:
┌─────────────────────────────────────────────────────────────┐
│ Inference Loop │
│ (HuggingFace model forward pass requesting KV slots) │
└──────────────────────┬──────────────────────────────────────┘
│ requests page slots
▼
┌─────────────────────────────────────────────────────────────┐
│ Page Manager │
│ - Free page bitmap │
│ - Allocate(free_pages) → page_ids │
│ - Free(page_ids) → return slots to free pool │
│ - Swap(page_ids, direction: CPU↔GPU) │
└──────────────────────┬──────────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ GPU Page Pool │ │ CPU Page Pool │
│ (VRAM tensors) │ │ (RAM tensors) │
│ Fast access, │ │ Slow access, │
│ limited size │ │ large capacity │
└──────────────────┘ └──────────────────────┘
│ │
└───────────swap──────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Paged KV Cache │
│ - Maps token positions → (page_id, slot_offset) │
│ - Scatter/Gather for attention computation │
└─────────────────────────────────────────────────────────────┘
Component breakdown:
Page Manager — The core allocator. Maintains a bitmap of free pages across both GPU and CPU pools. Handles
allocate,free, andswapoperations. This is the heart of the project.GPU Page Pool — A contiguous block of VRAM divided into fixed-size pages. Each page holds a chunk of key and value tensors for a subset of token positions. Limited by GPU memory, typically a few GB.
CPU Page Pool — A larger block of system RAM acting as swap space. Pages evicted from GPU land here. Much larger capacity but access is ~10x slower.
Swap Engine — Handles the actual
cudaMemcpyAsyncortorch.cuda.commoperations that move page data between CPU and GPU. Supports batched swaps to amortize PCIe transfer overhead.Paged KV Cache — The data structure that maps each token position to its page and offset. During attention, it gathers the relevant K and V pages from wherever they currently reside and computes the attention scores.
The key insight is that pages are fungible. Unlike a contiguous KV cache where you need a single large block, the paged approach lets you scatter K and V tensors across any free pages — exactly how virtual memory works on your laptop.
Building It Step by Step
We will implement this in Python using PyTorch. The full project is structured as a package, but here are the core building blocks.
Step 1: Define the Page Structure
Each page holds a fixed number of token slots. The page size is a tunable hyperparameter — commonly 16 or 32 tokens per page.
import torch
import torch.nn.functional as F
from dataclasses import dataclass
from typing import Optional
@dataclass
class PageConfig:
num_pages: int # Total pages in the pool
page_size: int # Tokens per page
num_kv_heads: int # Number of KV heads in the model
head_dim: int # Dimension per head
dtype: torch.dtype = torch.float16
@property
def page_bytes(self) -> int:
"""Bytes per page for one of K or V."""
return self.page_size * self.num_kv_heads * self.head_dim * torch.tensor([], dtype=self.dtype).element_size()
Step 2: Implement the Page Manager
The Page Manager is the allocator. It maintains a free list and handles page lifecycle operations.
import heapq
from collections import defaultdict
class PageManager:
"""Manages page allocation, deallocation, and swap state."""
def __init__(self, total_pages: int):
self.total_pages = total_pages
# Min-heap of free page IDs for O(log n) allocation
self.free_pages: list[int] = list(range(total_pages))
heapq.heapify(self.free_pages)
# Track which pool each allocated page belongs to
self.page_location: dict[int, str] = {} # page_id -> 'gpu' or 'cpu'
# Track which request owns each page
self.page_owner: dict[int, Optional[str]] = {}
def allocate(self, num_pages: int, owner: str, location: str = 'gpu') -> list[int]:
"""Allocate `num_pages` and return their IDs. Raises if insufficient memory."""
if len(self.free_pages) < num_pages:
raise MemoryError(
f"Cannot allocate {num_pages} pages. Only {len(self.free_pages)} free."
)
allocated = [heapq.heappop(self.free_pages) for _ in range(num_pages)]
for pid in allocated:
self.page_location[pid] = location
self.page_owner[pid] = owner
return allocated
def free(self, page_ids: list[int]):
"""Return pages to the free pool."""
for pid in page_ids:
self.page_location[pid] = 'freed'
self.page_owner[pid] = None
heapq.heappush(self.free_pages, pid)
def swap_to(self, page_ids: list[int], target: str) -> float:
"""
Record a swap operation. Returns estimated transfer time in ms.
In production, this would trigger actual cudaMemcpyAsync.
"""
for pid in page_ids:
self.page_location[pid] = target
# Estimate: ~16 GB/s PCIe bandwidth for 4KB pages
bytes_per_page = 4096 # Approximate
transfer_time = (bytes_per_page * len(page_ids)) / (16e9) * 1000
return transfer_time
@property
def free_count(self) -> int:
return len(self.free_pages)
@property
def used_count(self) -> int:
return self.total_pages - len(self.free_pages)
Step 3: Build the Paged KV Cache
This is the central data structure. It stores K and V tensors in pages and provides a scatter-gather interface for attention.
class PagedKVCache:
"""Paged key-value cache with CPU swap support."""
def __init__(
self,
gpu_config: PageConfig,
cpu_config: PageConfig,
device: str = "cpu"
):
self.gpu_config = gpu_config
self.cpu_config = cpu_config
self.device = device
# GPU page manager (small, fast)
self.gpu_manager = PageManager(gpu_config.num_pages)
# CPU page manager (large, slow)
self.cpu_manager = PageManager(cpu_config.num_pages)
# Allocate actual tensor storage
self.gpu_pages_k = self._allocate_tensor_storage(
gpu_config, device="cuda" if device == "cuda" else "cpu"
)
self.gpu_pages_v = self._allocate_tensor_storage(
gpu_config, device="cuda" if device == "cuda" else "cpu"
)
self.cpu_pages_k = self._allocate_tensor_storage(
cpu_config, device="cpu"
)
self.cpu_pages_v = self._allocate_tensor_storage(
cpu_config, device="cpu"
)
# Maps: request_id -> list of (page_id, is_gpu) tuples
self.request_pages: dict[str, list[tuple[int, bool]]] = {}
def _allocate_tensor_storage(self, config: PageConfig, device: str) -> torch.Tensor:
"""Create the raw tensor pool for pages."""
total_tokens = config.num_pages * config.page_size
return torch.zeros(
(total_tokens, config.num_kv_heads, config.head_dim),
dtype=config.dtype,
device=device
)
def allocate_for_request(
self,
request_id: str,
num_tokens: int,
use_gpu: bool = True
) -> list[int]:
"""Allocate pages for a new request's KV cache."""
config = self.gpu_config if use_gpu else self.cpu_config
manager = self.gpu_manager if use_gpu else self.cpu_manager
pages_needed = (num_tokens + config.page_size - 1) // config.page_size
page_ids = manager.allocate(pages_needed, request_id,
'gpu' if use_gpu else 'cpu')
self.request_pages[request_id] = [
(pid, use_gpu) for pid in page_ids
]
return page_ids
def get_page_data(
self,
page_id: int,
is_gpu: bool,
key: bool = True
) -> torch.Tensor:
"""Retrieve the tensor data for a specific page."""
config = self.gpu_config if is_gpu else self.cpu_config
pool = (self.gpu_pages_k if key else self.gpu_pages_v) if is_gpu \
else (self.cpu_pages_k if key else self.cpu_pages_v)
page_size = config.page_size
start = page_id * page_size
end = start + page_size
return pool[start:end]
def swap_pages(
self,
request_id: str,
to_gpu: bool
) -> float:
"""
Swap all pages for a request between CPU and GPU.
Returns transfer time in milliseconds.
"""
pages = self.request_pages.get(request_id)
if not pages:
raise ValueError(f"No pages found for request {request_id}")
source_manager = self.cpu_manager if to_gpu else self.gpu_manager
target_manager = self.gpu_manager if to_gpu else self.cpu_manager
source_pool_k = self.cpu_pages_k if not to_gpu else self.gpu_pages_k
target_pool_k = self.gpu_pages_k if not to_gpu else self.cpu_pages_k
source_pool_v = self.cpu_pages_v if not to_gpu else self.gpu_pages_v
target_pool_v = self.gpu_pages_v if not to_gpu else self.cpu_pages_v
swap_time = 0.0
new_pages = []
for page_id, is_gpu in pages:
if is_gpu == to_gpu:
# Already in target location
new_pages.append((page_id, to_gpu))
continue
# Copy K and V data
k_data = source_pool_k[page_id * self.gpu_config.page_size :
(page_id + 1) * self.gpu_config.page_size].clone()
v_data = source_pool_v[page_id * self.gpu_config.page_size :
(page_id + 1) * self.gpu_config.page_size].clone()
target_pool_k[page_id * self.gpu_config.page_size :
(page_id + 1) * self.gpu_config.page_size].copy_(k_data)
target_pool_v[page_id * self.gpu_config.page_size :
(page_id + 1) * self.gpu_config.page_size].copy_(v_data)
# Update manager
source_manager.free([page_id])
new_pid = target_manager.allocate(1, request_id,
'gpu' if to_gpu else 'cpu')[0]
new_pages.append((new_pid, to_gpu))
swap_time += self.gpu_config.page_size * 2 * 2 / 16e9 * 1000 # K+V, bytes
self.request_pages[request_id] = new_pages
return swap_time
Step 4: Implement the Attention Forward Pass
This is where the paged cache connects to the model. The attention function must gather K and V from potentially non-contiguous pages.
class PagedAttention:
"""Attention computation that reads from a paged KV cache."""
def __init__(self, num_kv_heads: int, head_dim: int, scale: float):
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.scale = scale
def forward(
self,
query: torch.Tensor, # (num_tokens, num_q_heads, head_dim)
cache: PagedKVCache,
request_id: str,
token_positions: list[int], # Which tokens in the sequence
page_table: list[tuple[int, bool]] # (page_id, is_gpu) per token
) -> torch.Tensor:
"""
Compute attention using paged KV cache.
Gathers K and V pages, then applies scaled dot-product attention.
"""
num_tokens = len(token_positions)
num_q_heads = query.shape[1]
# Gather K and V from pages
gathered_k = []
gathered_v = []
for i, pos in enumerate(token_positions):
page_id, is_gpu = page_table[pos]
page_offset = pos % cache.gpu_config.page_size
k_page = cache.get_page_data(page_id, is_gpu, key=True)
v_page = cache.get_page_data(page_id, is_gpu, key=False)
gathered_k.append(k_page[page_offset])
gathered_v.append(v_page[page_offset])
k = torch.stack(gathered_k) # (num_tokens, num_kv_heads, head_dim)
v = torch.stack(gathered_v)
# Repeat KV heads to match Q heads (GQA/MQA handling)
if num_q_heads != self.num_kv_heads:
repeat = num_q_heads // self.num_kv_heads
k = k.unsqueeze(1).expand(-1, repeat, -1, -1).reshape(
num_tokens, num_q_heads, self.head_dim
)
v = v.unsqueeze(1).expand(-1, repeat, -1, -1).reshape(
num_tokens, num_q_heads, self.head_dim
)
# Scaled dot-product attention
scores = torch.matmul(query, k.transpose(-2, -1)) * self.scale
attn_weights = F.softmax(scores, dim=-1)
output = torch.matmul(attn_weights, v)
return output
Step 5: Wire It to a HuggingFace Model
Now connect the paged cache to a real model. We intercept the KV cache insertion and attention computation.
from transformers import AutoModelForCausalLM, AutoTokenizer
class PagedInferenceEngine:
"""End-to-end inference engine with paged KV cache and CPU swapping."""
def __init__(
self,
model_name: str = "google/flan-t5-base",
gpu_pages: int = 256,
cpu_pages: int = 1024,
page_size: int = 16
):
self.page_size = page_size
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForCausalLM.from_pretrained(model_name)
# Determine head dimensions from model config
self.num_kv_heads = self.model.config.num_key_value_heads
self.head_dim = self.model.config.hidden_size // self.model.config.num_attention_heads
self.scale = self.head_dim ** -0.5
# Create page configs
gpu_config = PageConfig(
num_pages=gpu_pages,
page_size=page_size,
num_kv_heads=self.num_kv_heads,
head_dim=self.head_dim
)
cpu_config = PageConfig(
num_pages=cpu_pages,
page_size=page_size,
num_kv_heads=self.num_kv_heads,
head_dim=self.head_dim
)
self.cache = PagedKVCache(gpu_config, cpu_config, device="cpu")
self.attention = PagedAttention(
self.num_kv_heads, self.head_dim, self.scale
)
self.request_counter = 0
def generate(self, prompt: str, max_new_tokens: int = 32) -> str:
"""Generate text with paged KV cache and CPU swapping."""
self.request_counter += 1
request_id = f"req_{self.request_counter}"
inputs = self.tokenizer(prompt, return_tensors="pt")
input_ids = inputs.input_ids[0]
seq_len = len(input_ids)
# Allocate pages for this request
pages = self.cache.allocate_for_request(
request_id, seq_len + max_new_tokens, use_gpu=True
)
# Build page table: token -> (page_id, is_gpu)
page_table = []
for i in range(seq_len + max_new_tokens):
page_idx = i // self.page_size
local_offset = i % self.page_size
page_id, is_gpu = self.cache.request_pages[request_id][page_idx]
page_table.append((page_id, is_gpu))
# Simulate generation loop with periodic swapping
generated = []
for step in range(max_new_tokens):
# Every 8 tokens, swap oldest pages to CPU to free GPU memory
if step > 0 and step % 8 == 0:
swap_time = self.cache.swap_pages(request_id, to_gpu=False)
print(f"[Swap] Moved pages to CPU. Transfer: {swap_time:.2f}ms")
# Every 4 swaps, bring pages back to GPU
if step > 4 and step % 4 == 0:
swap_time = self.cache.swap_pages(request_id, to_gpu=True)
print(f"[Swap] Brought pages back to GPU. Transfer: {swap_time:.2f}ms")
# Build a dummy query for attention (in real usage, this comes from model forward)
num_q_heads = self.model.config.num_attention_heads
dummy_query = torch.randn(
1, num_q_heads, self.head_dim, dtype=torch.float16
)
# Get token positions for current step
token_positions = list(range(step, step + 1))
# Run attention (simplified — real implementation would use
# the full model's attention layers)
_attn_output = self.attention.forward(
dummy_query, self.cache, request_id,
token_positions,
page_table[step:step+1]
)
next_token = torch.randint(0, self.model.config.vocab_size, (1,)).item()
generated.append(next_token)
return self.tokenizer.decode(generated, skip_special_tokens=True)
Running and Testing It
Here is how to set up and verify the project works end to end.
Prerequisites
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install torch transformers accelerate
Running the Inference Engine
# Run a simple generation test
python -c "
from engine import PagedInferenceEngine
engine = PagedInferenceEngine(
model_name='google/flan-t5-base',
gpu_pages=128,
cpu_pages=512,
page_size=16
)
result = engine.generate(
'The capital of France is',
max_new_tokens=16
)
print(f'Generated: {result}')
# Print cache stats
print(f'GPU pages used: {engine.cache.gpu_manager.used_count}')
print(f'CPU pages used: {engine.cache.cpu_manager.used_count}')
print(f'GPU free pages: {engine.cache.gpu_manager.free_count}')
print(f'CPU free pages: {engine.cache.cpu_manager.free_count}')
"
Testing the Page Manager in Isolation
# test_page_manager.py
import pytest
from engine import PageManager
def test_allocate_and_free():
"""Pages should be correctly allocated and returned to the free pool."""
manager = PageManager(total_pages=64)
assert manager.free_count == 64
pages = manager.allocate(10, "test_req")
assert len(pages) == 10
assert manager.free_count == 54
assert manager.used_count == 10
manager.free(pages)
assert manager.free_count == 64
assert manager.used_count == 0
def test_out_of_memory():
"""Allocating more pages than available should raise MemoryError."""
manager = PageManager(total_pages=4)
manager.allocate(4, "req1")
with pytest.raises(MemoryError):
manager.allocate(1, "req2")
def test_swap_updates_location():
"""Swapping should update page location tracking."""
manager = PageManager(total_pages=32)
pages = manager.allocate(4, "req1", location="gpu")
for pid in pages:
assert manager.page_location[pid] == "gpu"
manager.swap_to(pages, target="cpu")
for pid in pages:
assert manager.page_location[pid] == "cpu"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
# Run the tests
pytest test_page_manager.py -v
Expected output:
test_page_manager.py::test_allocate_and_free PASSED
test_page_manager.py::test_out_of_memory PASSED
test_page_manager.py::test_swap_updates_location PASSED
Benchmarking Swap Throughput
# benchmark_swap.py
import time
from engine import PagedInferenceEngine
engine = PagedInferenceEngine(
model_name='google/flan-t5-base',
gpu_pages=64,
cpu_pages=256,
page_size=16
)
# Allocate a request
request_id = engine.allocate_for_request("bench_req", 128, use_gpu=True)
# Measure swap to CPU
start = time.perf_counter()
engine.cache.swap_pages("bench_req", to_gpu=False)
gpu_to_cpu_ms = (time.perf_counter() - start) * 1000
# Measure swap back to GPU
start = time.perf_counter()
engine.cache.swap_pages("bench_req", to_gpu=True)
cpu_to_gpu_ms = (time.perf_counter() - start) * 1000
print(f"GPU → CPU swap: {gpu_to_cpu_ms:.2f}ms")
print(f"CPU → GPU swap: {cpu_to_gpu_ms:.2f}ms")
print(f"Total round-trip: {gpu_to_cpu_ms + cpu_to_gpu_ms:.2f}ms")
On a machine with a PCIe 3.0 x16 link, you should see round-trip swap times in the 1–5ms range for a few pages — orders of magnitude faster than recomputing the KV cache from scratch.
Extending It: Your Roadmap to Senior-Level
The base implementation above is a working prototype. To turn it into something that genuinely signals production-grade engineering, work through these upgrades in order:
Add a swap prefetch pipeline. Implement double-buffered async swaps using
torch.cuda.Streamso that while the GPU computes attention for the current token, the next token’s pages are being prefetched from CPU to GPU. This matters because PCIe transfer latency is hidden behind computation, which is how vLLM achieves high throughput.Implement a page eviction policy. Replace the simple FIFO swap with an LRU or ARC (Adaptive Replacement Cache) policy that tracks access frequency and evicts the coldest pages first. This matters because real workloads have non-uniform access patterns, and the wrong eviction policy can thrash the cache.
Add Prometheus metrics and structured logging. Instrument page fault rate, swap throughput (pages/second), GPU utilization, and attention latency with
prometheus_clientand output structured JSON logs viastructlog. This matters because observability is the difference between “it works” and “you can debug it at 3 AM in production.”Add fault tolerance with checkpoint/restore. Serialize the entire page pool state (including CPU-resident pages) to disk using
torch.saveor a memory-mapped format, and implement a recovery routine that restores all in-flight requests after a crash. This matters because inference serving is a long-running process, and crashes without state recovery mean lost requests and violated SLAs.Implement horizontal scaling with a request router. Add a lightweight HTTP server using
fastapithat exposes the engine as a gRPC or REST service, and build a router that distributes requests across multiple engine instances, each with its own page pool. This matters because a single GPU cannot serve large models at scale, and distributed inference is the production reality.Add a benchmarking harness with
pytest-benchmark. Measure throughput (tokens/second) under varying batch sizes, page sizes, and GPU/CPU ratios. Compare against a naive contiguous KV cache baseline. This matters because every claim about performance improvement needs empirical proof, and hiring managers want to see that you measure rather than assert.
Key Takeaways
- A paged KV cache is the same abstraction as virtual memory paging, applied to LLM inference — it lets you decouple memory capacity from contiguity constraints.
- Implementing this project demonstrates memory management, data movement optimization, and ML systems integration in a single portfolio piece.
- The real code uses PyTorch tensors as page storage, a heap-based free page allocator, and a scatter-gather attention interface — all production-grade patterns.
- CPU-GPU swapping is not just a toy concept: it is the core mechanism that allows serving models larger than GPU memory, and measuring its latency is essential to tuning performance.
- The six extension upgrades (async prefetch, eviction policy, observability, fault tolerance, horizontal scaling, benchmarking) map directly to senior-level systems engineering responsibilities.
- This project bridges the gap between ML practitioners who use models and systems engineers who build the infrastructure that runs them.
Further Reading
- PagedAttention: Efficient Memory Management for Large Language Model Serving with PagedAttention — The foundational paper from vLLM that introduced paged attention. Read this first to understand the theoretical motivation and design decisions.
- HuggingFace Transformers Documentation — KV Caching — Official docs on how HuggingFace implements KV caching in its model forward passes, which your paged cache will replace.
- PyTorch CUDA Memory Management — Canonical documentation on PyTorch’s CUDA memory allocator,
torch.cuda.memory_allocated, and async copy APIs that you will use for the swap engine. - vLLM Source Code —
paged_attention.py— The production implementation of paged attention. Study this to see how the concepts in this project are realized in a real serving system. - OSDI 2022: The Case for Distributed Shared Memory in Disaggregated Inference — A paper on disaggregating prefill and decode phases across machines, which is the natural next step after mastering single-node paged KV cache swapping.
- FastAPI Documentation — Async Endpoints — The canonical docs for building the HTTP serving layer in extension step 5, covering async endpoints and dependency injection.
- Prometheus Client Python Documentation — Official docs for instrumenting the observability layer from extension step 3, including counters, histograms, and gauge metrics.