Download src/tinyllm/kv_cache.py from CoolDip00/tiny-llm: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/kv_cache.py
- Command line
-
hf download hf://spaces/CoolDip00/tiny-llm/src/tinyllm/kv_cache.py
-
curl -L -o kv_cache.py https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/kv_cache.py
10.7 kB
| """KV caches for incremental decoding (docs/INFERENCE.md). | |
| The model talks to a cache through three calls per forward pass: | |
| rope = cache.begin_step(T, rope_module) # positions of the T new tokens; builds the mask | |
| view = cache.layer(i) # per layer: view.update(k, v), view.attn_kwargs | |
| cache.end_step(T) # the new tokens are now stored | |
| - KVCache (4.3, 4.4 static): one shared position for every row. Optional left padding | |
| (`pad_lens`) lets prompts of different lengths share a batch: pad keys are masked. | |
| - SlotKVCache (4.4 continuous): S independent slots, each with its own length, so | |
| requests can join and leave between decode steps. | |
| Buffers are [B, n_kv_heads, max_len, head_dim] per layer. No reference cycles: the | |
| caches own plain tensor lists and `layer(i)` returns a short-lived view. (A cycle once | |
| kept every finished cache's GPU memory alive until Python's cycle collector ran.) | |
| Left padding needs no per-row RoPE positions: RoPE makes q·k depend only on the | |
| distance between two positions, and padding shifts every real token of a row by the | |
| same amount. Pad *queries* may attend to themselves, so no row is fully masked (a fully | |
| masked softmax row is NaN, and NaN would leak through 0 × NaN in later layers). | |
| """ | |
| import torch | |
| from torch import Tensor | |
| def causal_kwargs(start: int, T: int, device) -> dict: | |
| """SDPA masking for T new queries at positions start..start+T-1 over start+T keys, no padding. | |
| - start 0 (prefill / training): standard causal. | |
| - one new token: it may see every cached key, so no mask. | |
| - several new tokens on a non-empty cache (chunked prefill, speculative verify): | |
| SDPA's is_causal aligns to the top-left, which is wrong here, so pass an explicit | |
| mask where query i sees keys 0..start+i. | |
| """ | |
| if start == 0: | |
| return {"is_causal": True} | |
| if T == 1: | |
| return {} | |
| q_pos = torch.arange(start, start + T, device=device)[:, None] | |
| k_pos = torch.arange(start + T, device=device)[None, :] | |
| return {"attn_mask": k_pos <= q_pos} | |
| def padded_causal_mask(start: int, T: int, first_valid: Tensor) -> Tensor: | |
| """(B, 1, T, start+T) bool: causal, keys before first_valid[b] hidden, diagonal always kept.""" | |
| dev = first_valid.device | |
| q_pos = torch.arange(start, start + T, device=dev)[:, None] # (T, 1) | |
| k_pos = torch.arange(start + T, device=dev)[None, :] # (1, K) | |
| causal = k_pos <= q_pos | |
| valid = k_pos[None] >= first_valid[:, None, None] # (B, 1, K) | |
| return (causal[None] & (valid | (k_pos == q_pos)[None]))[:, None] # (B, 1, T, K) | |
| class KVCache: | |
| """Shared-position cache. pad_lens (B,): left-pad count per row, or None for no padding.""" | |
| def __init__(self, n_layers: int, max_len: int, pad_lens: Tensor | None = None): | |
| self.max_len = max_len | |
| self.pos = 0 | |
| self.pad_lens = pad_lens | |
| self.k: list[Tensor | None] = [None] * n_layers | |
| self.v: list[Tensor | None] = [None] * n_layers | |
| self.attn_kwargs: dict = {} | |
| def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]: | |
| cos, sin = rope(T, self.pos) | |
| if self.pad_lens is None: | |
| self.attn_kwargs = causal_kwargs(self.pos, T, cos.device) | |
| else: | |
| self.attn_kwargs = {"attn_mask": padded_causal_mask(self.pos, T, self.pad_lens)} | |
| return cos, sin | |
| def end_step(self, T: int) -> None: | |
| self.pos += T | |
| def layer(self, i: int) -> "LayerKV": | |
| return LayerKV(self, i) | |
| def nbytes(self) -> int: | |
| return sum(t.numel() * t.element_size() for t in self.k + self.v if t is not None) | |
| class LayerKV: | |
| """One layer's view of a KVCache.""" | |
| def __init__(self, cache: KVCache, layer_idx: int): | |
| self.cache = cache | |
| self.i = layer_idx | |
| def start(self) -> int: | |
| """Position of the first new token = number of tokens already cached.""" | |
| return self.cache.pos | |
| def attn_kwargs(self) -> dict: | |
| return self.cache.attn_kwargs | |
| def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]: | |
| """Store k, v (B, H, T, D) at positions start..start+T-1; return all cached K, V so far.""" | |
| c, i = self.cache, self.i | |
| B, H, T, D = k.shape | |
| if c.k[i] is None: | |
| shape = (B, H, c.max_len, D) | |
| c.k[i] = torch.zeros(shape, dtype=k.dtype, device=k.device) | |
| c.v[i] = torch.zeros(shape, dtype=v.dtype, device=v.device) | |
| start, end = self.start, self.start + T | |
| if end > c.max_len: | |
| raise ValueError(f"KV cache full: position {end} > max_len {c.max_len}") | |
| c.k[i][:, :, start:end] = k | |
| c.v[i][:, :, start:end] = v | |
| return c.k[i][:, :, :end], c.v[i][:, :, :end] | |
| class SlotKVCache: | |
| """S slots, each holding one sequence with its own length (continuous batching, 4.4). | |
| Slot b holds keys at positions first[b] .. lens[b]-1 (positions before first[b] are | |
| left padding from a batched prefill). A decode step feeds one token per slot, at | |
| position lens[b]. Free slots still run (their output is ignored) so shapes stay fixed. | |
| Every slot reserves max_len positions whatever its request needs: the waste that | |
| paged KV (4.5) removes. `used_tokens()` vs `reserved_tokens()` measures it. | |
| """ | |
| def __init__(self, n_layers: int, n_slots: int, n_kv_heads: int, head_dim: int, | |
| max_len: int, dtype: torch.dtype, device): | |
| shape = (n_slots, n_kv_heads, max_len, 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)] | |
| self.n_slots, self.max_len = n_slots, max_len | |
| # CPU copies drive scheduling without GPU syncs; device copies feed RoPE and masks. | |
| self.first = [0] * n_slots | |
| self.lens = [0] * n_slots | |
| self.active = [False] * n_slots | |
| 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._end = 0 | |
| # 4.11b: a fixed attention window (a graph bucket) instead of "as far as the longest | |
| # slot", so the step's shapes don't change between steps. None = eager default. | |
| self.window: int | None = None | |
| # -- scheduling -------------------------------------------------------- | |
| def free_slots(self) -> list[int]: | |
| return [s for s in range(self.n_slots) if not self.active[s]] | |
| def admit(self, slots: list[int], prefill: KVCache, pad_lens: list[int]) -> None: | |
| """Copy a finished batched prefill (row j → slot slots[j]) into the slots.""" | |
| L = prefill.pos | |
| if L > self.max_len: | |
| raise ValueError(f"prompt length {L} > slot max_len {self.max_len}") | |
| idx = torch.tensor(slots, device=self._lens_dev.device) | |
| for i in range(len(self.k)): | |
| self.k[i][idx, :, :L] = prefill.k[i][:, :, :L] | |
| self.v[i][idx, :, :L] = prefill.v[i][:, :, :L] | |
| for s, p in zip(slots, pad_lens): | |
| self.first[s], self.lens[s], self.active[s] = p, L, True | |
| self._sync() | |
| def release(self, slot: int) -> None: | |
| self.active[slot] = False | |
| self.first[slot] = self.lens[slot] = 0 | |
| self._sync() | |
| def _sync(self) -> None: | |
| """Push the CPU bookkeeping to the device (only on admit / release, not every step).""" | |
| 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 can_admit(self, lengths: list[int]) -> bool: | |
| return True # a free slot is all a request needs | |
| def reserve_step(self) -> list[int]: | |
| return [] # never preempts: capacity is per slot | |
| def used_tokens(self) -> int: | |
| return sum(n - f for n, f, a in zip(self.lens, self.first, self.active) if a) | |
| def allocated_tokens(self) -> int: | |
| """KV positions claimed by running sequences (a whole slot each).""" | |
| return sum(self.active) * self.max_len | |
| def reserved_tokens(self) -> int: | |
| return self.n_slots * self.max_len | |
| 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("SlotKVCache decodes one token per slot; prefill goes through KVCache") | |
| if max(self.lens) >= self.max_len: | |
| raise ValueError(f"a slot is full (max_len {self.max_len})") | |
| need = max(self.lens) + 1 # attend only as far as the longest slot | |
| if self.window is not None and self.window < need: | |
| raise ValueError(f"window {self.window} < {need} keys") | |
| self._end = self.window or need | |
| k_pos = torch.arange(self._end, device=self._lens_dev.device) | |
| valid = (k_pos >= self._first_dev[:, None]) & (k_pos <= self._lens_dev[:, None]) | |
| self.attn_kwargs = {"attn_mask": valid[:, None, None, :]} # (S, 1, 1, end) | |
| return rope.at(self._lens_dev[:, None]) | |
| def end_step(self, T: int) -> None: | |
| self.advance_host() | |
| self._lens_dev += self._active_dev # same update on the device, no copy | |
| def advance_host(self) -> None: | |
| """The host half of end_step. A CUDA-graph replay runs only the device half, so the | |
| graph decoder calls this after each replay.""" | |
| for s in range(self.n_slots): | |
| if self.active[s]: | |
| self.lens[s] += 1 | |
| def layer(self, i: int) -> "SlotLayerKV": | |
| return SlotLayerKV(self, i) | |
| class SlotLayerKV: | |
| def __init__(self, cache: SlotKVCache, 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 at its own position lens[s].""" | |
| c, i = self.cache, self.i | |
| c.k[i][c._rows, :, c._lens_dev] = k[:, :, 0] | |
| c.v[i][c._rows, :, c._lens_dev] = v[:, :, 0] | |
| return c.k[i][:, :, :c._end], c.v[i][:, :, :c._end] | |