Multi-head Latent Attention: the KV cache, compressed
The sequel to the KV-cache page. Same formula, one term attacked from a completely different direction.
Download mla_from_scratch.py — everything below runs from it. NumPy only, under a second.
The KV-cache page ends on the number that decides what you can serve: bytes per token. I'm not going to re-derive the cache here — go read that first if you haven't, because everything below assumes it. This page is about one line in that formula.
Multi-query and grouped-query attention attack kv_heads by
sharing keys and values across heads, and the GQA paper's own ablations show
them losing quality doing it — that's the trade
Llama-2 70B took with GQA-8. MLA doesn't touch
kv_heads. It changes what a cache entry is.
Store the archive, not the files
You've made this call before. A directory of files is too big to keep around, so you tar and gzip it:
store(tar_gz(files)) # small on disk
files = untar(gunzip(archive)) # pay CPU to get them back
The usual objection is that decompression costs you on every read. Now the twist. Suppose the only thing you ever do with the decompressed files is feed them into one specific function — and that function is a matrix multiply, and so is the decompression:
scores = (x @ W_q) @ (c @ W_uk).T # decompress c into keys, then dot
scores = x @ (W_q @ W_uk.T) @ c.T # same thing. Fold the two matrices
W_abs = W_q @ W_uk.T # once, at load. Now:
scores = (x @ W_abs) @ c.T # the keys are NEVER built
That's the whole paper. The decompression step folds into the operation you were going to do anyway, so at inference it costs nothing. Matrix multiplication being associative is not a deep fact. Noticing that it makes a 57× cache reduction free is.
| Term | Means | Programmer's analogy |
|---|---|---|
| Latent c | One small vector per token per layer, x @ W_dkv | The gzip archive. The only thing on disk |
| dc | How many numbers the latent has. 512 in DeepSeek-V2 | Compression level |
| Wuk, Wuv | Up-projections: latent → keys, latent → values | gunzip, twice, for two different outputs |
| Absorption | Folding Wuk into Wq once at load | Constant folding two adjacent multiplies |
| Decoupled RoPE dims | A small extra block that carries position, cached separately | The one field you can't compress because it's indexed |
The one formula
Not an attention formula. The cache formula from the previous page, and what MLA does to it:
MLA: layers × (dc + drope) × seq × dtype
Two things changed. Read them as terms:
| Term | Value | What it does |
|---|---|---|
2 | gone | K and V are compressed jointly. One latent serves both, so there is no separate V to store |
kv_heads × head_dim | gone | Replaced by dc. The latent is shared across every head, so adding heads no longer adds cache |
dc | 512 | The latent width. This is the dial. DeepSeek-V2 runs 512 against 128 × 128 = 16384 expanded dimensions |
drope | 64 | The decoupled position block. Also shared across heads. Exists only because RoPE breaks the trick — see below |
layers, seq, dtype | unchanged | Still the model, still your users, still fp16 |
The table that is the whole argument
Same arithmetic as the KV-cache page — I reuse its kv_cache_bytes
function unchanged — plus one new line for MLA. Two shapes: a Llama-2-7B one
you already know, and a DeepSeek-V2 one.
Don't take my word for it. Here's the actual output:
Llama-2-7B shape: layers=32 heads=32 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 524,288 bytes/token 1.0x
grouped-query 8: 131,072 bytes/token 4.0x smaller
MLA : 36,864 bytes/token 14.2x smaller
MLA vs GQA-8 : 3.6x smaller
DeepSeek-V2 shape: layers=60 heads=128 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 3,932,160 bytes/token 1.0x
grouped-query 8: 245,760 bytes/token 16.0x smaller
MLA : 69,120 bytes/token 56.9x smaller ✓
MLA vs GQA-8 : 3.6x smaller
Look at what happens when the head count goes from 32 to 128. Multi-head
cache goes up 7.5×, to 3.75 MiB per token per sequence — unservable, which
is why nobody builds that. GQA-8 only doubles, and that doubling comes from
the layer count (32 to 60), not the heads — it pinned kv_heads
at 8. MLA doesn't move at all: 576 floats per
layer regardless of how many heads read them. That invariance is the
property, and it's what lets DeepSeek run 128 heads in the first place.
Is it still attention? At full rank, exactly
A cache that changes the answer isn't a cache. So the script builds one toy model, runs textbook multi-head attention through it, then runs the same inputs through the down-projection and up-projection, and diffs.
One honesty note: to make the rank sweep mean anything I factor the joint
[W_k | W_v] with an SVD and shape its singular values to decay,
because trained weight matrices have a decaying spectrum and freshly
randomised ones don't.
d_model=32 n_heads=4 head_dim=8
full-rank latent d_c = 32
max |standard - through the latent| = 7.772e-15 <- float noise ✓
identical to 1e-10? True
now shrink the latent and watch what you are actually trading.
errors are relative: max abs error / max abs value.
d_c | K reconstruction | attention output | cache bytes/token
32 | 0.0000 | 0.0000 | 64 B
24 | 0.0130 | 0.0113 | 48 B
16 | 0.0429 | 0.0540 | 32 B
12 | 0.1549 | 0.1228 | 24 B
8 | 0.1952 | 0.1889 | 16 B
4 | 0.5808 | 0.6061 | 8 B
2 | 0.8738 | 0.8946 | 4 B
At full rank it's not an approximation at all — it's a lossless re-encoding, and 7.8 × 10−15 is float64 addition order. Below it you can watch the price. Halving the cache costs about 5% on the output; quartering it costs 60%. The output error tracks the key error closely, sometimes under and sometimes over, because softmax renormalises whatever it's given.
Absorption: the part that makes it free
Compressing the cache is easy and obvious. The reason it isn't a disaster is the fold. Written out per head, with Wq at (dmodel, headdim) and Wuk at (dc, headdim):
Nothing in that expression is new attention — it is still the
softmax(QKT/√d) V lookup, with the K
factored. The middle term is the point: nothing in
W_q @ W_uk.T depends
on the data, so you compute it once when the model loads and never again.
The query gets projected straight into latent space and dotted against the
cache. Keys are never materialised.
cached tokens n = 64, d_c = 16, head_dim = 8
W_q (32, 8) W_uk (16, 8)
W_absorbed = W_q @ W_uk.T -> (32, 16) (built ONCE at load)
expanded scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
absorbed scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
max abs difference = 8.438e-15 <- same thing ✓
operation counts, per head per decode step:
cached n | expanded MACs | absorbed MACs | saving
64 | 8,960 | 1,536 | 5.83x
512 | 69,888 | 8,704 | 8.03x
4,096 | 557,312 | 66,048 | 8.44x
32,768 | 4,456,704 | 524,800 | 8.49x
Be careful with that second table in an interview. At this toy's ratio of dc to headdim the absorbed path is genuinely fewer multiply-accumulates; at DeepSeek's real ratios it isn't always. The reliable win is that the expansion tensor never exists, so it never crosses the memory bus — and decode is memory-bound, which the previous page measured at 0.48 MACs per byte.
RoPE, and why the explanations stop here
Every blog post I read about MLA describes the compression, describes the absorption, and then quietly moves on. Here's what they skipped.
RoPE rotates q and k by an angle proportional to
their position. So the score isn't
(x W_q) · (c W_uk), it's
(R(m) x W_q) · (R(n) c W_uk) — and R(n) is a different matrix
for every cached token. There is no way to pull it out and fold it into a
fixed Wq at load time. The absorption and the rotation do not
commute. Not "approximately"; at all.
scores over 8 cached tokens, head 0, with RoPE applied:
correct (expand, then rotate) : [ 2.5354 -2.1796 0.0678 -3.9345]
absorbed then rotated : [ 6.6998 2.1142 2.3629 -2.7651]
max abs difference = 7.4962 <- wrong
ranking preserved? False
Not a rounding difference. The ranking of which tokens to attend to is different, which means it isn't a slightly worse model, it's a different one. DeepSeek's fix is to stop trying: split each head in two. Most dimensions carry no position and stay absorbable. A small extra block of drope = 64 dimensions carries the rotation, is computed the ordinary way, and is cached separately — one copy, shared by every head. The score is the sum of the two parts.
DeepSeek's fix -- split the head in two:
content part (absorbed, no position) : [ 2.2504 -0.2611 -0.1965 -0.022 ]
decoupled RoPE part (d_rope=8) : [ -3.7696 8.6941 -19.5117 6.6326]
score = sum : [ -1.5192 8.4329 -19.7082 6.6107]
matches the explicit two-part sum? True, max diff 0.000e+00 ✓
what the decoupled block costs, per token, DeepSeek-V2 shape:
latent only : 61,440 bytes/token
+ decoupled RoPE : 69,120 bytes/token (+7,680, +12.5%)
still vs multi-head: 56.9x smaller
Where the saving actually lands
Cache bytes per token is an abstraction. The number your finance team feels is how many concurrent sequences fit on one card. Fix a budget of 80 GiB for cache alone, on the DeepSeek-V2 shape:
context | multi-head | GQA-8 | MLA | MLA seqs in 80 GiB
4,096 | 15.00G | 0.94G | 0.26G | 303
16,384 | 60.00G | 3.75G | 1.05G | 75
32,768 | 120.00G | 7.50G | 2.11G | 37
131,072 | 480.00G | 30.00G | 8.44G | 9
sequences that fit in 80 GiB at 32k context:
multi-head : 0 concurrent sequences
GQA-8 : 10 concurrent sequences
MLA : 37 concurrent sequences ✓
At 32k context this shape cannot serve a single multi-head sequence in 80 GiB. GQA-8 fits 10. MLA fits 37. Per-token latency is identical in all three — the weights are in flight anyway — so that's roughly 3.7× the tokens per second per GPU, for the same silicon. This is the argument, and a lab that serves its own model at scale is exactly who would make it.
What it costs, said plainly
MLA is not free, and I'd rather you had the honest version before an interviewer hands it to you. Per attention layer, at DeepSeek-V2's shape:
per attention layer, d_model=5120, 128 heads x 128:
multi-head params: 335,544,320
GQA-8 params: 178,257,920
MLA params: 229,441,536 (0.68x multi-head, 1.29x GQA-8)
cache at 32,768 tokens, batch 1:
multi-head : 120.00 GiB
GQA-8 : 7.50 GiB
MLA : 2.11 GiB ✓
Against 128-head multi-head, MLA is actually cheaper in parameters, because the latent is a bottleneck. Against GQA-8 it costs about 1.29×, plus arithmetic: an extra projection on every token and a wider per-head dimension once the RoPE block is bolted on. The trade is a fixed cost you pay once and share across the whole batch, in exchange for a per-sequence per-token cost. That's a good deal at serving scale and a bad one at batch 1 on your laptop.
One more thing to keep straight, because it's the easiest place to be confidently wrong. DeepSeek also apply a low-rank compression to the queries. That one has nothing to do with the KV cache — queries are never cached, as the previous page's break-it table showed. It reduces activation memory during training. Same technique, different problem, and the papers are clear about it even though summaries usually aren't.
KV-cache பக்கம் முடிவதே ஒரு எண்ணில்தான் — நீங்கள் என்ன serve செய்ய முடியும் என்பதை முடிவு செய்யும் எண்: ஒரு token-க்கு எத்தனை bytes. அந்த cache-ஐ இங்கே மீண்டும் வரையறுக்கப் போவதில்லை — படிக்கவில்லை என்றால் முதலில் அதைப் படியுங்கள், ஏனெனில் இங்கே வருவது எல்லாமே அதை அனுமானித்தே எழுதப்பட்டிருக்கிறது. இந்தப் பக்கம் அந்த formula-வின் ஒரே ஒரு வரியைப் பற்றியது.
Multi-query மற்றும் grouped-query attention, heads-க்கு இடையே keys-ஐயும்
values-ஐயும் பகிர்ந்துகொண்டு kv_heads-ஐத் தாக்குகின்றன —
அப்படிச் செய்யும்போது தரம் குறைவதை GQA paper-ன் சொந்த ablations-ஏ
காட்டுகின்றன. Llama-2 70B GQA-8-உடன் ஏற்றுக்கொண்ட
trade அதுதான். MLA kv_heads-ஐத் தொடவே இல்லை. ஒரு cache entry
என்றால் என்ன என்பதையே மாற்றுகிறது.
Files-ஐ அல்ல, archive-ஐச் சேமியுங்கள்
இந்த முடிவை நீங்கள் ஏற்கனவே எடுத்திருக்கிறீர்கள். ஒரு directory-யிலுள்ள files வைத்திருக்க முடியாத அளவு பெரிதாக இருக்கிறது, அதனால் tar செய்து gzip செய்கிறீர்கள்:
store(tar_gz(files)) # disk-ல் சிறியது
files = untar(gunzip(archive)) # திரும்பப் பெற CPU செலவு
வழக்கமான ஆட்சேபனை: ஒவ்வொரு read-லும் decompression உங்களுக்குச் செலவு. இப்போது திருப்பம். decompress செய்யப்பட்ட files-ஐ வைத்து நீங்கள் செய்யும் ஒரே காரியம் ஒரு குறிப்பிட்ட function-க்குள் அதைச் செலுத்துவது மட்டுமே என்று வைத்துக்கொள்ளுங்கள் — அந்த function ஒரு matrix multiply, decompression-ம் ஒரு matrix multiply:
scores = (x @ W_q) @ (c @ W_uk).T # c-ஐ keys ஆக்கு, பிறகு dot
scores = x @ (W_q @ W_uk.T) @ c.T # அதே விஷயம். இரு matrix-ஐ மடி
W_abs = W_q @ W_uk.T # load-ல் ஒருமுறை. இப்போது:
scores = (x @ W_abs) @ c.T # keys ஒருபோதும் உருவாக்கப்படுவதில்லை
முழுப் paper-ம் இதுதான். decompression படி, நீங்கள் எப்படியும் செய்யப்போகும் operation-க்குள் மடிந்துவிடுகிறது, அதனால் inference-ல் அதற்குச் செலவே இல்லை. Matrix multiplication associative என்பது ஒன்றும் ஆழமான உண்மை அல்ல. அது 57× cache குறைப்பை இலவசமாக்குகிறது என்று கவனிப்பதுதான் ஆழம்.
| சொல் | அர்த்தம் | Programmer-க்கான உவமை |
|---|---|---|
| Latent c | ஒரு token-க்கு, ஒரு layer-க்கு ஒரு சிறிய vector, x @ W_dkv | gzip archive. disk-ல் இருப்பது இது மட்டுமே |
| dc | latent-ல் எத்தனை எண்கள். DeepSeek-V2-ல் 512 | Compression level |
| Wuk, Wuv | Up-projections: latent → keys, latent → values | gunzip, இரு வெவ்வேறு outputs-க்கு இருமுறை |
| Absorption | load-ல் ஒருமுறை Wuk-ஐ Wq-க்குள் மடிப்பது | அடுத்தடுத்த இரு multiplies-ஐ constant-fold செய்வது |
| Decoupled RoPE dims | position-ஐச் சுமக்கும் ஒரு சிறிய கூடுதல் block, தனியாக cache செய்யப்படுகிறது | index செய்யப்பட்டிருப்பதால் compress செய்ய முடியாத ஒரே field |
அந்த ஒரு formula
Attention formula அல்ல. முந்தைய பக்கத்தின் cache formula, மற்றும் MLA அதற்கு என்ன செய்கிறது:
MLA: layers × (dc + drope) × seq × dtype
இரண்டு விஷயங்கள் மாறின. அவற்றைத் தனித்தனிச் சொற்களாகப் படியுங்கள்:
| சொல் | மதிப்பு | என்ன செய்கிறது |
|---|---|---|
2 | போய்விட்டது | K-ம் V-ம் இணைந்து compress செய்யப்படுகின்றன. ஒரே latent இரண்டுக்கும் சேவை செய்கிறது, அதனால் தனியாக V சேமிக்க வேண்டியதில்லை |
kv_heads × head_dim | போய்விட்டது | dc இதற்குப் பதிலாக வருகிறது. latent ஒவ்வொரு head-ஆலும் பகிரப்படுகிறது, அதனால் heads சேர்த்தால் cache கூடாது |
dc | 512 | latent-ன் அகலம். இதுதான் dial. விரிவாக்கப்பட்ட 128 × 128 = 16384 dimensions-க்கு எதிராக DeepSeek-V2 512-ஐ இயக்குகிறது |
drope | 64 | decoupled position block. இதுவும் heads-க்கு இடையே பகிரப்படுகிறது. RoPE இந்தத் தந்திரத்தை உடைப்பதால் மட்டுமே இது இருக்கிறது — கீழே பாருங்கள் |
layers, seq, dtype | மாறவில்லை | இன்னும் அதே model, அதே users, அதே fp16 |
முழு வாதமுமான அந்த table
KV-cache பக்கத்தின் அதே கணக்கு — அதன் kv_cache_bytes
function-ஐ மாற்றாமல் அப்படியே மீண்டும் பயன்படுத்துகிறேன் — கூடவே MLA-க்கு
ஒரு புதிய வரி. இரண்டு shapes: உங்களுக்குத் தெரிந்த Llama-2-7B ஒன்று,
மற்றொன்று DeepSeek-V2.
என் வார்த்தையை நம்ப வேண்டாம். இதோ உண்மையான output:
Llama-2-7B shape: layers=32 heads=32 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 524,288 bytes/token 1.0x
grouped-query 8: 131,072 bytes/token 4.0x smaller
MLA : 36,864 bytes/token 14.2x smaller
MLA vs GQA-8 : 3.6x smaller
DeepSeek-V2 shape: layers=60 heads=128 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 3,932,160 bytes/token 1.0x
grouped-query 8: 245,760 bytes/token 16.0x smaller
MLA : 69,120 bytes/token 56.9x smaller ✓
MLA vs GQA-8 : 3.6x smaller
head எண்ணிக்கை 32-லிருந்து 128-க்குப் போகும்போது என்ன நடக்கிறது என்று
பாருங்கள். Multi-head cache 7.5× ஏறி, ஒரு sequence-ல் ஒரு token-க்கு
3.75 MiB ஆகிறது — serve செய்ய முடியாதது, அதனால்தான் யாரும் அப்படிக்
கட்டுவதில்லை. GQA-8 இரட்டிப்பாகிறது, அந்த இரட்டிப்பும் layer எண்ணிக்கையால்
வருகிறது (32-லிருந்து 60), heads-ஆல் அல்ல — அது kv_heads-ஐ
8-ல் நிறுத்தியது. MLA சிறிதும் அசையவில்லை: எத்தனை heads படித்தாலும்
ஒரு layer-க்கு 576 floats. அந்த மாறாத்தன்மைதான் இதன் இயல்பு, DeepSeek
முதலிலேயே 128 heads இயக்க முடிவதற்குக் காரணமும் அதுதான்.
Cache formula-la rendu term poidum: 2-um, kv_heads × head_dim-um. Latent ellaa heads-kkum share aagum, adhanaala heads koodinaalum cache koodaadhu. Aana honest-aana ottumottha comparison MLA vs GQA-8 = 3.6x dhaan; andha 57x yaarum ship pannaadha multi-head baseline-kku edhiraana en sondha kanakku.
இது இன்னும் attention-தானா? Full rank-ல், சரியாக
விடையையே மாற்றும் cache, cache அல்ல. அதனால் script ஒரு toy model-ஐக் கட்டி, அதன் வழியே பாடநூல் multi-head attention-ஐ இயக்கி, பிறகு அதே inputs-ஐ down-projection மற்றும் up-projection வழியே இயக்கி, வித்தியாசம் பார்க்கிறது.
ஒரு நேர்மைக் குறிப்பு: rank sweep-க்கு ஏதேனும் பொருள் இருக்க வேண்டும்
என்பதற்காக, இணைந்த [W_k | W_v]-ஐ SVD-யால் factor செய்து அதன்
singular values தேயும்படி வடிவமைக்கிறேன் — ஏனெனில் பயிற்சி பெற்ற weight
matrices-க்கு தேயும் spectrum இருக்கும், புதிதாக random ஆக்கப்பட்டவற்றுக்கு
இருக்காது.
d_model=32 n_heads=4 head_dim=8
full-rank latent d_c = 32
max |standard - through the latent| = 7.772e-15 <- float noise ✓
identical to 1e-10? True
now shrink the latent and watch what you are actually trading.
errors are relative: max abs error / max abs value.
d_c | K reconstruction | attention output | cache bytes/token
32 | 0.0000 | 0.0000 | 64 B
24 | 0.0130 | 0.0113 | 48 B
16 | 0.0429 | 0.0540 | 32 B
12 | 0.1549 | 0.1228 | 24 B
8 | 0.1952 | 0.1889 | 16 B
4 | 0.5808 | 0.6061 | 8 B
2 | 0.8738 | 0.8946 | 4 B
Full rank-ல் இது ஒரு approximation-ஏ அல்ல — இது இழப்பில்லாத மறு-encoding, 7.8 × 10−15 என்பது float64 கூட்டல் வரிசையின் விளைவு. அதற்குக் கீழே விலையைப் பார்க்கலாம். cache-ஐப் பாதியாக்கினால் output-ல் சுமார் 5% செலவு; கால்பங்காக்கினால் 60% செலவு. output error, key error-ஐ நெருக்கமாகப் பின்தொடர்கிறது — சில நேரம் குறைவாக, சில நேரம் அதிகமாக — ஏனெனில் softmax தனக்குக் கிடைப்பதை மறுபடி normalise செய்துவிடுகிறது.
Absorption: இதை இலவசமாக்கும் பகுதி
Cache-ஐ compress செய்வது எளிது, வெளிப்படையானது. அது ஒரு பேரழிவாக இல்லாததற்குக் காரணம் அந்த மடிப்பு. Wq (dmodel, headdim)-ல், Wuk (dc, headdim)-ல் இருக்க, ஒரு head-க்கு எழுதினால்:
அந்த expression-ல் புதிய attention எதுவும் இல்லை — இது இன்னும் அதே
softmax(QKT/√d) V lookup, K மட்டும்
factor செய்யப்பட்டிருக்கிறது. நடுவிலிருக்கும் term-தான் விஷயம்:
W_q @ W_uk.T-ல் எதுவும் data-வைச் சார்ந்திருக்கவில்லை, அதனால்
model load ஆகும்போது ஒருமுறை கணக்கிட்டால் போதும், மீண்டும் வேண்டாம். Query
நேராக latent space-க்குள் project செய்யப்பட்டு cache-உடன் dot
செய்யப்படுகிறது. Keys ஒருபோதும் உருவாக்கப்படுவதில்லை.
cached tokens n = 64, d_c = 16, head_dim = 8
W_q (32, 8) W_uk (16, 8)
W_absorbed = W_q @ W_uk.T -> (32, 16) (built ONCE at load)
expanded scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
absorbed scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
max abs difference = 8.438e-15 <- same thing ✓
operation counts, per head per decode step:
cached n | expanded MACs | absorbed MACs | saving
64 | 8,960 | 1,536 | 5.83x
512 | 69,888 | 8,704 | 8.03x
4,096 | 557,312 | 66,048 | 8.44x
32,768 | 4,456,704 | 524,800 | 8.49x
Interview-ல் அந்த இரண்டாவது table-ஐக் கவனமாகக் கையாளுங்கள். இந்த toy-ன் dc : headdim விகிதத்தில் absorbed பாதை உண்மையிலேயே குறைவான multiply-accumulates; DeepSeek-ன் உண்மையான விகிதங்களில் எப்போதும் அப்படி இல்லை. நம்பகமான வெற்றி என்னவென்றால், விரிவாக்கப்பட்ட tensor ஒருபோதும் உருவாகவே இல்லை, அதனால் அது memory bus-ஐக் கடப்பதேயில்லை — decode memory-bound, அதை முந்தைய பக்கம் ஒரு byte-க்கு 0.48 MACs என்று அளந்தது.
Absorption-oda point: W_q @ W_uk.T-la data dependency illa, adhanaala load time-la oru thadava kanakku pannalaam. Keys epavum build aagaadhu, adhanaala memory bus-ai adhu kadakkave illa — decode memory-bound aachaale idhu dhaan unmaiyaana laabam.
RoPE, விளக்கங்கள் ஏன் இங்கேயே நின்றுவிடுகின்றன
MLA பற்றி நான் படித்த ஒவ்வொரு blog post-ம் compression-ஐ விவரிக்கிறது, absorption-ஐ விவரிக்கிறது, பிறகு அமைதியாக அடுத்ததற்குச் சென்றுவிடுகிறது. அவர்கள் தவிர்த்தது இதுதான்.
RoPE, q-ஐயும் k-ஐயும் அவற்றின்
position-க்கு விகிதாசாரமான கோணத்தில் சுழற்றுகிறது. அதனால் score
(x W_q) · (c W_uk) அல்ல, அது
(R(m) x W_q) · (R(n) c W_uk) — R(n) ஒவ்வொரு cache செய்யப்பட்ட
token-க்கும் வேறு matrix. அதை வெளியே இழுத்து, load நேரத்தில் நிலையான
Wq-க்குள் மடிக்க வழியே இல்லை. Absorption-ம் சுழற்சியும்
commute ஆகாது. "தோராயமாக" அல்ல; சுத்தமாகவே இல்லை.
scores over 8 cached tokens, head 0, with RoPE applied:
correct (expand, then rotate) : [ 2.5354 -2.1796 0.0678 -3.9345]
absorbed then rotated : [ 6.6998 2.1142 2.3629 -2.7651]
max abs difference = 7.4962 <- wrong
ranking preserved? False
இது rounding வித்தியாசம் அல்ல. எந்த tokens-க்கு attend செய்வது என்பதன் வரிசையே வேறு, அதாவது இது சற்று மோசமான model அல்ல, இது வேறொரு model. DeepSeek-ன் தீர்வு: முயற்சியை நிறுத்துவது — ஒவ்வொரு head-ஐயும் இரண்டாகப் பிரிப்பது. பெரும்பாலான dimensions position-ஐச் சுமக்காமல் absorb செய்யக்கூடியதாகவே இருக்கின்றன. drope = 64 dimensions கொண்ட ஒரு சிறிய கூடுதல் block சுழற்சியைச் சுமந்து, சாதாரண முறையில் கணக்கிடப்பட்டு, தனியாக cache செய்யப்படுகிறது — ஒரே ஒரு நகல், ஒவ்வொரு head-ம் பகிர்ந்துகொள்கிறது. Score என்பது இரு பகுதிகளின் கூட்டல்.
DeepSeek's fix -- split the head in two:
content part (absorbed, no position) : [ 2.2504 -0.2611 -0.1965 -0.022 ]
decoupled RoPE part (d_rope=8) : [ -3.7696 8.6941 -19.5117 6.6326]
score = sum : [ -1.5192 8.4329 -19.7082 6.6107]
matches the explicit two-part sum? True, max diff 0.000e+00 ✓
what the decoupled block costs, per token, DeepSeek-V2 shape:
latent only : 61,440 bytes/token
+ decoupled RoPE : 69,120 bytes/token (+7,680, +12.5%)
still vs multi-head: 56.9x smaller
சேமிப்பு உண்மையில் எங்கே விழுகிறது
ஒரு token-க்கு cache bytes என்பது ஒரு abstraction. உங்கள் finance team உணரும் எண்: ஒரு card-ல் எத்தனை concurrent sequences பொருந்தும். cache-க்கு மட்டும் 80 GiB என்று ஒரு budget-ஐ நிலைநிறுத்தி, DeepSeek-V2 shape-ல்:
context | multi-head | GQA-8 | MLA | MLA seqs in 80 GiB
4,096 | 15.00G | 0.94G | 0.26G | 303
16,384 | 60.00G | 3.75G | 1.05G | 75
32,768 | 120.00G | 7.50G | 2.11G | 37
131,072 | 480.00G | 30.00G | 8.44G | 9
sequences that fit in 80 GiB at 32k context:
multi-head : 0 concurrent sequences
GQA-8 : 10 concurrent sequences
MLA : 37 concurrent sequences ✓
32k context-ல் இந்த shape 80 GiB-ல் ஒரே ஒரு multi-head sequence-ஐக் கூட serve செய்ய முடியாது. GQA-8-க்கு 10 பொருந்தும். MLA-க்கு 37. ஒரு token-க்கான latency மூன்றிலும் ஒன்றுதான் — weights எப்படியும் பறந்துகொண்டிருக்கின்றன — அதனால் அதே silicon-ல் ஒரு GPU-க்கு ஒரு விநாடிக்கான tokens சுமார் 3.7 மடங்கு. இதுதான் வாதம்; தன் சொந்த model-ஐப் பெரிய அளவில் serve செய்யும் ஒரு lab-தான் இந்த வாதத்தை வைக்கும்.
இதன் விலை, நேரடியாகச் சொன்னால்
MLA இலவசம் அல்ல; ஒரு interviewer உங்களிடம் இதைத் தருவதற்கு முன்பே நேர்மையான பதிப்பு உங்களிடம் இருக்க வேண்டும் என்றே விரும்புகிறேன். DeepSeek-V2-ன் shape-ல், ஒரு attention layer-க்கு:
per attention layer, d_model=5120, 128 heads x 128:
multi-head params: 335,544,320
GQA-8 params: 178,257,920
MLA params: 229,441,536 (0.68x multi-head, 1.29x GQA-8)
cache at 32,768 tokens, batch 1:
multi-head : 120.00 GiB
GQA-8 : 7.50 GiB
MLA : 2.11 GiB ✓
128-head multi-head-உடன் ஒப்பிடும்போது MLA parameters-ல் உண்மையில் மலிவானது, ஏனெனில் latent ஒரு bottleneck. GQA-8-உடன் ஒப்பிடும்போது சுமார் 1.29× செலவு, கூடவே கணக்கீடு: ஒவ்வொரு token-லும் ஒரு கூடுதல் projection, RoPE block பொருத்தப்பட்ட பிறகு ஒரு head-க்கு அகலமான dimension. இந்த trade என்பது — ஒருமுறை கட்டி முழு batch-ஆல் பகிரப்படும் நிலையான செலவுக்கு ஈடாக, ஒரு sequence-க்கு ஒரு token-க்கான செலவைக் குறைப்பது. serve செய்யும் அளவில் இது நல்ல பேரம், உங்கள் laptop-ல் batch 1-ல் மோசமான பேரம்.
இன்னொன்றைத் தெளிவாக வைத்திருங்கள், ஏனெனில் இங்குதான் நம்பிக்கையுடன் தவறாகச் சொல்வது மிக எளிது. DeepSeek queries-க்கும் ஒரு low-rank compression-ஐப் பயன்படுத்துகிறார்கள். அதற்கும் KV cache-க்கும் சம்பந்தமே இல்லை — queries ஒருபோதும் cache செய்யப்படுவதில்லை, முந்தைய பக்கத்தின் break-it table அதைக் காட்டியது. அது training-ன்போது activation memory-ஐக் குறைக்கிறது. அதே நுட்பம், வேறு பிரச்சனை; papers இதில் தெளிவாக இருக்கின்றன, சுருக்கங்கள் பொதுவாகத் தெளிவாக இருப்பதில்லை.
KV-cache वाला पन्ना उसी संख्या पर ख़त्म होता है जो तय करती है कि आप क्या serve कर सकते हैं: प्रति token कितने bytes। उस cache को यहाँ दोबारा नहीं निकालूँगा — अगर नहीं पढ़ा है तो पहले वही पढ़िए, क्योंकि नीचे सब कुछ उसी को मानकर चलता है। यह पन्ना उस formula की सिर्फ़ एक line के बारे में है।
Multi-query और grouped-query attention, heads के बीच keys और values साझा
करके kv_heads पर हमला करते हैं — और ऐसा करने में गुणवत्ता गिरती
है, यह GQA paper के अपने ablations दिखाते हैं। यही सौदा
Llama-2 70B ने GQA-8 के साथ किया। MLA
kv_heads को छूता ही नहीं। वह यह बदल देता है कि एक cache entry
होती क्या है।
Files नहीं, archive रखिए
यह फ़ैसला आप पहले भी ले चुके हैं। files की एक directory रखने के लिए बहुत बड़ी है, तो आप tar और gzip कर देते हैं:
store(tar_gz(files)) # disk पर छोटा
files = untar(gunzip(archive)) # वापस पाने के लिए CPU खर्च
आम आपत्ति यह है कि हर read पर decompression का ख़र्च पड़ता है। अब मोड़। मान लीजिए decompress की गई files के साथ आप बस एक ही काम करते हैं — उन्हें एक ख़ास function में डालना — और वह function एक matrix multiply है, और decompression भी:
scores = (x @ W_q) @ (c @ W_uk).T # c को keys बनाओ, फिर dot
scores = x @ (W_q @ W_uk.T) @ c.T # वही बात। दोनों matrix मोड़ दो
W_abs = W_q @ W_uk.T # load पर एक बार। अब:
scores = (x @ W_abs) @ c.T # keys कभी बनते ही नहीं
पूरा paper यही है। decompression का क़दम उसी operation में मुड़ जाता है जो आप वैसे भी करने वाले थे, इसलिए inference पर उसका कोई ख़र्च नहीं। Matrix multiplication का associative होना कोई गहरी बात नहीं है। यह ध्यान देना कि इससे 57× cache की कमी मुफ़्त हो जाती है — वह गहरी बात है।
| शब्द | मतलब | Programmer वाली उपमा |
|---|---|---|
| Latent c | हर token, हर layer के लिए एक छोटा vector, x @ W_dkv | gzip archive। disk पर बस यही है |
| dc | latent में कितनी संख्याएँ। DeepSeek-V2 में 512 | Compression level |
| Wuk, Wuv | Up-projections: latent → keys, latent → values | gunzip, दो अलग outputs के लिए दो बार |
| Absorption | load पर एक बार Wuk को Wq में मोड़ना | दो पास-पास वाले multiplies की constant folding |
| Decoupled RoPE dims | position ढोने वाला एक छोटा अतिरिक्त block, अलग से cache होता है | वह इकलौता field जिसे compress नहीं कर सकते क्योंकि वह indexed है |
वह एक formula
Attention वाला formula नहीं। पिछले पन्ने का cache formula, और MLA उसके साथ क्या करता है:
MLA: layers × (dc + drope) × seq × dtype
दो चीज़ें बदलीं। उन्हें अलग-अलग terms की तरह पढ़िए:
| शब्द | मान | क्या करता है |
|---|---|---|
2 | ग़ायब | K और V को साथ मिलाकर compress किया जाता है। एक ही latent दोनों का काम करता है, इसलिए अलग से V रखने की ज़रूरत नहीं |
kv_heads × head_dim | ग़ायब | इसकी जगह dc। latent हर head में साझा है, इसलिए heads बढ़ाने से cache नहीं बढ़ता |
dc | 512 | latent की चौड़ाई। यही वह dial है। फैले हुए 128 × 128 = 16384 dimensions के मुक़ाबले DeepSeek-V2 512 चलाता है |
drope | 64 | decoupled position block। यह भी heads में साझा। यह सिर्फ़ इसलिए है क्योंकि RoPE इस तरकीब को तोड़ देता है — नीचे देखिए |
layers, seq, dtype | अपरिवर्तित | वही model, वही users, वही fp16 |
वह table जो पूरी दलील है
वही गणित जो KV-cache वाले पन्ने पर था — मैं उसका
kv_cache_bytes function ज्यों का त्यों दोबारा इस्तेमाल करता हूँ
— और साथ में MLA की एक नई line। दो shapes: एक Llama-2-7B वाला जो आप जानते
हैं, और एक DeepSeek-V2 वाला।
मेरी बात पर मत जाइए। यह रहा असली output:
Llama-2-7B shape: layers=32 heads=32 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 524,288 bytes/token 1.0x
grouped-query 8: 131,072 bytes/token 4.0x smaller
MLA : 36,864 bytes/token 14.2x smaller
MLA vs GQA-8 : 3.6x smaller
DeepSeek-V2 shape: layers=60 heads=128 head_dim=128 d_c=512 d_rope=64, fp16
multi-head : 3,932,160 bytes/token 1.0x
grouped-query 8: 245,760 bytes/token 16.0x smaller
MLA : 69,120 bytes/token 56.9x smaller ✓
MLA vs GQA-8 : 3.6x smaller
देखिए क्या होता है जब head की संख्या 32 से 128 हो जाती है। Multi-head cache
7.5× बढ़कर प्रति token प्रति sequence 3.75 MiB हो जाता है — जिसे serve नहीं
किया जा सकता, इसीलिए कोई वैसा बनाता ही नहीं। GQA-8 सिर्फ़ दोगुना होता है, और
वह दोगुनापन layer की संख्या से आता है (32 से 60), heads से नहीं — उसने
kv_heads को 8 पर टाँक रखा है। MLA बिलकुल भी नहीं
हिलता: चाहे कितने भी heads पढ़ें, प्रति layer 576 floats। यही अपरिवर्तनीयता
इसका असली गुण है, और इसी वजह से DeepSeek पहले स्थान पर 128 heads चला पाता
है।
क्या यह अब भी attention है? Full rank पर, बिलकुल
जो cache जवाब बदल दे वह cache नहीं है। इसलिए script एक toy model बनाती है, उसमें से किताबी multi-head attention चलाती है, फिर वही inputs down-projection और up-projection से गुज़ारकर फ़र्क़ निकालती है।
एक ईमानदारी की बात: rank sweep का कोई मतलब बने, इसके लिए मैं संयुक्त
[W_k | W_v] को SVD से factor करके उसकी singular values को घटते
हुए आकार में ढालता हूँ, क्योंकि प्रशिक्षित weight matrices का spectrum घटता
हुआ होता है और ताज़ा randomise किए गए का नहीं।
d_model=32 n_heads=4 head_dim=8
full-rank latent d_c = 32
max |standard - through the latent| = 7.772e-15 <- float noise ✓
identical to 1e-10? True
now shrink the latent and watch what you are actually trading.
errors are relative: max abs error / max abs value.
d_c | K reconstruction | attention output | cache bytes/token
32 | 0.0000 | 0.0000 | 64 B
24 | 0.0130 | 0.0113 | 48 B
16 | 0.0429 | 0.0540 | 32 B
12 | 0.1549 | 0.1228 | 24 B
8 | 0.1952 | 0.1889 | 16 B
4 | 0.5808 | 0.6061 | 8 B
2 | 0.8738 | 0.8946 | 4 B
Full rank पर यह approximation है ही नहीं — यह बिना नुक़सान वाली re-encoding है, और 7.8 × 10−15 float64 के जोड़ने के क्रम का असर है। उसके नीचे आप क़ीमत देख सकते हैं। cache आधा करने पर output पर क़रीब 5% का ख़र्च; चौथाई करने पर 60%। output की error, key की error के साथ-साथ चलती है — कभी कम, कभी ज़्यादा — क्योंकि softmax जो मिलता है उसे दोबारा normalise कर देता है।
Absorption: वह हिस्सा जो इसे मुफ़्त बनाता है
Cache को compress करना आसान और ज़ाहिर है। यह आपदा नहीं बनता, इसकी वजह वह मोड़ है। प्रति head लिखें, जहाँ Wq (dmodel, headdim) पर है और Wuk (dc, headdim) पर:
इस expression में कुछ भी नया attention नहीं है — यह अब भी वही
softmax(QKT/√d) V lookup है, बस K
factor कर दिया गया है। बीच वाला term ही असल बात है:
W_q @ W_uk.T में कुछ भी data पर निर्भर नहीं, इसलिए आप उसे model
load होते समय एक बार निकालते हैं और फिर कभी नहीं। Query सीधे latent space
में project होकर cache से dot हो जाती है। Keys कभी बनते ही नहीं।
cached tokens n = 64, d_c = 16, head_dim = 8
W_q (32, 8) W_uk (16, 8)
W_absorbed = W_q @ W_uk.T -> (32, 16) (built ONCE at load)
expanded scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
absorbed scores[0][:4] = [ 1.4168 1.7375 0.3522 -5.6463]
max abs difference = 8.438e-15 <- same thing ✓
operation counts, per head per decode step:
cached n | expanded MACs | absorbed MACs | saving
64 | 8,960 | 1,536 | 5.83x
512 | 69,888 | 8,704 | 8.03x
4,096 | 557,312 | 66,048 | 8.44x
32,768 | 4,456,704 | 524,800 | 8.49x
Interview में उस दूसरी table को सँभालकर इस्तेमाल कीजिए। इस toy के dc और headdim के अनुपात पर absorbed रास्ता सचमुच कम multiply-accumulates लेता है; DeepSeek के असली अनुपातों पर हमेशा नहीं। भरोसे वाली जीत यह है कि फैलाव वाला tensor कभी बनता ही नहीं, इसलिए वह memory bus पार ही नहीं करता — और decode memory-bound होता है, जिसे पिछले पन्ने ने 0.48 MACs प्रति byte नापा था।
RoPE, और व्याख्याएँ यहीं क्यों रुक जाती हैं
MLA पर मैंने जितनी भी blog posts पढ़ीं, सब compression बताती हैं, absorption बताती हैं, और फिर चुपचाप आगे बढ़ जाती हैं। जो वे छोड़ देती हैं, वह यह है।
RoPE, q और k को उनकी position के समानुपाती
कोण से घुमाता है। तो score (x W_q) · (c W_uk) नहीं है, वह
(R(m) x W_q) · (R(n) c W_uk) है — और R(n) हर cached token के
लिए अलग matrix है। उसे बाहर खींचकर load time पर किसी स्थिर Wq में
मोड़ने का कोई तरीक़ा नहीं। Absorption और rotation commute नहीं करते।
"लगभग" नहीं; बिलकुल नहीं।
scores over 8 cached tokens, head 0, with RoPE applied:
correct (expand, then rotate) : [ 2.5354 -2.1796 0.0678 -3.9345]
absorbed then rotated : [ 6.6998 2.1142 2.3629 -2.7651]
max abs difference = 7.4962 <- wrong
ranking preserved? False
यह rounding का फ़र्क़ नहीं है। किन tokens पर ध्यान देना है, उनका क्रम ही अलग है, यानी यह थोड़ा ख़राब model नहीं, यह एक अलग model है। DeepSeek का इलाज है कोशिश छोड़ देना: हर head को दो में बाँट दो। ज़्यादातर dimensions कोई position नहीं ढोते और absorb होने लायक़ बने रहते हैं। drope = 64 dimensions का एक छोटा अतिरिक्त block rotation ढोता है, सामान्य तरीक़े से निकाला जाता है, और अलग से cache होता है — एक ही copy, हर head के लिए साझा। Score दोनों हिस्सों का जोड़ है।
DeepSeek's fix -- split the head in two:
content part (absorbed, no position) : [ 2.2504 -0.2611 -0.1965 -0.022 ]
decoupled RoPE part (d_rope=8) : [ -3.7696 8.6941 -19.5117 6.6326]
score = sum : [ -1.5192 8.4329 -19.7082 6.6107]
matches the explicit two-part sum? True, max diff 0.000e+00 ✓
what the decoupled block costs, per token, DeepSeek-V2 shape:
latent only : 61,440 bytes/token
+ decoupled RoPE : 69,120 bytes/token (+7,680, +12.5%)
still vs multi-head: 56.9x smaller
बचत असल में कहाँ उतरती है
प्रति token cache bytes एक abstraction है। जो संख्या आपकी finance team महसूस करती है वह यह है कि एक card पर कितनी concurrent sequences आती हैं। सिर्फ़ cache के लिए 80 GiB का budget तय कीजिए, DeepSeek-V2 shape पर:
context | multi-head | GQA-8 | MLA | MLA seqs in 80 GiB
4,096 | 15.00G | 0.94G | 0.26G | 303
16,384 | 60.00G | 3.75G | 1.05G | 75
32,768 | 120.00G | 7.50G | 2.11G | 37
131,072 | 480.00G | 30.00G | 8.44G | 9
sequences that fit in 80 GiB at 32k context:
multi-head : 0 concurrent sequences
GQA-8 : 10 concurrent sequences
MLA : 37 concurrent sequences ✓
32k context पर यह shape 80 GiB में एक भी multi-head sequence serve नहीं कर सकता। GQA-8 में 10 आती हैं। MLA में 37। प्रति token latency तीनों में एक जैसी है — weights तो वैसे भी आ-जा रहे हैं — तो उतने ही silicon पर प्रति GPU प्रति सेकंड लगभग 3.7 गुना tokens। यही दलील है, और यह दलील वही lab रखेगी जो अपना model ख़ुद बड़े पैमाने पर serve करती है।
इसकी क़ीमत, साफ़ शब्दों में
MLA मुफ़्त नहीं है, और मैं चाहूँगा कि ईमानदार version आपके पास तब आए जब कोई interviewer आपसे यह पूछे उससे पहले। DeepSeek-V2 के shape पर, प्रति attention layer:
per attention layer, d_model=5120, 128 heads x 128:
multi-head params: 335,544,320
GQA-8 params: 178,257,920
MLA params: 229,441,536 (0.68x multi-head, 1.29x GQA-8)
cache at 32,768 tokens, batch 1:
multi-head : 120.00 GiB
GQA-8 : 7.50 GiB
MLA : 2.11 GiB ✓
128-head multi-head के मुक़ाबले MLA parameters में सचमुच सस्ता है, क्योंकि latent एक bottleneck है। GQA-8 के मुक़ाबले वह लगभग 1.29× महँगा है, और साथ में गणित भी: हर token पर एक अतिरिक्त projection, और RoPE block जुड़ने के बाद प्रति head चौड़ा dimension। सौदा यह है — एक बार चुकाई जाने वाली और पूरे batch में बँटने वाली स्थिर लागत, बदले में प्रति sequence प्रति token की लागत। serving के पैमाने पर यह अच्छा सौदा है और आपके laptop पर batch 1 पर बुरा।
एक और बात साफ़ रखिए, क्योंकि यहीं आत्मविश्वास के साथ ग़लत होना सबसे आसान है। DeepSeek queries पर भी एक low-rank compression लगाते हैं। उसका KV cache से कोई लेना-देना नहीं — queries कभी cache नहीं होतीं, जैसा पिछले पन्ने की break-it table ने दिखाया। वह training के दौरान activation memory घटाता है। वही तकनीक, अलग समस्या, और papers इस पर साफ़ हैं, भले सारांश आमतौर पर न हों।
The code
Pure NumPy. No torch, no training — a forward pass and some byte counting, which is all a linear-algebra paper needs. Runs in about a seventh of a second. Every function under 20 lines.
Download the full script — 567 lines plus 7 runnable demos. Then:
python3 mla_from_scratch.py
def kv_cache_bytes(layers, kv_heads, head_dim, seq_len, dtype_bytes=2,
batch=1):
"""Standard KV cache. Same formula as the KV-cache page, unchanged."""
return 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes * batch
def mla_cache_bytes(layers, d_c, d_rope, seq_len, dtype_bytes=2, batch=1):
"""MLA cache: one latent per token per layer, plus the decoupled RoPE key.
Both are shared across ALL heads, which is where the saving comes from."""
return layers * (d_c + d_rope) * seq_len * dtype_bytes * batch
def low_rank_factors(W, rank):
"""W (m, n) -> (W_down (m, rank), W_up (rank, n)) with W ~ W_down @ W_up."""
U, S, Vt = np.linalg.svd(W, full_matrices=False)
return U[:, :rank] * S[:rank], Vt[:rank]
class ToyMLA:
def __init__(self, d_model, n_heads, head_dim, d_c, seed=0):
rng = np.random.default_rng(seed)
self.d_model, self.n_heads, self.head_dim = d_model, n_heads, head_dim
self.d_c = d_c
d_all = n_heads * head_dim
self.W_q = rng.normal(0, 0.4, (d_model, d_all))
# The joint K/V projection. I shape its singular values to DECAY,
# because a trained weight matrix has a decaying spectrum and a
# freshly randomised one does not. Without this the rank sweep below
# is meaningless -- every direction matters equally, so truncation is
# uniformly catastrophic and you learn nothing from it.
raw = rng.normal(0, 0.4, (d_model, 2 * d_all))
U, _, Vt = np.linalg.svd(raw, full_matrices=False)
spectrum = 6.0 * (0.82 ** np.arange(min(U.shape[1], Vt.shape[0])))
W_kv = (U * spectrum) @ Vt # (d, 2*d_all)
self.W_k, self.W_v = W_kv[:, :d_all], W_kv[:, d_all:]
# Compress K and V JOINTLY -- one latent serves both. That joint-ness
# is the paper's word and it is why there is no factor of 2 above.
self.W_dkv, W_ukv = low_rank_factors(W_kv, d_c)
self.W_uk = W_ukv[:, :d_all] # (d_c, d_all)
self.W_uv = W_ukv[:, d_all:] # (d_c, d_all)
def latent(self, X):
"""The ONLY thing that gets cached: (n, d_c)."""
return X @ self.W_dkv
def attention_via_latent(self, X):
"""MLA the naive way: compress, then re-expand K and V and carry on.
Correct, but it materialises the full K and V -- no saving at all
except in what you STORE. Demo 4 removes the expansion."""
C = self.latent(X) # (n, d_c) <- cached
Q = self.heads(X @ self.W_q)
K = self.heads(C @ self.W_uk) # re-expanded
V = self.heads(C @ self.W_uv) # re-expanded
w = softmax(Q @ K.transpose(0, 2, 1) / np.sqrt(self.head_dim))
out = w @ V
return out.transpose(1, 0, 2).reshape(X.shape[0], -1)
def absorb_q_into_latent(W_q_head, W_uk_head):
"""(d_model, head_dim) x (d_c, head_dim) -> (d_model, d_c). Done ONCE."""
return W_q_head @ W_uk_head.T
def scores_absorbed(x, W_absorbed, C):
"""The MLA way: project into latent space, dot against the cache."""
q_c = x @ W_absorbed # (1, d_c)
return q_c @ C.T # (1, n)
def decoupled_score(x_q, pos_q, C, positions, W_absorbed, W_qr, K_rope):
"""DeepSeek's score: absorbable content part + decoupled RoPE part.
K_rope is (n, d_rope), already rotated and cached -- ONE copy for all
heads. That sharing is why the extra cache is cheap."""
content = (x_q @ W_absorbed) @ C.T # no position
q_r = apply_rope(x_q @ W_qr, np.array([pos_q])) # (1, d_rope)
return content + q_r @ K_rope.T
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — and two of the rows below are bugs that raise nothing, pass every shape assertion you'd think to write, and just quietly give you a worse model.
| Break this | Watch for |
|---|---|
Set the latent too small: ToyMLA(..., d_c=2) instead of full rank |
Key reconstruction error climbs to 0.8738 relative, attention output to 0.8946, and the absolute output error is 3.0672. The sweep in demo 2 shows where it turns: output is still within about 5% at d_c=16, past 60% wrong at d_c=4. |
Cache the expanded keys and values instead of the latent — swap mla_cache_bytes for kv_cache_bytes(60, 128, 128, 1) |
69,120 bytes per token becomes 3,932,160. That is 56.9× worse, and it is the entire point of the paper gone. You have paid for the projections and kept none of the benefit. |
Apply RoPE with no decoupled dimensions: apply_rope(x @ W_abs, pos) @ C.T |
Scores go from [-4.5353 18.2531 -10.3902] to [-8.3733 4.9212 2.3044] — max difference 28.9771, and ranking preserved? False. Different attention, not noisier attention. |
Absorb the wrong matrix: W_q @ W_uv.T instead of W_q @ W_uk.T |
Identical shape (32, 16). No exception. Scores off by 22.4897. Meanwhile the genuinely reversed order, W_uk @ W_q.T, is just the transpose and is harmless — 0.0000. Shape checks are not correctness checks. |
| Rebuild the absorbed matrix inside the decode loop instead of once at load | Still correct, and pointless: 536,870,912 extra multiply-accumulates over 32,768 steps on a four-head toy. On a real model this is the difference between absorption being free and absorption being a tax. |
On the maths anxiety
This one is genuinely just linear algebra, and less of it than the attention page needed. You need three things: that a matrix product is associative, that a matrix can be written as a thin product of two smaller ones, and that throwing away the smallest singular values is the best approximation of a given rank. The first is the whole trick. The second is what "low-rank" means. The third is only how I built the toy — a real model learns its factors by gradient descent and never runs an SVD.
What you do need is the byte arithmetic, and you need it fast, because the interview question is not "explain MLA". It's "you have an 80 GB card and a 32k context — what's your maximum batch size, and which architecture gets you there". That's multiplication and division on numbers you can read off a config file, and demo 5 is exactly that computation.
Where to go next
This page gave you one thing: the cache arithmetic and the absorption identity, in code you can break. It says nothing about how MLA trains, nothing about the MoE it ships inside, and nothing about how the kernels are written.
-
DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model
Read the MLA part of the architecture section and stop. The decoupled-RoPE subsection is the one worth slowing down for — it's the part I built demo 4 around. Their KV-cache comparison table is the same arithmetic as demo 1, on their own configs.
-
DeepSeek-V3 Technical Report
Useful mainly as evidence: a much bigger model, a rewritten MoE, MLA carried over unchanged. What a technique looks like once a lab has stopped arguing about it.
-
On MLA
The deep-dive on absorption I wish I'd had. Goes through the matrix algebra properly, including the value-side fold I only gestured at, and is careful about which arrangement actually helps on real hardware.
-
The Big LLM Architecture Comparison
Puts MLA next to GQA, sliding-window and everything else shipping in 2024–25, with the DeepSeek ablation results discussed rather than asserted. The comparison this page deliberately kept to one table, done properly.