Download src/tinyllm/paged_kv.py from CoolDip00/tiny-llm: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/paged_kv.py
- Command line
-
hf download hf://spaces/CoolDip00/tiny-llm/src/tinyllm/paged_kv.py
-
curl -L -o paged_kv.py https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/paged_kv.py
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 | |
| def num_free(self) -> int: | |
| return len(self._free) | |
| 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 | |
| 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]) | |