tiny-llm / src /tinyllm /cuda_graph.py
CoolDip00's picture
tiny-llm 75e4579: 6.5b step 5: 5a Space created, 5b secrets added
d656a30 verified
Raw History Blame Contribute Delete
13.8 kB
"""CUDA-graph decode (4.11, docs/INFERENCE.md).
A decode step of a small model is launch-bound: ~10 kernels per layer, each a few µs of
GPU work, and the CPU needs about as long to launch each one. A CUDA graph records the
whole step once and replays it with a single launch.
A graph replays fixed kernels on fixed addresses, so the step must have static shapes:
- StaticKVCache keeps the position as a device tensor. K/V are written with index_copy_
at that position, RoPE is gathered at it, and every decode step attends over a fixed
window of W keys, with keys past the position masked out.
- W comes from length buckets (128, 256, ..., max_len). Each bucket has its own graph;
the step uses the smallest bucket that holds pos + 1 keys. All graphs share one memory
pool and the same static buffers (cache, input token, position).
- Prefill stays eager: it runs once per request and has a different shape each time.
Sampling stays eager too.
Not bit-identical to eager generate(): eager decode attends over exactly pos + 1 keys
with no mask; the graph attends over W keys with a mask, and SDPA picks a different
kernel for that. Graph replay *is* bit-identical to the same static step run eagerly.
Autocast runs with cache_enabled=False: its weight-cast cache doesn't survive capture.
"""
import time
import torch
from torch import Tensor
from tinyllm.kv_cache import causal_kwargs, padded_causal_mask
from tinyllm.model import Transformer
def default_buckets(max_len: int, first: int = 128) -> tuple[int, ...]:
"""first, 2*first, ... below max_len, then max_len itself."""
out, b = [], first
while b < max_len:
out.append(b)
b *= 2
return (*out, max_len)
class StaticKVCache:
"""Shared-position cache with fixed shapes (B rows, max_len positions).
Same model protocol as kv_cache.KVCache. Two modes:
- prefill (eager, position 0): as KVCache, writes and returns positions 0..T-1.
- decode (`window` set, T=1): position from the device tensor `pos`; returns the first
`window` keys with a mask, so shapes don't depend on the position.
first_valid (B,) is the left-pad count per row; zeros when unpadded.
"""
def __init__(self, n_layers: int, batch: int, max_len: int, device):
self.n_layers, self.batch, self.max_len = n_layers, batch, max_len
self.k: list[Tensor | None] = [None] * n_layers
self.v: list[Tensor | None] = [None] * n_layers
self.pos = torch.zeros(1, dtype=torch.long, device=device) # device copy: graph input
self.first_valid = torch.zeros(batch, dtype=torch.long, device=device)
self.padded = False
self.n = 0 # host copy of pos, for bounds and bucket choice (no sync)
self.window: int | None = None # None = prefill mode
self.attn_kwargs: dict = {}
def reset(self, pad_lens: Tensor | None = None) -> None:
self.n, self.window = 0, None
self.pos.zero_()
self.padded = pad_lens is not None
self.first_valid.copy_(pad_lens if self.padded else torch.zeros_like(self.first_valid))
def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
if self.window is None: # eager prefill from position 0
if self.n != 0:
raise ValueError("StaticKVCache prefills only from position 0")
cos, sin = rope(T, 0)
self.attn_kwargs = ({"attn_mask": padded_causal_mask(0, T, self.first_valid)}
if self.padded else causal_kwargs(0, T, cos.device))
return cos, sin
if T != 1:
raise ValueError("decode mode takes one token per row")
k_pos = torch.arange(self.window, device=self.pos.device)
valid = (k_pos <= self.pos) & (k_pos >= self.first_valid[:, None]) # (B, W)
self.attn_kwargs = {"attn_mask": valid[:, None, None, :]} # (B, 1, 1, W)
return rope.cos.index_select(0, self.pos), rope.sin.index_select(0, self.pos)
def end_step(self, T: int) -> None:
if self.window is None:
self.n = T
self.pos.fill_(T)
else:
self.pos.add_(1) # captured into the graph; the host copy moves in step()
def layer(self, i: int) -> "StaticLayerKV":
return StaticLayerKV(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 StaticLayerKV:
def __init__(self, cache: StaticKVCache, 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]:
c, i = self.cache, self.i
B, H, T, D = k.shape
if c.k[i] is None:
shape = (c.batch, 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)
elif c.k[i].dtype != k.dtype:
raise ValueError(f"cache holds {c.k[i].dtype}, got {k.dtype}")
if c.window is None:
if T > c.max_len:
raise ValueError(f"KV cache full: position {T} > max_len {c.max_len}")
c.k[i][:, :, :T] = k
c.v[i][:, :, :T] = v
return c.k[i][:, :, :T], c.v[i][:, :, :T]
c.k[i].index_copy_(2, c.pos, k)
c.v[i].index_copy_(2, c.pos, v)
return c.k[i][:, :, :c.window], c.v[i][:, :, :c.window]
class GraphDecoder:
"""Prefill eagerly, then decode one token per row with one CUDA-graph replay per step.
Reusable: prefill() resets the cache, and graphs captured for one request serve the
next (same batch size). use_graph=False runs the identical static step eagerly, the
reference that replay must match bit for bit.
"""
def __init__(self, model: Transformer, batch: int, max_len: int | None = None,
buckets: tuple[int, ...] | None = None, use_graph: bool = True):
device = next(model.parameters()).device
if device.type != "cuda":
raise ValueError("CUDA graphs need a CUDA model")
self.model = model
self.max_len = max_len or model.cfg.max_seq_len
if self.max_len > model.cfg.max_seq_len:
raise ValueError(f"max_len {self.max_len} > max_seq_len {model.cfg.max_seq_len}")
self.buckets = tuple(sorted(buckets)) if buckets else default_buckets(self.max_len)
if self.buckets[-1] < self.max_len:
raise ValueError(f"largest bucket {self.buckets[-1]} < max_len {self.max_len}")
self.cache = StaticKVCache(model.cfg.n_layers, batch, self.max_len, device)
self.tok = torch.zeros(batch, 1, dtype=torch.long, device=device)
self.use_graph = use_graph
self.graphs: dict[int, tuple[torch.cuda.CUDAGraph, Tensor]] = {}
self.pool = torch.cuda.graph_pool_handle()
def _autocast(self):
return torch.autocast("cuda", dtype=torch.bfloat16, cache_enabled=False)
@torch.no_grad()
def prefill(self, idx: Tensor, pad_lens: Tensor | None = None) -> Tensor:
"""idx (B, T), left-padded by pad_lens (B,) if given. Returns logits (B, T, V)."""
if idx.shape[0] != self.cache.batch:
raise ValueError(f"batch {idx.shape[0]} != decoder batch {self.cache.batch}")
self.cache.reset(pad_lens)
with self._autocast():
logits, _ = self.model(idx, cache=self.cache)
return logits
def _forward(self) -> Tensor:
with self._autocast():
logits, _ = self.model(self.tok, cache=self.cache)
return logits
def _capture(self, window: int) -> tuple[torch.cuda.CUDAGraph, Tensor]:
# Warm up on a side stream (lazy cuBLAS / SDPA setup must not happen during capture).
# The warm-up runs the real step: its K/V writes are the ones replay will make, and
# the position it advances is put back.
saved = self.cache.pos.clone()
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
self._forward()
torch.cuda.current_stream().wait_stream(side)
self.cache.pos.copy_(saved)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool=self.pool):
out = self._forward()
return graph, out
def bucket(self, n_keys: int) -> int:
return next(b for b in self.buckets if b >= n_keys)
@torch.no_grad()
def step(self, tok: Tensor) -> Tensor:
"""tok (B, 1) → logits (B, 1, V). With graphs, the result is a static buffer that
the next step overwrites: read it (or sample from it) first."""
c = self.cache
if c.n >= self.max_len:
raise ValueError(f"KV cache full: position {c.n + 1} > max_len {self.max_len}")
c.window = self.bucket(c.n + 1)
self.tok.copy_(tok)
if not self.use_graph:
out = self._forward()
else:
if c.window not in self.graphs:
self.graphs[c.window] = self._capture(c.window)
graph, out = self.graphs[c.window]
graph.replay()
c.n += 1
return out
class SlotGraphDecoder:
"""The continuous engine's decode step (one token per SlotKVCache slot) as CUDA graphs
(4.11b). Admission, release and prefill stay eager in the engine.
Why it can be a graph: the batch is always n_slots rows (free slots compute too), and
slot lengths / pad starts / active flags live in fixed device tensors that admit() and
release() update in place. The one changing shape is the attention window, so the cache
gets a fixed window from the length buckets, with keys past each slot's length masked.
Each step writes the token into a static buffer and replays the graph for the smallest
bucket that holds the longest slot + 1. The replay advances the device lengths; the host
lengths move in advance_host(). use_graph=False runs the identical windowed step eagerly,
the reference that replay must match bit for bit.
"""
def __init__(self, model: Transformer, slots, buckets: tuple[int, ...] | None = None,
use_graph: bool = True):
device = next(model.parameters()).device
if device.type != "cuda":
raise ValueError("CUDA graphs need a CUDA model")
if not hasattr(slots, "advance_host"):
raise ValueError("SlotGraphDecoder supports the slot cache (kv='slots') only")
self.model, self.slots = model, slots
self.buckets = tuple(sorted(buckets)) if buckets else default_buckets(slots.max_len)
if self.buckets[-1] < slots.max_len:
raise ValueError(f"largest bucket {self.buckets[-1]} < max_len {slots.max_len}")
self.tok = torch.zeros(slots.n_slots, 1, dtype=torch.long, device=device)
self.use_graph = use_graph
self.graphs: dict[int, tuple[torch.cuda.CUDAGraph, Tensor]] = {}
self.pool = torch.cuda.graph_pool_handle()
def _forward(self) -> Tensor:
with torch.autocast("cuda", dtype=torch.bfloat16, cache_enabled=False):
logits, _ = self.model(self.tok, cache=self.slots)
return logits
def _capture(self) -> tuple[torch.cuda.CUDAGraph, Tensor]:
# The warm-up runs the real step (its K/V writes are the ones replay will make) and
# advances the lengths; capture runs the host half again. Put both back each time.
c = self.slots
lens, lens_dev = list(c.lens), c._lens_dev.clone()
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
self._forward()
torch.cuda.current_stream().wait_stream(side)
c.lens[:] = lens
c._lens_dev.copy_(lens_dev)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool=self.pool):
out = self._forward()
c.lens[:] = lens
return graph, out
def bucket(self, n_keys: int) -> int:
return next(b for b in self.buckets if b >= n_keys)
@torch.no_grad()
def capture(self, windows: tuple[int, ...] | None = None) -> float:
"""Capture graphs ahead of time (default: every bucket), so no timed step pays for it.
Safe between steps: the warm-up's K/V writes land at each slot's next position, which
the next real step overwrites. Returns the seconds it took."""
t0 = time.perf_counter()
c = self.slots
try:
for w in windows or self.buckets:
if w not in self.graphs:
c.window = w
self.graphs[w] = self._capture()
finally:
c.window = None
torch.cuda.synchronize()
return time.perf_counter() - t0
@torch.no_grad()
def step(self, tok: Tensor) -> Tensor:
"""tok (n_slots, 1) → logits (n_slots, 1, V). With graphs, the result is a static
buffer that the next step overwrites: sample from it first."""
c = self.slots
need = max(c.lens) + 1
if need > c.max_len:
raise ValueError(f"a slot is full (max_len {c.max_len})")
c.window = self.bucket(need)
self.tok.copy_(tok)
try:
if not self.use_graph:
return self._forward()
if c.window not in self.graphs:
self.graphs[c.window] = self._capture()
graph, out = self.graphs[c.window]
graph.replay()
c.advance_host()
return out
finally:
c.window = None