3Day 3

Multi-head latent attention

MLA caches one small shared vector per token instead of per-head keys and values. Against multi-head attention that is a large saving. Against the two-KV-head model octlm already had, the arithmetic said it was small, and the note wrote that down before building it.

Not measuredEXP-019

Grouped-query attention shrinks the cache by sharing key and value heads. DeepSeek-V2's multi-head latent attention (MLA) goes further. It caches one low-rank vector per token, shared by every head, and rebuilds the keys and values from it when needed.

Definition

Compress keys and values into one latent

Each token's hidden vector hth_t is projected down to a latent ctc_t of rank rr, much smaller than the model width. Keys and values for every head are projected back up from it:

ct=Wdown ht,[kt;vt]=Wup ctc_t = W_{\text{down}}\, h_t, \qquad [k_t; v_t] = W_{\text{up}}\, c_t

Only ctc_t is cached. For a model with many heads, rr numbers per token replace 2⋅H⋅dhead2 \cdot H \cdot d_{\text{head}}.

RoPE does not survive this. A rotation that depends on position cannot pass through a fixed up-projection, so MLA adds a separate small key, ktropek^{\text{rope}}_t of width drd_r, shared across heads and rotated normally. The cache holds ctc_t and ktropek^{\text{rope}}_t: r+drr + d_r numbers per token per layer.

octlm/model.pyline 262
def _project_mla(
    self, x: Tensor, rope: tuple[Tensor, Tensor] | None
) -> tuple[Tensor, Tensor, Tensor]:
    """Content rides a shared low-rank latent, position rides a separate shared RoPE key."""
    rope_dim = self.config.mla_rope_dim
    content = self.kv_up(self.kv_down(x))
    key, value = content.chunk(2, dim=-1)
    key = self._heads(key, self.n_heads, self.head_size)
    value = self._heads(value, self.n_heads, self.head_size)
    query = self._heads(self.query(x), self.n_heads, self.head_size)
    cosine, sine = rope
    rope_slice = (cosine[..., : rope_dim // 2], sine[..., : rope_dim // 2])
    query_rope = apply_rope(self._heads(self.q_rope(x), self.n_heads, rope_dim), *rope_slice)
    key_rope = apply_rope(self.k_rope(x).unsqueeze(1), *rope_slice)
    key_rope = key_rope.expand(-1, self.n_heads, -1, -1)
    return torch.cat((query, query_rope), -1), torch.cat((key, key_rope), -1), value
Diagram
Attention
hₜlatent cₜ, 64kᵣₒₚₑ, 16K, 8 heads, rebuilt from cₜV, 8 heads, rebuilt from cₜ
Cached per token per layer80green boxes
Against MHA6.4× smaller
Against GQA-21.6×

Width 256, 8 heads of 32, the Day 2 shape. Green is what the cache stores. MHA and GQA store keys and values directly. MLA stores a 64-wide latent and a small RoPE key, and rebuilds the per-head keys and values with an up-projection at every step, trading memory for extra matrix work.

What each attention type stores in the cache for one token, at the Day 2 shape.
The arithmetic

A large win against MHA, a small one against GQA-2

PLAN.md and day-wise.md both carried MLA in as a large cache saving, and DeepSeek reports reductions past 90 percent. The note checked where that figure comes from. It is measured against multi-head attention with many heads. Day 2 had already taken most of that saving with two KV heads.

Playground
RoPE key width
Model width
MLAMHAGQA-2
0.00100.00200.00300.00400.00500.00050100150200250latent rankcached numbers per token per layerMLAMHAGQA-2
Hover the chart to read values.
MLA at this rank8064 latent + 16 RoPE key
GQA-21282 × 2 × 32
MHA5122 × 8 × 32
MLA against GQA-21.60×smaller
Numbers cached per token per layer, computed with octlm's cacheDims. Slide the rank and change the width to see where MLA beats GQA-2 and by how much.

What happened?

This was written into the note as a conflict with both plans, before any code ran.

The hypothesis

MLA loses to GQA-2 at this width

The useful number is the rank at which MLA's cache first falls below GQA-2's 128 numbers, and whether quality survives at that rank. Stop condition: ranks 32, 64 and 128 against the GQA-2 control, three seeds, reporting cache numbers per token, code and prose bits per byte, and seconds per step. The note marked the result experimental either way.

Status

Built, tested, not scheduled

The plan changed before the run. The code stays behind a flag that defaults to off.

Skipped

What we did not build, and why