Vol. 14 · LLM Foundations
LLM Internals: KV Cache & Generation
An in-depth analysis of the mechanics of inference: how exactly KV-cache works and why O(n²) without it, prefill vs decode phases, GPU memory arithmetic, GQA/MQA/PagedAttention, prompt caching at the API level (Anthropic + OpenAI), sampling from logits to token, speculative decoding, and the main case - “mother washed the frame” through LLM step by step.
KV-Cache: mechanics and why without it O(n²)
Decoder models generate one token at a time. Without optimization, each step requires a complete recalculation of attention for ALL previous tokens. 200 response tokens = 200 × N² operations. KV-cache is a solution that turns this into linear cost.
Add output tokens, then compare cached and uncached decode.
- This step
- Cumulative K/V projections
- Reused slots
Source-complete notespractical effect
practical effect
- 10-100× decode phase acceleration
- price: GPU memory - cache grows with length
- cache is stored per-layer, per-head
KV-cache is not a “query cache”, it is a specific data structure: two tensors K and V for each layer, each head, each token. Q is NOT put into the cache - only Q of the new token is needed for the next step.
Source-complete notescache structure
text# Cache form for one model:
kv_cache.shape = [
2, #K and V (two tensors)
num_layers, #32 for Llama-3 8B
num_kv_heads, #8 (GQA) or 32 (MHA)
seq_len, # current length (growing!)
d_head #128 = 4096 / 32
]
# Q is NOT cached because:
# Q is only needed to calculate the current step
# q_i = W_Q t_i - only for new token i
# K and V are ALWAYS needed with each new token
# At each decode step:
kv_cache.K[layer][head].append(k_new)
kv_cache.V[layer][head].append(v_new)
# seq_len increases by 1 → cache growsCausal mask in the decoder-only model provides a key property: when generating token t_i, attention weights to t_i do not affect t₀..t_{i-1} - they have already been generated. This makes caching K and V correct.
As the context window grows, KV-cache grows linearly. At 128K tokens, the cache becomes huge - more than the weights of the model itself. This is what makes long-context inference so expensive.
Source-complete notescache growth at different lengths · why GQA saves the day
text# KV-cache size formula (fp16):
kv_bytes = 2 × L × H_kv × S × d_h × 2
# Llama-3 8B: L=32, H_kv=8 (GQA), d_h=128
S= 4,096: 2 × 32 × 8 × 4096 × 128 × 2 = 537 MB
S= 32,768: 2 × 32 × 8 × 32768 × 128 × 2 = 4.3 GB
S=128,000: 2 × 32 × 8 × 128,000 × 128 × 2= 16.8 GB
↑ MORE models (8GB)
# GPT-4 levels (96 layers, 128 heads, d_h=128, MHA):
S=128,000: 2 × 96 × 128 × 128,000 × 128 × 2= ~640 GB
← requires several A100 only for KV-cache!why GQA saves the day
- GQA: H_kv=8 instead of 32 → 4× smaller cache
- MQA: H_kv=1 → 32× smaller cache
- compromise: slightly worse quality vs MHA
Prefill vs Decode: two different worlds
LLM inference - two stages with opposite load characteristics. Prefill - matmul-bound: processes all prompt tokens in parallel, GPU is busy. Decode - memory-bound: each step reads the entire KV-cache from memory, the GPU is almost idle. Understanding the difference means optimizing correctly.
Source-complete notesBottleneck-type optimizations
Bottleneck-type optimizations
- TTFT → shorten prompt / prompt caching
- TPS → reduce KV-cache (GQA/MQA)
- continuous batching → decode GPU utilization increases
Problem: a long prefill (2K+ tokens) blocks the GPU and delays TTFT for other users in the batch. Chunked prefill splits the prefill into parts, interspersed with decode - improves the uniformity of latency.
Source-complete notesmechanics
text# Without chunked prefill:
User A prefill: [────────────────── 2000 tokens ──]
User B decode: [waiting]
← B is blocked for the entire time prefill A
# With chunked prefill (chunk=256):
User A chunk 1: [──256──]
User B token 1: [─1─]
User A chunk 2: [──256──]
User B token 2: [─1─]
...
← A finishes a little later, but B doesn't freeze
← latency tail (p99) improves significantly
# Implementation: vLLM 0.4+, TensorRT-LLMStatic batching: requests are grouped by N and wait for the entire batch to complete. The problem is that the longest request in the batch makes everyone wait. Continuous batching: requests enter and exit the batch dynamically at each decode step.
Source-complete notestable
| Static | Continuous | |
|---|---|---|
| Batch formation | times in N requests | each decode step |
| New request included | waiting for the next batch | right away |
| GPU utilization | low at different lengths | high |
| Throughput | 2–3× less | maximum |
| Implementation | just | difficult (KV scheduling) |
| Who uses | old systems | vLLM, TGI, TRT-LLM |
Memory Math: GQA, MQA, PagedAttention
Multi-Head Attention keeps separate K and V for each head - maximum quality, maximum KV-cache. GQA and MQA - progressive reduction in the number of K/V goals while maintaining the number of Q goals. Llama-3, Mistral, Gemma use GQA.
Source-complete notesin production
in production
- Llama-3 8B: GQA (32Q / 8KV) → 2024 standard
- Mistral 7B: GQA (32Q / 8KV)
- MQA → niche, GQA displaces
The problem with traditional KV-cache: it is allocated as a contiguous block under max_seq_len. With a response of 512 tokens, a prompt of 100 tokens - 80% of the memory is idle. PagedAttention stores KV in continuous pages of ~16 tokens, allocating on demand.
Source-complete notesmechanics pages · bonus: sharing pages
text# Traditional KV-cache:
kv[req_1] = allocate(max_seq_len=2048) ← reserve everything at once
kv[req_2] = allocate(max_seq_len=2048) ← 2048 more, although 100 are needed
← 90% of GPU memory is fragmented with empty blocks
# PagedAttention:
page_size = 16 # tokens per page
req_1 ← [page_4, page_7, page_12] ← not physically adjacent
req_2 ← [page_1, page_9]
req_3 ← [page_2, page_5, page_11] ← allocated as they grow
# block_table stores the mapping of logical → physical pages
# GPU core vLLM can perform attention on non-adjacent blocks
Result:
memory waste: 30–40% (naive) → < 4% (PagedAttention)
throughput: +2–4× with the same GPUbonus: sharing pages
- common system prompt → same page for all requests
- copy-on-write with branching (beam search)
- vLLM, TGI, SGLang use PagedAttention
An alternative approach: do not store KV for all past tokens - only for the last W. Each token sees only a window from the W previous ones. Mistral 7B uses SWA with W=4096. The cache stops growing after W tokens.
Source-complete notestradeoff · applies
textFull KV-cache:
attention(q_i, K[0..i], V[0..i]) ← all tokens
maximum quality
memory: O(seq_len) → grows without limit
Sliding Window (W=4096):
attention(q_i, K[i-W..i], V[i-W..i]) ← window only
quality: good for local dependencies
memory: O(W) → constant after W tokens!
SWA problem:
token outside window = "forgotten"
distant dependencies (beginning → end of text) are lost
Hybrid: some layers full attention,
part of the SWA (Mistral Mixtral) layersapplies
- Mistral 7B: W=4096 + full for multiple layers
- bad for tasks with long dependencies
- Longformer: SWA + global tokens (hybrid)
Prompt Caching: KV-cache at the API level
A regular KV-cache lives inside a single request. Prompt Caching is a KV-cache between requests at the API level. If many requests have the same prefix (system prompt + RAG documents), the API provider stores them in KV-cache and does not recalculate them. Savings: up to 90% prefill cost and 85% TTFT latency.
Source-complete noteswhen caching is especially effective
when caching is especially effective
- long system prompt (>1024 tokens)
- RAG with fixed documents
- few-shot examples in the prompt
- unique documents in each request - the cache will not help
Anthropic requires explicit marking via cache_control. You decide which blocks to cache. Minimum 1024 tokens per cached block. TTL - 5 minutes (extends with each cache hit).
Source-complete notesrequest structure · Pricing (Claude 3.5 Sonnet)
sqlmessages = client.messages.create(
model = "claude-sonnet-4-6",
system = [
{
"type": "text",
"text": system_prompt, ← static prompt
"cache_control": {"type": "ephemeral"}
}
],
messages = [
{"role": "user", "content": [
{"type": "text", "text": rag_context,
"cache_control": {"type": "ephemeral"}},
{"type": "text", "text": user_query} ← not cacheable
]}
]
)
# Check the result:
usage = messages.usage
usage.cache_read_input_tokens # how many from cache
usage.cache_creation_input_tokens # how many are written to the cache
usage.input_tokens # regular (not cached)Pricing (Claude 3.5 Sonnet)
- cache_write: 125% of input price
- cache_read: 10% of input price (90% discount)
- pays off for >2 requests with one prefix
OpenAI (gpt-4o, gpt-4o-mini from Oct 2024) enables caching automatically - no markup is needed. The cache is activated at a prefix of 1024 tokens and a granularity of 128 tokens. TTL - 5-10 minutes.
Source-complete notesdifferences from Anthropic · practice
textOpenAI - automatically:
✓ No cache_control markup needed
✓ The API itself determines matching prefixes
✓ 50% discount on cached tokens (input)
? There is no explicit control - you don’t know what is cached
# Check in response:
response.usage.prompt_tokens_details
→ {"cached_tokens": 1920, "audio_tokens": 0}
Anthropic - explicit markup:
✓ Full control over which block to cache
✓ 90% discount (vs 50% for OpenAI) → more aggressive
! We need to rebuild the prompt structure
! Minimum 1024 tokens per block
Advice: both providers - install static first,
dynamic (query) to the end. It works for both.practice
- OpenAI - easier to get started, no code changes
- Anthropic - more savings with correct markings
- both: block order is critical - static first
Sampling: from logits to final token
After forward pass, the model returns a vector of logits: one number for each token in the dictionary (128K for GPT-4). From these raw scores, you need to select one token. The chain of operations is deterministic, except for the final sample.
Change both controls and inspect which tokens remain eligible.
Source-complete notestypical settings
typical settings
- code/factual: T=0, greedy (reproducible)
- chat: T=0.7, top_p=0.9
- creative: T=1.0–1.2, top_p=0.95
- T>1.5 → incoherent text
The name "temperature" comes from thermodynamics. At high temperatures, molecules move randomly (high entropy). When low, it is orderly (low entropy). Similarly, temperature controls the entropy of the tokens' probability distribution.
Source-complete notesdistributional effect
text# Initial logits: [8.41, 6.23, 5.87, 4.12]
T=0.3 (cold, “confident”):
scaled: [28.0, 20.8, 19.6, 13.7]
softmax: [0.993, 0.004, 0.002, 0.001]
← almost always "cat"
T=1.0 (neutral, “raw distribution”):
scaled: [8.41, 6.23, 5.87, 4.12]
softmax: [0.617, 0.083, 0.058, 0.011]
← sometimes other options
T=2.0 (hot, “chaotic”):
scaled: [4.21, 3.12, 2.93, 2.06]
softmax: [0.342, 0.189, 0.158, 0.071]
← often unexpected tokens
# T→0: argmax (deterministic)
# T→∞: uniform (everything is equally likely)Three fundamentally different strategies for choosing the next token. Greedy is fast, Beam is high-quality for NMT, Sampling is diverse for generative tasks.
Source-complete notestable · when what
| Strategy | Logic | Plus | Minus |
|---|---|---|---|
| Greedy | argmax at each step | fast, deterministic | local optima |
| Beam (B=4) | store B best ways | globally better greedy | 4x more expensive, repeats |
| Sampling | random from distribution | variety, creative | unstable |
| Top-p sampling | nucleus + chance | balance of quality and diff. | no |
when what
- translation/summarization: Beam B=4
- chat/creative: top-p sampling
- structured output: greedy (T=0)
LLM without additional mer tend to repeat tokens - especially at high temperatures. Repetition penalty reduces the probability of tokens that have already been encountered in the context. Logit bias allows you to force specific tokens to be strengthened or banned.
Source-complete notesmechanics
pythonRepetition penalty (θ = 1.3):
if token_id in context:
logit[token_id] = logit[token_id] / 1.3 ← if > 0
logit[token_id] = logit[token_id] * 1.3 ← if < 0
# 1.0 = no penalty, 1.3 = moderate, 2.0 = aggressive
Logit bias:
# force deny token
logit_bias = {token_id_of_swear: -100} ← = -∞
# force the token to be strengthened
logit_bias = {token_id_of_yes: +10} ← ≈ 20000× more likely
# OpenAI API: logit_bias parameter
# Usage: guardrail for banned words
# or force structured outputGeneration Optimizations: speculative, quantization
The main bottleneck of the decode phase is memory-bound: the GPU is idle waiting for data. Speculative decoding uses this time: a small “draft” model generates K tokens, a large model verifies them in ONE forward pass. Accepted tokens are free. Acceleration 2–3×.
Source-complete noteswhen to use
when to use
- latency is critical, draft and target are one family
- self-hosting: vLLM supports speculative decoding
- low temperature → higher acceptance rate
- when T>1.2: draft is often rejected → no profit
Model parameters are stored in fp16 (2 bytes) or fp32 (4 bytes). Quantization reduces to int8 (1 byte) or int4 (0.5 byte). Llama-3 70B in fp16 = 140GB, int4 = 35GB - fits on two A100 40GB.
Source-complete notesformats and tradeoff · practice
text# Size of Llama-3 70B in different formats:
fp32: 280 GB ← unrealistic
fp16: 140 GB ← training
bf16: 140 GB ← training (better fp16 for large numbers)
int8: 70 GB ← almost no quality loss (LLM.int8)
int4: 35 GB ← GPTQ/AWQ: slight loss of quality
int3: 27 GB ← noticeable loss
int2: 18 GB ← significant loss
# KV-cache can also be quantized:
fp16 → int8: KV-cache 2x smaller
← saves GPU memory for larger batches
← slight drift on long sequencespractice
- int4 (GPTQ/AWQ): deploy 70B on 2×A40
- int8 (bitsandbytes): safe for production
- int4 KV-cache: carefully eval before deployment
The standard implementation of attention materializes a matrix S = QKᵀ of size n×n in HBM - with n=8192 this is 512MB for only one matrix. Flash Attention calculates attention by tile, never materializing the full matrix.
Source-complete noteskey idea · applies
tex# Standard attention (slow, lots of memory):
S = Q @ K.T / sqrt(d_k) # [n × n] in HBM - expensive!
A = softmax(S) # [n × n] in HBM
O=A@V#[n×d] in HBM
Memory: O(n²)
# Flash Attention (Dao et al. 2022):
# Tile algorithm: Q, K, V blocks are loaded into SRAM
# (fast GPU memory), softmax is calculated incrementally
#S never fully materializes
Memory: O(n) ← linear! only SRAM tiles
Result:
Speed: 2–4x faster on A100
Memory: 5–20× less
Numerically identical to standard attentionapplies
- all modern libraries: vLLM, HuggingFace
- critical for long-context (>8K tokens)
- FlashAttention-3 (2024): optimized for H100
Token Embeddings vs Text Embeddings: Don't be confused!
One of the most common conceptual failures: mixing token embeddings (a table inside GPT, transforms ID→vector) and text embeddings (output of an encoder model of type text-embedding-3, representing all text as one vector for search). These are fundamentally different objects with different properties.
Source-complete notesselection rule
selection rule
- RAG/search → text-embedding-3 / bge-m3
- fine-tuning models → token embeddings (inside)
- never use token embeddings from LLM for retrieval
Sometimes LLM hidden states are used as text embeddings - through prompt engineering. It works (LLM-Embedder, E5-mistral), but is niche: expensive (high inference), slow, justified only when maximum semantics is needed.
Source-complete noteswhen it makes sense
text# E5-mistral: prompt engineering for retrieval
prompt = f"Instruct: Given a query, find relevant documents.\n
Query: {query}"
embedding = llm_model.last_hidden_state(prompt)[-1]
← take the last hidden state (pool)
# When justified:
✓ the task requires deep understanding (code, science)
✓ has a GPU, latency < 1s is acceptable
✓ bi-encoder embedding is not enough
# Why standard text-embedding is better in 99% of cases:
✗ LLM inference: 100–500ms vs 5–20ms for bi-encoder
✗ cost 10–50× higher
✗ bge-m3 on MTEB is often no worse than LLM-embeddedEnd-to-End: “mom washed the frame” via GPT
End-to-end parsing of one inference call. Let's take Llama-3 8B - specific numbers, specific forms of tensors. Task: continue the sentence.
Specific numbers help calibrate expectations and explain in interviews why this or that optimization is important. Latency and throughput depend on batch size, length and accuracy.
Source-complete notesindicative metrics · GPU memory budget
text# Llama-3 8B, fp16, A100 80GB SXM, vLLM
Prefill (compute-bound):
1024 prompt tokens: TTFT ≈ 30 ms
4096 prompt tokens: TTFT ≈ 120 ms
16K prompt token: TTFT ≈ 900 ms ← O(n²) hits
Decode (memory-bound):
batch=1: TPS ≈ 80 tok/s
batch=8: TPS ≈ 220 tok/s ← batching helps
batch=32: TPS ≈ 400 tok/s ← saturates HBM bandwidth
Memory:
Model weights: ~16 GB (fp16)
KV-cache @ 4K × batch=8: ~4.3 GB
Activations: ~1–2 GB
Total A100 80GB: fits with a margin
With optimizations:
int4 (AWQ): 4 GB model, batch×2
Speculative: latency 2–3× lower
Prompt cache: TTFT 5–10× lower on hitGPU memory budget
- A100 80GB: 70B fp16 + KV-cache (batch=4)
- A100 40GB: 8B fp16 + KV-cache (batch=16)
- 4×A100: 70B + large batch or long context
No dead end
Keep moving through the map.
Continue in sequence, switch to a related guide, or return to the seven-track learning map.