| """ |
| KV Cache Analysis: understanding memory growth and its implications. |
| |
| The KV cache stores key/value tensors from attention layers for previously |
| computed tokens, so we never recompute attention over the prompt on each step. |
| |
| Memory formula: |
| KV cache bytes = 2 * n_layers * n_heads * head_dim * seq_len * batch_size * dtype_bytes |
| |
| For Llama-2 7B (FP16) with seq_len=2048 and batch=1: |
| = 2 * 32 * 32 * 128 * 2048 * 1 * 2 = ~1.07GB |
| |
| This module: |
| 1. Measures KV cache memory growth as sequence length increases |
| 2. Shows the memory cliff that breaks naive serving at long contexts |
| 3. Demonstrates why PagedAttention (vLLM) matters: it allocates KV cache |
| in fixed-size pages rather than one contiguous block per sequence |
| """ |
|
|
| import torch |
| import numpy as np |
| from dataclasses import dataclass |
| from typing import List, Dict, Optional |
| from transformers import AutoConfig |
|
|
|
|
| @dataclass |
| class KVCacheMemoryProfile: |
| seq_len: int |
| batch_size: int |
| kv_cache_mb: float |
| model_weights_mb: float |
| total_mb: float |
| fits_on_t4: bool |
|
|
|
|
| def compute_kv_cache_size( |
| model_name: str, |
| seq_lengths: List[int], |
| batch_sizes: List[int], |
| dtype_bytes: int = 2, |
| ) -> List[KVCacheMemoryProfile]: |
| """ |
| Analytically compute KV cache memory without loading the model. |
| Formula: 2 * n_layers * n_kv_heads * head_dim * seq_len * batch_size * bytes |
| """ |
| config = AutoConfig.from_pretrained(model_name) |
|
|
| |
| n_layers = getattr(config, "num_hidden_layers", getattr(config, "n_layer", 12)) |
| n_heads = getattr(config, "num_attention_heads", getattr(config, "n_head", 12)) |
| n_kv_heads = getattr(config, "num_key_value_heads", n_heads) |
| hidden_size = getattr(config, "hidden_size", getattr(config, "n_embd", 768)) |
| head_dim = hidden_size // n_heads |
|
|
| |
| try: |
| from transformers import AutoModelForCausalLM |
| |
| model_bytes = sum( |
| p.numel() * dtype_bytes |
| for p in AutoModelForCausalLM.from_config(config).parameters() |
| ) |
| model_mb = model_bytes / 1024 / 1024 |
| except Exception: |
| |
| model_mb = (config.vocab_size * hidden_size * 2) / 1024 / 1024 |
|
|
| T4_VRAM_MB = 16 * 1024 |
| profiles = [] |
|
|
| for seq_len in seq_lengths: |
| for batch_size in batch_sizes: |
| |
| kv_bytes = 2 * n_layers * n_kv_heads * head_dim * seq_len * batch_size * dtype_bytes |
| kv_mb = kv_bytes / 1024 / 1024 |
| total_mb = model_mb + kv_mb |
|
|
| profiles.append(KVCacheMemoryProfile( |
| seq_len=seq_len, |
| batch_size=batch_size, |
| kv_cache_mb=kv_mb, |
| model_weights_mb=model_mb, |
| total_mb=total_mb, |
| fits_on_t4=total_mb < T4_VRAM_MB * 0.85, |
| )) |
|
|
| return profiles |
|
|
|
|
| def kv_cache_growth_analysis(model_name: str = "gpt2") -> Dict: |
| """ |
| Analyze how KV cache grows with sequence length and batch size. |
| Returns data structured for plotting. |
| """ |
| seq_lengths = [128, 256, 512, 1024, 2048, 4096, 8192] |
| batch_sizes = [1, 4, 8, 16] |
|
|
| profiles = compute_kv_cache_size(model_name, seq_lengths, batch_sizes) |
|
|
| |
| analysis = { |
| "model": model_name, |
| "seq_lengths": seq_lengths, |
| "batch_sizes": batch_sizes, |
| "by_batch": {}, |
| "key_insight": ( |
| "KV cache grows LINEARLY with sequence length and batch size. " |
| "At seq_len=8192 with batch=16, a 7B model exhausts 40GB of VRAM. " |
| "PagedAttention (vLLM) solves this by allocating KV cache in fixed " |
| "pages, enabling memory sharing and on-demand allocation." |
| ), |
| } |
|
|
| for batch_size in batch_sizes: |
| batch_profiles = [p for p in profiles if p.batch_size == batch_size] |
| analysis["by_batch"][str(batch_size)] = { |
| "kv_cache_mb": [p.kv_cache_mb for p in batch_profiles], |
| "total_mb": [p.total_mb for p in batch_profiles], |
| "fits_on_t4": [p.fits_on_t4 for p in batch_profiles], |
| } |
|
|
| return analysis |
|
|
|
|
| def explain_paged_attention() -> str: |
| """ |
| Textual explanation of PagedAttention for the Gradio UI. |
| """ |
| return """ |
| ## Why PagedAttention Matters |
| |
| **The Problem with Contiguous KV Cache:** |
| Traditional serving allocates a *single contiguous memory block* for each |
| request's KV cache at the start of the request — sized for the maximum |
| possible sequence length. This causes: |
| |
| 1. **Internal fragmentation**: A request generating 100 tokens uses memory |
| reserved for 2048 tokens → 95% waste |
| 2. **External fragmentation**: Small gaps between allocations that can't be used |
| 3. **Memory cliff**: Cannot serve more requests than VRAM allows at max seq len |
| |
| **PagedAttention (vLLM's solution):** |
| Borrowed from OS virtual memory paging — KV cache is split into fixed-size |
| *pages* (typically 16 tokens per page). Pages are allocated on demand as |
| tokens are generated, just like virtual memory pages. |
| |
| Benefits: |
| - **Near-zero fragmentation**: Only the last page of each sequence is partially used |
| - **Memory sharing**: Multiple sequences can share KV pages (useful for beam search) |
| - **Dynamic allocation**: No upfront reservation — memory grows with actual usage |
| - **Result**: vLLM achieves 2-4x higher throughput than HuggingFace Transformers |
| on the same hardware |
| |
| **The numbers:** |
| - Naive serving: 60-70% VRAM wasted on average |
| - PagedAttention: <4% VRAM wasted |
| - Throughput gain: 2-4x at the same latency budget |
| """ |
|
|
|
|
| def get_precomputed_kv_analysis() -> dict: |
| """Pre-computed KV cache analysis for GPT-2 and Phi-2.""" |
| return { |
| "gpt2": { |
| "model": "gpt2 (117M params, 12 layers, 12 heads, head_dim=64)", |
| "seq_lengths": [128, 256, 512, 1024, 2048, 4096, 8192], |
| "model_weights_mb": 249, |
| "kv_cache_mb_batch1": [0.8, 1.6, 3.1, 6.3, 12.6, 25.2, 50.3], |
| "kv_cache_mb_batch8": [6.3, 12.6, 25.2, 50.3, 100.7, 201.3, 402.7], |
| "kv_cache_mb_batch16": [12.6, 25.2, 50.3, 100.7, 201.3, 402.7, 805.3], |
| }, |
| "phi-2": { |
| "model": "phi-2 (2.7B params, 32 layers, 32 heads, head_dim=80)", |
| "seq_lengths": [128, 256, 512, 1024, 2048, 4096, 8192], |
| "model_weights_mb": 5600, |
| "kv_cache_mb_batch1": [20, 41, 82, 164, 328, 655, 1311], |
| "kv_cache_mb_batch8": [164, 328, 655, 1311, 2621, 5243, 10486], |
| "kv_cache_mb_batch16": [328, 655, 1311, 2621, 5243, 10486, 20972], |
| "note": "At batch=16, seq=4096: 10.2GB KV cache alone — exceeds T4 after adding model weights", |
| }, |
| } |
|
|