KV caching and the generation loop
Why inference is affordable. It's a direct consequence of the causal mask, and nobody explains it properly.
Download kv_cache_from_scratch.py — everything below runs from it. NumPy only, random weights, under a second.
This one isn't a paper. It's the engineering consequence of one line in Attention Is All You Need, and it lives in folklore: everyone who has served a model knows it, nobody writes it up. I shipped an inference stack before I could have told you, on a whiteboard, why the cache is allowed to exist. The papers here are the ones that later went and optimised it.
The whole thing is one observation. Generating token n means running attention over all n tokens. Do that naively for a whole sequence and you pay roughly n3. But the causal mask guarantees that token i's key and value never change once computed. So you cache them. The loop becomes n2, and each step is one new row instead of a full matrix.
The KV cache is memoisation
You've written this a hundred times:
@lru_cache
def expensive(x): # safe to cache, because it is PURE:
return f(x) # same x always gives the same answer
And you know the rule: you may only memoise a pure function. Cache something whose answer depends on hidden mutable state and you get the worst class of bug — right answer on Tuesday, wrong answer under load.
A decoder's per-token key and value are that pure function. Token
i's k and v are computed from token
i's embedding and position, and from nothing else that arrives
later. So:
cache = {"K": [], "V": []}
def decode_step(token, pos, cache):
q, k, v = project(embed(token) + pos_enc(pos)) # q is per-step, throwaway
cache["K"].append(k) # append ONE row
cache["V"].append(v)
scores = q @ stack(cache["K"]).T # (1, n), not (n, n)
return softmax(scores) @ stack(cache["V"])
That's it. That's the KV cache. The interesting part is not the code, it's the licence to write it — and the licence comes from the mask.
| Term | Means | Programmer's analogy |
|---|---|---|
| Prefill | Run the whole prompt through once, filling the cache | Warming the cache on startup |
| Decode | One token in, one token out, cache grows by a row | The steady-state request loop |
| KV cache | Every layer's keys and values for every token so far | The memo table |
| Causal mask | Row i may only read columns ≤ i | The proof that the function is pure |
| Eviction | Dropping cache entries when you run out of room | LRU on a bounded cache |
The one formula
Not an attention formula — you already have that. The formula that decides what you can actually serve:
Read it as terms you control:
| Term | Typical | What it is |
|---|---|---|
2 | 2 | K and V. Never Q — Q is used once and thrown away |
layers | 32 | Every layer keeps its own cache. They are not shared |
kv_heads | 32 → 8 → 1 | The only term the architecture papers attack. MHA → GQA → MQA |
head_dim | 128 | dmodel / nheads |
seq_len | 4k…128k | Grows every single decode step. This is the one that hurts |
dtype_bytes | 2 | fp16 or bf16. Quantising the cache to 8 bits halves it |
batch | 1…256 | Every concurrent request pays the whole thing again |
Two loops, and a 34× gap
The script runs both loops over a toy decoder and counts
multiply-accumulates analytically, so the number doesn't depend on how fast
your laptop is. Per token, per layer: 4·d·d for the four
projections, 2·d·n for attention over n cached keys,
2·d·ff for the feed-forward.
Don't take my word for it. Here's the actual output:
toy model: 2 layers, 4 heads, d_model=32, seq=64
naive loop total MACs: 62,566,400
cached loop total MACs: 1,839,104
ratio : 34.0x
per-step cost (MACs), naive grows, cached is flat:
step | naive | cached
1 | 24,704 | 24,704
8 | 204,800 | 25,600
16 | 425,984 | 26,624
32 | 917,504 | 28,672
48 | 1,474,560 | 30,720
64 | 2,097,152 | 32,768 ✓
Look at the two columns. The naive step cost multiplies by 85 between step 1 and step 64. The cached step cost grows by a third — and that residue is real, not rounding. Attention still has to scan every cached key. What went flat is the projections and the feed-forward, which is most of the work.
Same tokens, to float precision
A cache that changes the answer is not a cache, it's an approximation. So the script generates the same 12 tokens both ways and diffs the logits.
naive tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
cached tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
same token sequence? True
max |naive logits - cached logits| = 1.887e-15 ✓
1.9 × 10−15 is float64 noise from doing the additions in a different order. The weights are random and untrained, so the model happily loops on token 16 — that's fine, this page is about compute and memory, not learning. What matters is that both loops loop identically.
The mask is the proof
Here is the part nobody writes down. Why is it legal to keep those rows?
Because with a causal mask, appending a token cannot change any earlier
attention row. Row i has −∞ in every column past
i, so the new column contributes exactly nothing to its softmax
denominator.
Same layer, same head, before and after appending a 5th token:
WITH causal mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[1. 0. 0. 0.]
[0.671 0.329 0. 0. ]
[0.53 0.235 0.235 0. ]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[1. 0. 0. 0. 0.]
[0.671 0.329 0. 0. 0. ]
[0.53 0.235 0.235 0. 0. ]
[0.553 0.162 0.159 0.126 0. ]
max change in the first 4 rows: 0.000e+00 ✓
Exactly zero. Not "small". Now delete the mask — turn the decoder into a BERT-style bidirectional encoder — and run the identical experiment:
NO mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[0.386 0.182 0.305 0.127]
[0.417 0.204 0.261 0.117]
[0.47 0.209 0.208 0.113]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[0.362 0.17 0.285 0.119 0.064]
[0.394 0.193 0.247 0.111 0.054]
[0.448 0.199 0.198 0.107 0.048]
[0.506 0.149 0.146 0.115 0.084]
max change in the first 4 rows: 4.631e-02
now the consequence for a cache:
unmasked model, cached vs recomputed logits, max abs diff: 0.4017
argmax token: recomputed=44 cached=16
Every earlier row moved, because softmax renormalises over the new column. The cached rows are now stale, the logits are off by 0.40, and the model picks a different token. Same code, one boolean flipped.
−∞ matrix at decode time, because the cache only ever holds
the past.
The cache outgrows the model
Now the arithmetic that decides your hardware bill. A Llama-2-7B-shaped model: 32 layers, 32 heads, head_dim 128, fp16.
bytes = 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes
weights (fp16, 6.74B params): 12.55 GiB
cache per token: 524,288 bytes = 512 KiB
seq_len | batch 1 | batch 32 | batch 32 vs weights
1,024 | 0.50 GiB | 16.0 GiB | 1.3x the weights
4,096 | 2.00 GiB | 64.0 GiB | 5.1x the weights
32,768 | 16.00 GiB | 512.0 GiB | 40.8x the weights
131,072 | 64.00 GiB | 2048.0 GiB | 163.2x the weights
Half a megabyte per token. One 128k-token conversation is 64 GiB of cache on its own — five times the weights, and it doesn't fit on an 80 GB card alongside the model. Batch 32 at a mundane 4k context already outweighs the checkpoint. When someone says a model "needs an A100", they're usually quoting the weights, which is the small number.
MQA and GQA: shrink one term
Look at the formula again. layers and head_dim
are the model. seq_len and batch are your users.
That leaves kv_heads, and that is exactly where the papers
went.
Multi-Query Attention (Shazeer, 2019): keep all 32 query heads, but give them one shared key/value head. Grouped-Query Attention (Ainslie et al., 2023) is the middle ground: 8 KV heads, four query heads sharing each. Q is never touched, in either — because Q is never stored.
variant kv_heads | 4k, b32 | 32k, b32 | reduction
multi-head (2017) 32 | 64.0GiB | 512.0GiB | 1x
GQA, 8 groups 8 | 16.0GiB | 128.0GiB | 4x
MQA, 1 kv head 1 | 2.0GiB | 16.0GiB | 32x
same table as bytes per token, batch 1:
multi-head 524,288 bytes/token
GQA-8 131,072 bytes/token
MQA 16,384 bytes/token ✓
The reduction is exactly n_heads / n_kv_heads. No subtlety.
MQA takes 32× off and the GQA paper's ablations show it losing quality;
GQA-8 takes 4× off and lands close to multi-head. That is why
Llama-2 70B uses GQA-8, and why almost every model
released since has too. It's a memory decision that happens to be
architectural.
Prefill is compute-bound, decode is memory-bound
Take a 512-token prompt. Process it in one batched pass, or as 512 sequential decode steps. Same total arithmetic — and completely different machines.
Llama-2-7B-shaped, 512-token prompt (approximate, attn + FFN):
prefill, one batched pass : 3,332,961,730,560 MACs, weights read 1x
512 sequential decode steps: 3,332,961,730,560 MACs, weights read 512x
one decode step : 6,576,668,672 MACs
arithmetic intensity (MACs per byte moved off memory):
prefill : 242.5 MACs/byte ✓
decode : 0.48 MACs/byte
A modern accelerator needs somewhere around 100–300 MACs per byte of memory traffic to keep its arithmetic units busy. Prefill clears that. Decode misses it by more than two orders of magnitude: each step drags all 12.55 GiB of weights across the memory bus to do six billion MACs, which the chip finishes while it's still waiting.
Two consequences you will be asked about. Batching helps throughput, not latency — the weights are in flight anyway, so 32 concurrent requests ride along for nearly free, and each user's token still arrives at the same time. And buying more FLOPs doesn't speed up decode; memory bandwidth and cache size do. Every trick in serving — continuous batching, speculative decoding, quantisation, GQA — is aimed at that one imbalance.
Where PagedAttention comes in
One more problem the formula hides. You don't know how long a reply will be, so the naive implementation reserves a contiguous slab for the maximum length, per request. Most of it is never used, and what's left is fragmented — you have the free bytes, just not in one piece.
PagedAttention (Kwon et al., 2023, the paper behind vLLM) does the obvious thing once you name it: treat the cache like virtual memory. Fixed-size pages, a block table per sequence, allocate on demand, and share pages between sequences with a common prefix. Nothing about attention changes; the storage layout does. That's what made high-throughput serving practical, and it is the reason "KV cache" is a phrase you now hear in ops meetings.
இது ஒரு paper அல்ல. Attention Is All You Need-ல் இருக்கும் ஒரே ஒரு வரியின் engineering விளைவு இது — எழுதப்படாமல் வாய்மொழியாகவே வாழ்கிறது: model serve செய்த எல்லாருக்கும் தெரியும், யாரும் எழுதி வைப்பதில்லை. நான் ஒரு inference stack ship செய்த பிறகுதான், ஒரு whiteboard-ல் நின்று ஏன் இந்த cache-க்கு அனுமதி இருக்கிறது என்று சொல்லக் கற்றுக்கொண்டேன். இங்கே சொல்லப்படும் papers எல்லாம், பிறகு வந்து இதை optimise செய்தவை.
மொத்தமும் ஒரே ஒரு கவனிப்புதான். n-வது token-ஐ உருவாக்க வேண்டுமென்றால் எல்லா n tokens மேலும் attention ஓட வேண்டும். முழு sequence-க்கும் இதை மொள்ளமாகச் செய்தால் தோராயமாக n3 செலவாகும். ஆனால் causal mask ஒரு உத்தரவாதம் தருகிறது: token i-ன் key-யும் value-வும் ஒரு முறை கணக்கிட்ட பிறகு ஒருபோதும் மாறாது. அதனால் அவற்றை cache செய்யலாம். Loop n2 ஆகிறது, ஒவ்வொரு step-ம் முழு matrix-க்குப் பதிலாக ஒரே ஒரு புது row மட்டும்.
KV cache என்பது memoisation
இதை நீங்கள் நூறு முறை எழுதியிருப்பீர்கள்:
@lru_cache
def expensive(x): # cache செய்யலாம், ஏனெனில் இது PURE:
return f(x) # ஒரே x எப்போதும் ஒரே விடை
விதியும் உங்களுக்குத் தெரியும்: pure function-ஐ மட்டுமே memoise செய்ய முடியும். மறைவான mutable state-ஐச் சார்ந்திருக்கும் ஒன்றை cache செய்தால், மிக மோசமான வகை bug கிடைக்கும் — செவ்வாய்க்கிழமை சரியான விடை, load-ல் தவறான விடை.
ஒரு decoder-ன் token-வாரியான key-யும் value-வும் அப்படிப்பட்ட pure function
தான். Token i-ன் k மற்றும் v, அந்த token-ன்
embedding மற்றும் position-ல் இருந்து மட்டுமே கணக்கிடப்படுகின்றன — பிறகு
வரும் எதுவும் அவற்றுள் நுழைவதில்லை. அதனால்:
cache = {"K": [], "V": []}
def decode_step(token, pos, cache):
q, k, v = project(embed(token) + pos_enc(pos)) # q ஒரு step-க்கு மட்டும்
cache["K"].append(k) # ஒரே ஒரு row சேர்
cache["V"].append(v)
scores = q @ stack(cache["K"]).T # (1, n), (n, n) அல்ல
return softmax(scores) @ stack(cache["V"])
அவ்வளவுதான். இதுதான் KV cache. சுவாரஸ்யம் code-ல் இல்லை — அதை எழுதுவதற்கான உரிமம் தான் சுவாரஸ்யம். அந்த உரிமம் mask-ல் இருந்து வருகிறது.
KV cache-nu solradhu memoisation dhaan. Token i-oda key-um value-um oru thadava kanakku pannitta apparam maaradhu, adhaan cache-kku licence.
| சொல் | அர்த்தம் | Programmer உதாரணம் |
|---|---|---|
| Prefill | முழு prompt-ஐயும் ஒரே முறை ஓட்டி cache-ஐ நிரப்புவது | Startup-ல் cache-ஐ சூடாக்குவது |
| Decode | ஒரு token உள்ளே, ஒரு token வெளியே, cache ஒரு row வளரும் | நிலையான request loop |
| KV cache | இதுவரையிலான ஒவ்வொரு token-க்கும், ஒவ்வொரு layer-ன் keys மற்றும் values | Memo table |
| Causal mask | Row i, ≤ i columns-ஐ மட்டுமே படிக்கலாம் | Function pure என்பதற்கான நிரூபணம் |
| Eviction | இடம் தீர்ந்தபோது cache entries-ஐ விட்டுவிடுவது | வரம்புள்ள cache-ல் LRU |
அந்த ஒரு சூத்திரம்
இது attention சூத்திரம் அல்ல — அது ஏற்கனவே உங்களிடம் இருக்கிறது. நீங்கள் உண்மையில் என்ன serve செய்ய முடியும் என்பதைத் தீர்மானிக்கும் சூத்திரம் இது:
உங்கள் கையில் இருக்கும் terms-ஆகப் படியுங்கள்:
| Term | வழக்கமாக | என்ன என்றால் |
|---|---|---|
2 | 2 | K மற்றும் V. Q ஒருபோதும் இல்லை — Q ஒரு முறை பயன்படுத்தி தூக்கியெறியப்படுகிறது |
layers | 32 | ஒவ்வொரு layer-ம் தன் சொந்த cache வைத்திருக்கும். பகிரப்படுவதில்லை |
kv_heads | 32 → 8 → 1 | Architecture papers தாக்கும் ஒரே term. MHA → GQA → MQA |
head_dim | 128 | dmodel / nheads |
seq_len | 4k…128k | ஒவ்வொரு decode step-லும் வளர்கிறது. வலிக்க வைப்பது இதுதான் |
dtype_bytes | 2 | fp16 அல்லது bf16. Cache-ஐ 8 bits-க்கு quantise செய்தால் பாதியாகும் |
batch | 1…256 | ஒவ்வொரு இணையான request-ம் இதை முழுவதுமாக மீண்டும் கட்டுகிறது |
இரண்டு loops, 34× இடைவெளி
Script ஒரு பொம்மை decoder மேல் இரு loops-ஐயும் ஓட்டி, multiply-accumulate
எண்ணிக்கையைக் கணித முறையில் எண்ணுகிறது — அதனால் உங்கள் laptop-ன் வேகம்
எண்ணை மாற்றாது. Token-க்கு, layer-க்கு: நான்கு projections-க்கு
4·d·d, cache-ல் உள்ள n keys மேல் attention-க்கு
2·d·n, feed-forward-க்கு 2·d·ff.
என் பேச்சை நம்ப வேண்டாம். இதோ உண்மையான output:
toy model: 2 layers, 4 heads, d_model=32, seq=64
naive loop total MACs: 62,566,400
cached loop total MACs: 1,839,104
ratio : 34.0x
per-step cost (MACs), naive grows, cached is flat:
step | naive | cached
1 | 24,704 | 24,704
8 | 204,800 | 25,600
16 | 425,984 | 26,624
32 | 917,504 | 28,672
48 | 1,474,560 | 30,720
64 | 2,097,152 | 32,768 ✓
இரண்டு columns-ஐயும் பாருங்கள். Naive step செலவு, step 1-ல் இருந்து step 64 வரை 85 மடங்காகப் பெருகுகிறது. Cached step செலவு மூன்றில் ஒரு பங்கு மட்டுமே வளர்கிறது — அந்த மீதி உண்மையானது, rounding அல்ல. Attention இன்னும் cache-ல் உள்ள ஒவ்வொரு key-ஐயும் scan செய்ய வேண்டும். தட்டையாகிப் போனது projections-ம் feed-forward-ம், அதுதான் பெரும்பாலான வேலை.
அதே tokens, float துல்லியம் வரை
விடையை மாற்றும் cache, cache அல்ல — அது ஒரு approximation. அதனால் script அதே 12 tokens-ஐ இரு வழிகளிலும் உருவாக்கி logits-ஐ ஒப்பிடுகிறது.
naive tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
cached tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
same token sequence? True
max |naive logits - cached logits| = 1.887e-15 ✓
1.9 × 10−15 என்பது கூட்டல்களை வேறு வரிசையில் செய்ததால் வரும் float64 சத்தம். Weights சீரற்றவை, train செய்யப்படாதவை — அதனால் model token 16-ல் சந்தோஷமாக loop அடிக்கிறது. அது பரவாயில்லை; இந்தப் பக்கம் compute மற்றும் memory பற்றியது, கற்றல் பற்றியது அல்ல. முக்கியமானது என்னவென்றால், இரு loops-ம் ஒரே மாதிரி loop அடிக்கின்றன.
Mask தான் நிரூபணம்
இதோ யாரும் எழுதி வைக்காத பகுதி. அந்த rows-ஐ வைத்துக்கொள்வது ஏன் சட்டப்படி
சரி? Causal mask இருக்கும்போது, ஒரு token-ஐச் சேர்ப்பது முந்தைய எந்த
attention row-ஐயும் மாற்ற முடியாது. Row i-ல், i-க்குப் பிறகு
வரும் ஒவ்வொரு column-லும் −∞ இருக்கிறது; அதனால் புதிய column
அதன் softmax வகுஎண்ணுக்கு சரியாக ஒன்றுமே சேர்ப்பதில்லை.
அதே layer, அதே head — 5-வது token சேர்ப்பதற்கு முன், பின்:
WITH causal mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[1. 0. 0. 0.]
[0.671 0.329 0. 0. ]
[0.53 0.235 0.235 0. ]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[1. 0. 0. 0. 0.]
[0.671 0.329 0. 0. 0. ]
[0.53 0.235 0.235 0. 0. ]
[0.553 0.162 0.159 0.126 0. ]
max change in the first 4 rows: 0.000e+00 ✓
சரியாகப் பூஜ்ஜியம். "சிறிது" அல்ல. இப்போது mask-ஐ நீக்குங்கள் — decoder-ஐ BERT பாணி இருவழி encoder ஆக்குங்கள் — அதே சோதனையை மீண்டும் ஓட்டுங்கள்:
NO mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[0.386 0.182 0.305 0.127]
[0.417 0.204 0.261 0.117]
[0.47 0.209 0.208 0.113]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[0.362 0.17 0.285 0.119 0.064]
[0.394 0.193 0.247 0.111 0.054]
[0.448 0.199 0.198 0.107 0.048]
[0.506 0.149 0.146 0.115 0.084]
max change in the first 4 rows: 4.631e-02
now the consequence for a cache:
unmasked model, cached vs recomputed logits, max abs diff: 0.4017
argmax token: recomputed=44 cached=16
முந்தைய ஒவ்வொரு row-ம் நகர்ந்துவிட்டது, ஏனெனில் softmax புதிய column சேர்த்து மீண்டும் normalise செய்கிறது. Cache-ல் இருக்கும் rows இப்போது பழையவை, logits 0.40 அளவு விலகுகின்றன, model வேறொரு token-ஐத் தேர்ந்தெடுக்கிறது. அதே code, ஒரே ஒரு boolean மாற்றம்.
−∞ matrix கூடத் தேவையில்லை, ஏனெனில் cache-ல் எப்போதும்
கடந்த காலம் மட்டுமே இருக்கும்.
Mask irundha, pudhu token sethaalum pazhaya rows adhe dhaan — maatram sariyaa poojyam. Mask-ai eduthuttaa cache pazhasaayidum, adhaan BERT-kku KV cache illa.
Cache, model-ஐ விடப் பெரிதாகிறது
இப்போது உங்கள் hardware bill-ஐத் தீர்மானிக்கும் கணக்கு. Llama-2-7B வடிவ model: 32 layers, 32 heads, head_dim 128, fp16.
bytes = 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes
weights (fp16, 6.74B params): 12.55 GiB
cache per token: 524,288 bytes = 512 KiB
seq_len | batch 1 | batch 32 | batch 32 vs weights
1,024 | 0.50 GiB | 16.0 GiB | 1.3x the weights
4,096 | 2.00 GiB | 64.0 GiB | 5.1x the weights
32,768 | 16.00 GiB | 512.0 GiB | 40.8x the weights
131,072 | 64.00 GiB | 2048.0 GiB | 163.2x the weights
Token-க்கு அரை megabyte. 128k tokens கொண்ட ஒரே ஒரு உரையாடல் மட்டுமே 64 GiB cache — weights-ஐ விட ஐந்து மடங்கு, 80 GB card-ல் model-உடன் சேர்ந்து அடங்காது. சாதாரண 4k context-ல் batch 32 என்பதே checkpoint-ஐ விடப் பெரிது. ஒரு model-க்கு "A100 வேண்டும்" என்று யாராவது சொன்னால், அவர்கள் பொதுவாக weights-ஐத்தான் மேற்கோள் காட்டுகிறார்கள் — அதுதான் சிறிய எண்.
MQA மற்றும் GQA: ஒரு term-ஐச் சுருக்குங்கள்
சூத்திரத்தை மீண்டும் பாருங்கள். layers-ம்
head_dim-ம் model. seq_len-ம்
batch-ம் உங்கள் users. மீதி kv_heads மட்டும்தான் —
papers சரியாக அங்கேதான் போயின.
Multi-Query Attention (Shazeer, 2019): 32 query heads-ஐயும் வைத்துக்கொள்ளுங்கள், ஆனால் அவற்றுக்கு ஒரே ஒரு பகிரப்பட்ட key/value head கொடுங்கள். Grouped-Query Attention (Ainslie et al., 2023) நடுவழி: 8 KV heads, ஒவ்வொன்றையும் நான்கு query heads பகிர்ந்துகொள்கின்றன. இரண்டிலும் Q தொடப்படுவதே இல்லை — ஏனெனில் Q சேமிக்கப்படுவதே இல்லை.
variant kv_heads | 4k, b32 | 32k, b32 | reduction
multi-head (2017) 32 | 64.0GiB | 512.0GiB | 1x
GQA, 8 groups 8 | 16.0GiB | 128.0GiB | 4x
MQA, 1 kv head 1 | 2.0GiB | 16.0GiB | 32x
same table as bytes per token, batch 1:
multi-head 524,288 bytes/token
GQA-8 131,072 bytes/token
MQA 16,384 bytes/token ✓
குறைப்பு சரியாக n_heads / n_kv_heads. நுட்பம் ஒன்றுமில்லை. MQA
32 மடங்கு குறைக்கிறது, ஆனால் GQA paper-ன் ablations அது தரத்தை இழப்பதைக்
காட்டுகின்றன; GQA-8 நான்கு மடங்கு குறைத்து multi-head-க்கு அருகிலேயே
நிற்கிறது. அதனால்தான் Llama-2 70B GQA-8
பயன்படுத்துகிறது, அதன் பிறகு வெளியான கிட்டத்தட்ட எல்லா model-ம் அப்படியே.
இது memory முடிவு, தற்செயலாக architecture-ஆகிவிட்டது.
Prefill compute-bound, decode memory-bound
512 tokens கொண்ட ஒரு prompt-ஐ எடுங்கள். ஒரே batched pass-ஆக ஓட்டுங்கள், அல்லது 512 வரிசையான decode steps-ஆக ஓட்டுங்கள். மொத்த கணக்கு ஒன்றுதான் — ஆனால் முற்றிலும் வேறு இயந்திரங்கள்.
Llama-2-7B-shaped, 512-token prompt (approximate, attn + FFN):
prefill, one batched pass : 3,332,961,730,560 MACs, weights read 1x
512 sequential decode steps: 3,332,961,730,560 MACs, weights read 512x
one decode step : 6,576,668,672 MACs
arithmetic intensity (MACs per byte moved off memory):
prefill : 242.5 MACs/byte ✓
decode : 0.48 MACs/byte
நவீன accelerator-க்கு, தன் கணக்கு அலகுகளை வேலையில் வைத்திருக்க, நகர்த்தும் ஒவ்வொரு byte-க்கும் ஏறத்தாழ 100–300 MACs தேவை. Prefill அதைத் தாண்டுகிறது. Decode இரண்டு அளவுகோல்களுக்கும் மேலாகத் தவறவிடுகிறது: ஒவ்வொரு step-லும் 12.55 GiB weights முழுவதையும் memory bus வழியாக இழுத்து ஆறு பில்லியன் MACs செய்கிறது — chip அதை முடித்துவிட்டு இன்னும் காத்திருக்கும்.
உங்களிடம் கேட்கப்படும் இரண்டு விளைவுகள். Batching throughput-ஐ உயர்த்தும், latency-ஐ அல்ல — weights எப்படியும் பயணத்தில் இருக்கின்றன, அதனால் 32 இணையான requests கிட்டத்தட்ட இலவசமாக உடன் வருகின்றன, ஒவ்வொரு user-ன் token-ம் அதே நேரத்திலேயே வந்துவிடும். மேலும் அதிக FLOPs வாங்குவது decode-ஐ வேகப்படுத்தாது; memory bandwidth-ம் cache அளவும்தான் வேகப்படுத்தும். Serving-ல் இருக்கும் ஒவ்வொரு தந்திரமும் — continuous batching, speculative decoding, quantisation, GQA — அந்த ஒரே சமநிலையின்மையை நோக்கியதுதான்.
Prefill-la chip kanakku panni ozhaikkum, decode-la weights-ai memory-la irundhu izhukkaradhula neram poyidum. Adhaan batching throughput-ai mattum kootuthu, latency-ai illa.
PagedAttention எங்கே வருகிறது
சூத்திரம் மறைக்கும் இன்னொரு பிரச்சனை. பதில் எவ்வளவு நீளமாக இருக்கும் என்று உங்களுக்குத் தெரியாது; அதனால் மொள்ளமான implementation ஒவ்வொரு request-க்கும் அதிகபட்ச நீளத்துக்கான தொடர்ச்சியான இடத்தை முன்பதிவு செய்கிறது. அதில் பெரும்பாலானது ஒருபோதும் பயன்படுவதில்லை, மீதி துண்டு துண்டாகிறது — free bytes உங்களிடம் இருக்கின்றன, ஆனால் ஒரே துண்டாக இல்லை.
PagedAttention (Kwon et al., 2023, vLLM-க்குப் பின்னால் இருக்கும் paper) பெயர் வைத்தவுடன் தெளிவாகும் செயலைச் செய்கிறது: cache-ஐ virtual memory போலப் பாவியுங்கள். நிலையான அளவு pages, ஒவ்வொரு sequence-க்கும் ஒரு block table, தேவைப்படும்போது ஒதுக்கீடு, பொதுவான prefix கொண்ட sequences-க்கு இடையே pages-ஐப் பகிர்தல். Attention-ல் எதுவும் மாறவில்லை; சேமிப்பு அமைப்பு மட்டும் மாறுகிறது. அதுதான் அதிக throughput serving-ஐ நடைமுறையாக்கியது, "KV cache" என்ற சொல் இப்போது ops meetings-ல் கேட்பதற்கும் அதுதான் காரணம்.
यह कोई paper नहीं है। यह Attention Is All You Need की एक ही लाइन का engineering नतीजा है, और यह लोककथा की तरह जीता है: जिसने भी कोई model serve किया है वह इसे जानता है, लिखता कोई नहीं। मैंने एक inference stack ship कर दिया था, उससे पहले मैं whiteboard पर खड़ा होकर यह नहीं बता सकता था कि इस cache को रखने की इजाज़त क्यों है। यहाँ जो papers हैं, वे बाद में आकर इसे optimise करने वाले हैं।
पूरी बात एक ही observation है। nवाँ token बनाने का मतलब है सारे n tokens पर attention चलाना। पूरे sequence के लिए यह भोलेपन से कीजिए तो क़ीमत लगभग n3 पड़ती है। लेकिन causal mask यह गारंटी देता है कि token i की key और value एक बार बन जाने के बाद कभी नहीं बदलतीं। इसलिए उन्हें cache कर लीजिए। Loop n2 हो जाता है, और हर step पूरी matrix की जगह सिर्फ़ एक नई row है।
KV cache असल में memoisation है
यह आपने सौ बार लिखा है:
@lru_cache
def expensive(x): # cache करना सुरक्षित है, क्योंकि यह PURE है:
return f(x) # वही x हमेशा वही जवाब देता है
और नियम भी आप जानते हैं: सिर्फ़ pure function को ही memoise किया जा सकता है। जिसका जवाब किसी छुपे हुए mutable state पर टिका हो, उसे cache कीजिए और आपको सबसे बुरी क़िस्म का bug मिलेगा — मंगलवार को सही जवाब, load पर ग़लत।
एक decoder की प्रति-token key और value ठीक वैसा ही pure function हैं। Token
i की k और v उसी token के embedding और
position से बनती हैं, और बाद में आने वाली किसी चीज़ से नहीं। तो:
cache = {"K": [], "V": []}
def decode_step(token, pos, cache):
q, k, v = project(embed(token) + pos_enc(pos)) # q हर step का, फेंक देना है
cache["K"].append(k) # सिर्फ़ एक row जोड़ो
cache["V"].append(v)
scores = q @ stack(cache["K"]).T # (1, n), न कि (n, n)
return softmax(scores) @ stack(cache["V"])
बस इतना ही। यही KV cache है। दिलचस्प हिस्सा code नहीं है, दिलचस्प है इसे लिखने का लाइसेंस — और वह लाइसेंस mask से आता है।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Prefill | पूरे prompt को एक बार चलाकर cache भर देना | Startup पर cache गरम करना |
| Decode | एक token अंदर, एक token बाहर, cache एक row बढ़ता है | स्थिर अवस्था का request loop |
| KV cache | अब तक के हर token के लिए, हर layer की keys और values | Memo table |
| Causal mask | Row i सिर्फ़ ≤ i columns पढ़ सकती है | इस बात का सबूत कि function pure है |
| Eviction | जगह ख़त्म होने पर cache entries हटाना | सीमित cache पर LRU |
वह एक सूत्र
यह attention का सूत्र नहीं है — वह आपके पास पहले से है। यह वह सूत्र है जो तय करता है कि आप असल में serve क्या कर सकते हैं:
इसे उन terms की तरह पढ़िए जो आपके हाथ में हैं:
| Term | आम तौर पर | यह है क्या |
|---|---|---|
2 | 2 | K और V। Q कभी नहीं — Q एक बार इस्तेमाल होकर फेंक दिया जाता है |
layers | 32 | हर layer अपना अलग cache रखती है। ये साझा नहीं होते |
kv_heads | 32 → 8 → 1 | architecture papers सिर्फ़ इसी term पर हमला करते हैं। MHA → GQA → MQA |
head_dim | 128 | dmodel / nheads |
seq_len | 4k…128k | हर decode step पर बढ़ता है। दर्द यही देता है |
dtype_bytes | 2 | fp16 या bf16। Cache को 8 bits में quantise करने पर आधा |
batch | 1…256 | हर समानांतर request पूरी क़ीमत दोबारा चुकाती है |
दो loops, और 34× का फ़ासला
Script एक खिलौना decoder पर दोनों loops चलाती है और multiply-accumulates
गणितीय ढंग से गिनती है, ताकि यह संख्या आपके laptop की रफ़्तार पर न टिके।
प्रति token, प्रति layer: चार projections के लिए 4·d·d,
cache में रखी n keys पर attention के लिए 2·d·n, और
feed-forward के लिए 2·d·ff।
मेरी बात पर भरोसा मत कीजिए। यह रहा असली output:
toy model: 2 layers, 4 heads, d_model=32, seq=64
naive loop total MACs: 62,566,400
cached loop total MACs: 1,839,104
ratio : 34.0x
per-step cost (MACs), naive grows, cached is flat:
step | naive | cached
1 | 24,704 | 24,704
8 | 204,800 | 25,600
16 | 425,984 | 26,624
32 | 917,504 | 28,672
48 | 1,474,560 | 30,720
64 | 2,097,152 | 32,768 ✓
दोनों columns देखिए। Naive step की लागत step 1 से step 64 के बीच 85 गुना हो जाती है। Cached step की लागत एक तिहाई बढ़ती है — और वह बची हुई बढ़त असली है, rounding नहीं। Attention को अब भी cache की हर key scan करनी पड़ती है। जो सपाट हुआ वह projections और feed-forward है, यानी अधिकांश काम।
वही tokens, float की सटीकता तक
जो cache जवाब बदल दे वह cache नहीं, approximation है। इसलिए script वही 12 tokens दोनों तरीक़ों से बनाती है और logits का अंतर निकालती है।
naive tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
cached tokens: [3, 17, 42, 8, 16, 16, 16, 16, 44, 44, 16, 16, 16, 16, 44, 16]
same token sequence? True
max |naive logits - cached logits| = 1.887e-15 ✓
1.9 × 10−15 सिर्फ़ float64 का शोर है, जो जोड़ों का क्रम बदलने से आता है। Weights यादृच्छिक और अप्रशिक्षित हैं, इसलिए model मज़े से token 16 पर loop मारता है — कोई बात नहीं, यह पन्ना compute और memory के बारे में है, सीखने के बारे में नहीं। अहम यह है कि दोनों loops एक जैसे ही चलते हैं।
Mask ही सबूत है
यह रहा वह हिस्सा जो कोई लिखता नहीं। उन rows को रख लेना जायज़ क्यों है?
क्योंकि causal mask के साथ, एक token जोड़ने से पहले की कोई भी attention row
बदल नहीं सकती। Row i में i के बाद के हर column में
−∞ है, इसलिए नया column उसके softmax हर में बिल्कुल कुछ नहीं
जोड़ता।
वही layer, वही head — 5वाँ token जोड़ने से पहले और बाद:
WITH causal mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[1. 0. 0. 0.]
[0.671 0.329 0. 0. ]
[0.53 0.235 0.235 0. ]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[1. 0. 0. 0. 0.]
[0.671 0.329 0. 0. 0. ]
[0.53 0.235 0.235 0. 0. ]
[0.553 0.162 0.159 0.126 0. ]
max change in the first 4 rows: 0.000e+00 ✓
बिल्कुल शून्य। "थोड़ा सा" नहीं। अब mask हटा दीजिए — decoder को BERT जैसा द्विदिश encoder बना दीजिए — और वही प्रयोग दोबारा चलाइए:
NO mask -- layer 0, head 0 attention weights
before appending a 5th token (4x4):
[0.386 0.182 0.305 0.127]
[0.417 0.204 0.261 0.117]
[0.47 0.209 0.208 0.113]
[0.553 0.162 0.159 0.126]
after appending a 5th token, first 4 rows (4x5):
[0.362 0.17 0.285 0.119 0.064]
[0.394 0.193 0.247 0.111 0.054]
[0.448 0.199 0.198 0.107 0.048]
[0.506 0.149 0.146 0.115 0.084]
max change in the first 4 rows: 4.631e-02
now the consequence for a cache:
unmasked model, cached vs recomputed logits, max abs diff: 0.4017
argmax token: recomputed=44 cached=16
पहले की हर row हिल गई, क्योंकि softmax नए column को मिलाकर दोबारा normalise करता है। Cache में रखी rows अब बासी हैं, logits 0.40 भटक गए, और model दूसरा token चुन लेता है। वही code, बस एक boolean पलटा।
−∞ वाली matrix की ज़रूरत भी नहीं रहती, क्योंकि cache में हमेशा
सिर्फ़ अतीत होता है।
Cache, model से बड़ा हो जाता है
अब वह हिसाब जो आपका hardware bill तय करता है। Llama-2-7B जैसा model: 32 layers, 32 heads, head_dim 128, fp16।
bytes = 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes
weights (fp16, 6.74B params): 12.55 GiB
cache per token: 524,288 bytes = 512 KiB
seq_len | batch 1 | batch 32 | batch 32 vs weights
1,024 | 0.50 GiB | 16.0 GiB | 1.3x the weights
4,096 | 2.00 GiB | 64.0 GiB | 5.1x the weights
32,768 | 16.00 GiB | 512.0 GiB | 40.8x the weights
131,072 | 64.00 GiB | 2048.0 GiB | 163.2x the weights
आधा megabyte प्रति token। 128k tokens की एक ही बातचीत अकेले 64 GiB cache है — weights से पाँच गुना, और वह 80 GB के card पर model के साथ अँटती नहीं। मामूली 4k context पर batch 32 पहले ही checkpoint से भारी है। जब कोई कहता है कि किसी model को "A100 चाहिए", तो वह आम तौर पर weights का आँकड़ा बोल रहा होता है — और वही छोटी संख्या है।
MQA और GQA: एक term घटाइए
सूत्र फिर देखिए। layers और head_dim model हैं।
seq_len और batch आपके users हैं। बचा
kv_heads — और papers ठीक वहीं गए।
Multi-Query Attention (Shazeer, 2019): सारे 32 query heads रखिए, पर उन्हें एक साझा key/value head दीजिए। Grouped-Query Attention (Ainslie et al., 2023) बीच का रास्ता है: 8 KV heads, हर एक को चार query heads साझा करते हैं। दोनों में Q को हाथ तक नहीं लगाया जाता — क्योंकि Q कभी stored ही नहीं होता।
variant kv_heads | 4k, b32 | 32k, b32 | reduction
multi-head (2017) 32 | 64.0GiB | 512.0GiB | 1x
GQA, 8 groups 8 | 16.0GiB | 128.0GiB | 4x
MQA, 1 kv head 1 | 2.0GiB | 16.0GiB | 32x
same table as bytes per token, batch 1:
multi-head 524,288 bytes/token
GQA-8 131,072 bytes/token
MQA 16,384 bytes/token ✓
कटौती ठीक n_heads / n_kv_heads है। कोई बारीक़ी नहीं। MQA 32
गुना घटाता है और GQA paper के ablations दिखाते हैं कि उसमें गुणवत्ता गिरती
है; GQA-8 चार गुना घटाता है और multi-head के क़रीब ही रहता है। इसीलिए
Llama-2 70B GQA-8 इस्तेमाल करता है, और इसीलिए उसके
बाद रिलीज़ हुआ लगभग हर model भी। यह memory का फ़ैसला है जो संयोग से
architecture बन गया।
Prefill compute-bound है, decode memory-bound
512 tokens का एक prompt लीजिए। उसे एक batched pass में चलाइए, या 512 क्रमिक decode steps की तरह। कुल गणित वही — और मशीनें बिल्कुल अलग।
Llama-2-7B-shaped, 512-token prompt (approximate, attn + FFN):
prefill, one batched pass : 3,332,961,730,560 MACs, weights read 1x
512 sequential decode steps: 3,332,961,730,560 MACs, weights read 512x
one decode step : 6,576,668,672 MACs
arithmetic intensity (MACs per byte moved off memory):
prefill : 242.5 MACs/byte ✓
decode : 0.48 MACs/byte
आधुनिक accelerator को अपनी गणित इकाइयाँ व्यस्त रखने के लिए memory से हिलाए गए हर byte पर लगभग 100–300 MACs चाहिए। Prefill यह पार कर लेता है। Decode इससे दो से ज़्यादा order of magnitude पीछे रह जाता है: हर step पूरे 12.55 GiB weights को memory bus पर घसीटता है ताकि छह अरब MACs कर सके, जिन्हें chip इंतज़ार करते-करते ही निपटा देता है।
दो नतीजे, जो आपसे पूछे जाएँगे। Batching throughput बढ़ाता है, latency नहीं — weights तो वैसे भी रास्ते में हैं, इसलिए 32 समानांतर requests लगभग मुफ़्त में साथ चली आती हैं, और हर user का token उसी समय पर पहुँचता है। और ज़्यादा FLOPs ख़रीदने से decode तेज़ नहीं होता; memory bandwidth और cache का आकार तेज़ करते हैं। Serving की हर तरकीब — continuous batching, speculative decoding, quantisation, GQA — उसी एक असंतुलन पर निशाना है।
PagedAttention कहाँ आता है
एक और समस्या, जिसे सूत्र छिपा लेता है। आपको पता नहीं होता कि जवाब कितना लंबा होगा, इसलिए भोली implementation हर request के लिए अधिकतम लंबाई जितनी लगातार जगह पहले से रोक लेती है। उसका ज़्यादातर हिस्सा कभी काम नहीं आता, और जो बचता है वह टुकड़ों में बँट जाता है — खाली bytes आपके पास हैं, बस एक साथ नहीं।
PagedAttention (Kwon et al., 2023, वह paper जिस पर vLLM खड़ा है) वही करता है जो नाम रखते ही ज़ाहिर हो जाता है: cache को virtual memory की तरह बरतिए। तय आकार के pages, हर sequence के लिए एक block table, ज़रूरत पर allocation, और साझा prefix वाले sequences के बीच pages साझा। Attention में कुछ नहीं बदलता; storage का ढाँचा बदलता है। यही वह चीज़ है जिसने high-throughput serving को व्यवहारिक बनाया, और यही वजह है कि "KV cache" अब ops meetings में सुनाई देने वाला वाक्यांश है।
The code
Pure NumPy. No torch, no training — a two-layer decoder with random weights, because the point is compute and memory, not learning. Runs in about a quarter of a second.
Download the full script — 481 lines plus 7 runnable demos. Then:
python3 kv_cache_from_scratch.py
def forward_full(model, cfg, ids, use_mask=True):
"""Run the whole prefix through every layer. Returns (n, vocab) logits."""
n = len(ids)
x = model["emb"][ids] + model["pos"][:n]
mask = np.tril(np.ones((n, n), dtype=bool)) if use_mask else None
for L in model["layers"]:
h = layernorm(x)
Q = split_heads(h @ L["Wq"], cfg) # recomputed every step
K = split_heads(h @ L["Wk"], cfg) # recomputed every step -- waste
V = split_heads(h @ L["Wv"], cfg) # recomputed every step -- waste
scores = Q @ K.transpose(0, 2, 1) / np.sqrt(cfg.head_dim)
if mask is not None:
scores = np.where(mask, scores, -np.inf)
x = x + merge_heads(softmax(scores) @ V) @ L["Wo"]
h = layernorm(x)
x = x + np.maximum(h @ L["W1"], 0) @ L["W2"]
return layernorm(x) @ model["head"]
def new_cache(cfg):
"""Per layer: K and V, each (n_heads, seq_so_far, head_dim). Never Q."""
return [{"K": np.zeros((cfg.n_heads, 0, cfg.head_dim)),
"V": np.zeros((cfg.n_heads, 0, cfg.head_dim))}
for _ in range(cfg.n_layers)]
def decode_step_cached(model, cfg, token_id, pos, cache, append=True):
"""One token in, one row of logits out. `pos` indexes the position table."""
if pos >= cfg.max_ctx:
raise IndexError(f"position {pos} past context window {cfg.max_ctx}")
x = model["emb"][[token_id]] + model["pos"][[pos]] # (1, d_model)
for L, c in zip(model["layers"], cache):
h = layernorm(x)
q = split_heads(h @ L["Wq"], cfg) # (H, 1, hd) -- NOT cached
k = split_heads(h @ L["Wk"], cfg) # (H, 1, hd) -- appended
v = split_heads(h @ L["Wv"], cfg) # (H, 1, hd) -- appended
if append:
c["K"] = np.concatenate([c["K"], k], axis=1)
c["V"] = np.concatenate([c["V"], v], axis=1)
# No mask needed: the cache only ever holds the past. The mask has
# become a data-structure invariant instead of a matrix of -inf.
scores = q @ c["K"].transpose(0, 2, 1) / np.sqrt(cfg.head_dim)
x = x + merge_heads(softmax(scores) @ c["V"]) @ L["Wo"]
h = layernorm(x)
x = x + np.maximum(h @ L["W1"], 0) @ L["W2"]
return (layernorm(x) @ model["head"])[0] # (vocab,)
def macs_per_token(cfg, n_ctx):
d, ff = cfg.d_model, cfg.d_ff
return cfg.n_layers * (4 * d * d + 2 * d * n_ctx + 2 * d * ff)
def macs_cached_step(cfg, n_cached):
"""One new token attending over n_cached keys. Flat in the projections."""
return macs_per_token(cfg, n_cached)
def macs_naive_step(cfg, n_ctx):
"""Recompute all n_ctx tokens from scratch. Linear in n_ctx, per step."""
return n_ctx * macs_per_token(cfg, n_ctx)
def kv_cache_bytes(layers, kv_heads, head_dim, seq_len, dtype_bytes=2,
batch=1):
return 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes * batch
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — and every row below is a bug that has shipped to production somewhere, in a real serving stack, written by someone competent.
| Break this | Watch for |
|---|---|
Pass use_mask=False to forward_full in demo 3 |
Earlier attention rows shift by 4.631e−02, cached logits drift 0.4017, and the argmax flips from token 44 to 16. The cache was never valid without the mask. |
Run generate_cached(..., append=False) — skip c["K"] = np.concatenate(...) |
The model attends only to the prompt and freezes: it generates token 16 eight times, 1 distinct token instead of 2. It never sees what it just wrote. |
Also cache q and reuse it next step |
Nothing to reuse — ||q|| is 5.414, 5.678, 5.368, 6.161, a new value every step, read exactly once. K and V get read again at every future step; Q does not. That asymmetry is the whole reason the cache holds two tensors, not three. |
Generate past cfg.max_ctx with no eviction |
IndexError: position 128 past context window 128. In a real stack with a wrapping position table you get no error at all — just positions silently colliding and output turning to mush. |
Hand sequence B's warm cache to sequence A: generate_cached(..., cache=warm) |
Prompt [3, 17, 42, 8] generates [44, 44, 44, 16, 16, 44, 44, 23] instead of [16, 16, 16, 16, 44, 44, 16, 16] — it is attending to the other user's tokens. This is the classic serving bug, and it leaks context between requests. |
On the maths anxiety
There is almost no maths here, and that's the point. You need matrix shapes,
and you need to notice that softmax normalises over a row — which is the one
fact that makes the −∞ columns contribute nothing. That's the
entire proof of correctness for the KV cache.
Everything else on this page is counting. Multiply-accumulates per token. Bytes per token. Bytes per byte moved. It's the same arithmetic you already do when you size a Redis instance or work out whether a query fits in the page cache — and it is genuinely more useful, day to day, than being able to derive backprop.
What this page skips: the actual attention kernel. I compute the full
(1, n) score row in NumPy and materialise it. Real
implementations use FlashAttention-style tiling so the scores never hit
memory at all, which is a separate and orthogonal optimisation — that one is
about the working set, this one is about the history. I
also skip quantised caches, sliding-window and streaming attention, and
cross-attention caches in encoder-decoder models.
Where to go next
This page gave you one thing: the mask as a purity proof, plus the arithmetic that follows. These three go where I stopped.
-
Attention Is All You Need
The paper never mentions a KV cache — it's about training, where you see the whole sequence at once. Read the masked-attention paragraphs in the model section anyway, because that mask is the only reason inference is cheap. Everything on this page is a consequence the authors didn't need to state.
-
vLLM: easy, fast and cheap LLM serving with PagedAttention
The fragmentation problem I described in one paragraph, drawn properly: the block table, pages shared between sequences with a common prefix, and the memory waste it removes. Read this before the arXiv version — it's the same idea with pictures.
-
Large Transformer Model Inference Optimization
The map of everything I called "orthogonal": quantisation, pruning, sparsity, distillation, MQA, FlashAttention. Go here when you want to know which lever to pull next, and why decode being memory-bound is what makes each of them worth pulling.
-
How to Scale Your Model: inference
My arithmetic-intensity numbers, done properly and on real accelerators — where the compute-bound/memory-bound crossover actually sits, and how that decides your batch size and your sharding. This is the rigorous version of my last section.