tiny-llm / src /tinyllm /paged_kv.py
CoolDip00's picture
tiny-llm 75e4579: 6.5b step 5: 5a Space created, 5b secrets added
d656a30 verified
Raw History Blame Contribute Delete
10.9 kB
"""Step 4.5: paged KV cache (vLLM-style blocks), a drop-in for SlotKVCache in run_continuous.
The analogy is virtual memory:
block table = page table (one per sequence: logical block j -> physical block)
block = page (block_size tokens of K and V, every layer)
token t = (table[t // block_size], t % block_size)
All layers share one block table: block b holds the same token positions in every layer's
pool. Blocks are allocated as a sequence grows, not reserved up front, and every block has
the same size, so a freed block fits any sequence: no external fragmentation, and at most
block_size - 1 wasted slots per sequence (its last, partly filled block).
Left padding from the batched prefill is not stored: only real tokens go into blocks.
The pad count becomes a RoPE offset (`first`), so a token at storage index j keeps its
rotation position first + j and the next query lands at first + len, as in SlotKVCache.
Attention reads the blocks by gathering each sequence's blocks into a contiguous
(S, H, n_blocks * block_size, D) view and calling SDPA as before. vLLM's PagedAttention
kernel reads the blocks in place instead; the gather is this toy's extra copy (measured
in docs/INFERENCE.md).
When decode needs more blocks than are free, the newest sequence is preempted: its blocks
are freed and the engine requeues it to be recomputed (prompt + tokens so far), which
reproduces the same greedy tokens. This is vLLM's default recompute policy.
"""
import math
import torch
from torch import Tensor
from tinyllm.kv_cache import KVCache
class OutOfBlocks(RuntimeError):
pass
class BlockAllocator:
"""Fixed-size blocks with a free list. Lowest ids are handed out first (reused LIFO)."""
def __init__(self, n_blocks: int, block_size: int):
self.n_blocks, self.block_size = n_blocks, block_size
self._free = list(range(n_blocks - 1, -1, -1)) # stack: pop() gives block 0 first
self._in_use = [False] * n_blocks
self.peak_used = 0
@property
def num_free(self) -> int:
return len(self._free)
@property
def num_used(self) -> int:
return self.n_blocks - len(self._free)
def alloc(self) -> int:
if not self._free:
raise OutOfBlocks(f"all {self.n_blocks} KV blocks in use")
b = self._free.pop()
self._in_use[b] = True
self.peak_used = max(self.peak_used, self.num_used)
return b
def free(self, blocks: list[int]) -> None:
for b in blocks:
if not self._in_use[b]:
raise ValueError(f"block {b} freed twice (or never allocated)")
self._in_use[b] = False
self._free.append(b)
class PagedKVCache:
"""S sequence slots over a shared pool of n_blocks KV blocks.
Same model protocol as SlotKVCache (begin_step / layer / end_step, one token per slot)
and the same scheduling calls (free_slots / admit / release), plus can_admit() and
reserve_step() so the engine can respect the block budget.
"""
def __init__(self, n_layers: int, n_slots: int, n_kv_heads: int, head_dim: int,
n_blocks: int, block_size: int, max_len: int, dtype: torch.dtype, device):
self.alloc = BlockAllocator(n_blocks, block_size)
self.bs, self.n_slots, self.max_len = block_size, n_slots, max_len
self.max_blocks = math.ceil(max_len / block_size)
self.scratch = n_blocks # extra block, never allocated: free slots write here
shape = (n_blocks + 1, n_kv_heads, block_size, head_dim)
self.k = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
self.v = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
# CPU bookkeeping (scheduling, no GPU syncs) and its device mirror (indexing, masks).
self.tables: list[list[int]] = [[] for _ in range(n_slots)]
self.lens = [0] * n_slots # tokens stored (no padding)
self.first = [0] * n_slots # RoPE offset = left-pad count at prefill
self.active = [False] * n_slots
self._admit_no = [0] * n_slots # admission order, for picking preemption victims
self._admits = 0
self._table_dev = torch.full((n_slots, self.max_blocks), self.scratch,
dtype=torch.long, device=device)
self._lens_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
self._first_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
self._active_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
self._rows = torch.arange(n_slots, device=device)
self.attn_kwargs: dict = {}
self._n_read_blocks = 0
self._blk = self._off = None
# -- scheduling --------------------------------------------------------
def free_slots(self) -> list[int]:
return [s for s in range(self.n_slots) if not self.active[s]]
def can_admit(self, lengths: list[int]) -> bool:
"""Enough free blocks for these prompts, plus room for each one's first decode token."""
return sum(math.ceil((n + 1) / self.bs) for n in lengths) <= self.alloc.num_free
def admit(self, slots: list[int], prefill: KVCache, pad_lens: list[int]) -> None:
"""Copy row j of a padded prefill (real tokens only) into fresh blocks for slots[j]."""
L = prefill.pos
dev = self._lens_dev.device
rows, src, blk, off = [], [], [], []
for j, (s, p) in enumerate(zip(slots, pad_lens)):
n = L - p
if n >= self.max_len:
raise ValueError(f"prompt length {n} >= max_len {self.max_len}")
blocks = [self.alloc.alloc() for _ in range(math.ceil(n / self.bs))]
self.tables[s] = blocks
self.lens[s], self.first[s], self.active[s] = n, p, True
self._admits += 1
self._admit_no[s] = self._admits
self._table_dev[s].fill_(self.scratch)
self._table_dev[s, :len(blocks)] = torch.tensor(blocks, device=dev)
t = torch.arange(n)
rows.append(torch.full((n,), j))
src.append(t + p)
blk.append(torch.tensor(blocks, dtype=torch.long)[t // self.bs])
off.append(t % self.bs)
rows, src, blk, off = (torch.cat(x).to(dev) for x in (rows, src, blk, off))
for i in range(len(self.k)):
self.k[i][blk, :, off] = prefill.k[i][rows, :, src] # (N, H, D) both sides
self.v[i][blk, :, off] = prefill.v[i][rows, :, src]
self._sync()
def release(self, slot: int) -> None:
self.alloc.free(self.tables[slot])
self.tables[slot] = []
self.active[slot] = False
self.lens[slot] = self.first[slot] = 0
self._table_dev[slot].fill_(self.scratch)
self._sync()
def reserve_step(self) -> list[int]:
"""Make sure the next decode step's new blocks can be allocated. Preempts the newest
sequences (frees their blocks) until they can; returns the preempted slots, newest first."""
victims = []
while True:
need = sum(1 for s in range(self.n_slots)
if self.active[s] and len(self.tables[s]) * self.bs <= self.lens[s])
if need <= self.alloc.num_free or not any(self.active):
return victims
v = max((s for s in range(self.n_slots) if self.active[s]), key=self._admit_no.__getitem__)
self.release(v)
victims.append(v)
def _sync(self) -> None:
self._lens_dev.copy_(torch.tensor(self.lens))
self._first_dev.copy_(torch.tensor(self.first))
self._active_dev.copy_(torch.tensor(self.active, dtype=torch.long))
def used_tokens(self) -> int:
return sum(n for n, a in zip(self.lens, self.active) if a)
def allocated_tokens(self) -> int:
return self.alloc.num_used * self.bs
def reserved_tokens(self) -> int:
"""Pool capacity: the KV memory this cache holds."""
return self.alloc.n_blocks * self.bs
def nbytes(self) -> int:
return sum(t.numel() * t.element_size() for t in self.k + self.v)
# -- model protocol (decode only: one token per slot) ------------------
def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
if T != 1:
raise ValueError("PagedKVCache decodes one token per slot; prefill goes through KVCache")
if max(self.lens) >= self.max_len:
raise ValueError(f"a sequence is full (max_len {self.max_len})")
new_rows, new_cols, new_blks = [], [], []
for s in range(self.n_slots): # grow sequences that filled a block
if self.active[s] and len(self.tables[s]) * self.bs <= self.lens[s]:
b = self.alloc.alloc() # reserve_step made room
new_rows.append(s)
new_cols.append(len(self.tables[s]))
new_blks.append(b)
self.tables[s].append(b)
if new_rows:
dev = self._table_dev.device
self._table_dev[torch.tensor(new_rows, device=dev),
torch.tensor(new_cols, device=dev)] = torch.tensor(new_blks, device=dev)
self._n_read_blocks = math.ceil((max(self.lens) + 1) / self.bs)
self._blk = self._table_dev[self._rows, self._lens_dev // self.bs] # where to write
self._off = self._lens_dev % self.bs
k_pos = torch.arange(self._n_read_blocks * self.bs, device=self._lens_dev.device)
self.attn_kwargs = {"attn_mask": (k_pos <= self._lens_dev[:, None])[:, None, None, :]}
return rope.at((self._first_dev + self._lens_dev)[:, None])
def end_step(self, T: int) -> None:
for s in range(self.n_slots):
if self.active[s]:
self.lens[s] += 1
self._lens_dev += self._active_dev
def layer(self, i: int) -> "PagedLayerKV":
return PagedLayerKV(self, i)
class PagedLayerKV:
def __init__(self, cache: PagedKVCache, layer_idx: int):
self.cache = cache
self.i = layer_idx
@property
def attn_kwargs(self) -> dict:
return self.cache.attn_kwargs
def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
"""k, v (S, H, 1, D): write each slot's token into its block, then gather its blocks."""
c, i = self.cache, self.i
c.k[i][c._blk, :, c._off] = k[:, :, 0]
c.v[i][c._blk, :, c._off] = v[:, :, 0]
table = c._table_dev[:, :c._n_read_blocks] # (S, nb)
S, nb = table.shape
_, H, bs, D = c.k[i].shape
def gather(pool: Tensor) -> Tensor: # (S, nb, H, bs, D) -> (S, H, nb*bs, D)
return pool[table].transpose(1, 2).reshape(S, H, nb * bs, D)
return gather(c.k[i]), gather(c.v[i])