cd .. / course
Part 2 · Lesson 5/8

Paged attention: stop reserving memory you won't use

You don't know how long a reply will be, so the naive cache reserves each request's maximum slot — and short replies leave most of it empty. Fixed-size pages hand back the slack, so far more requests fit the same VRAM.

⏱ ~5 min 🧮 padding waste, live math: some

Last lesson the KV cache grew per token and per request. But there's a second, sneakier cost: the cache is variable-length — you don't know a reply's length up front. The naive fix reserves each request's maximum contiguous slot. A 4-token reply parked in a 2048-token slot leaves 2044 slots empty as padding — and the free pool fragments.

Paged attention (the idea behind vLLM) chops the cache into fixed-size pages — say 16 tokens — placed wherever there's room, exactly like an operating system's virtual memory. A sequence uses only as many whole pages as it actually fills.

Waste drops from "up to the max length per request" to "under one page per request." Same K/V, same output — only the layout changed.

Reserve-max vs paged · count the wasted slotscapacity
allocation strategy
page size 16 tokens
request lengths — the K/V tokens each reply actually needs (max-slot = 2048)
used K/V tokens wasted — reserved but empty free pool (available for more requests)
memory wasted
—
requests served
—
reserve = N × max_len  ·  paged = Σ ⌈lenᵢ / page⌉ × page  ·  waste = 1 − used / allocated

TRY → Start on reserve max — every row is mostly hatched padding and waste reads ~75%. Flip to paged: the rows collapse to a short colored block plus a tiny page-tail, the rest of each row goes free, and waste drops to ~1% with ~4× more requests served. Now drag the page size slider up — bigger pages round each request up further, so waste creeps back.

Show me the actual numbers (default lengths [512, 37, 1900, 128, 4], page 16)

Five requests, used = 512 + 37 + 1900 + 128 + 4 = 2581 tokens of real K/V.

Reserve-max gives each request the full 2048-token slot no matter how short it is: alloc = 5 × 2048 = 10,240. Waste = 1 − 2581 / 10,240 = 74.8% — three-quarters of the pool is padding.

Paged (page = 16) rounds each length up to whole pages with pagesFor(len, 16):

requestlen⌈len/16⌉ pagesalloctail waste
r1512325120
r23734811
r3190011919044
r412881280
r5411612

alloc = 512 + 48 + 1904 + 128 + 16 = 2608. Waste = 1 − 2581 / 2608 = 1.0% — under one page per request. In a fixed 40,960-slot pool that lifts capacity from ⌊40960 / (10240/5)⌋ = 20 requests to ⌊40960 / (2608/5)⌋ = 78 — about 3.9× more.

Paging trades a tiny per-page rounding waste (at most page − 1 tokens per request) for the freedom to place pages anywhere, which also kills fragmentation. Smaller pages waste less but add bookkeeping; 16 is a common middle ground. The stored K/V and the model's output are identical either way — only the memory layout changed.

MythEach request needs one big contiguous block of cache, sized for the longest reply it might produce.
RealitySplit the cache into fixed pages placed anywhere in memory. A request occupies only the pages it fills, so wasted memory drops from ~75% to ~1% and several times more requests fit the same VRAM.
Takeaway

Reserving each request's maximum slot wastes most of it on padding. Paged attention allocates fixed pages on demand — a request uses only what it fills, so waste falls to under one page and the pool serves ~4× more requests, with zero change to the answer.

Why does reserving each request's maximum cache slot waste memory?
Why: paged attention allocates fixed pages on demand, so a request occupies only what it fills — under one page of slack — instead of the whole max-length block. Same K/V, same output; only the layout changes.