AI Grimoire
Sheet
statuspromising
difficultyadvanced
timeO(√N·d)
memoryO(N·d)
described2019
revisedtoday

Product-Key Memory

If the feed-forward block is a memory, make it a much bigger one. Factorising the keys into two halves turns a search over a million entries into two searches over a thousand.

Standing

PromisingPromising and actively moving. The results are real but narrow — one lab, one model family, or one benchmark suite — and the picture may look different in a year.

A 2019 idea that Meta revived in 2024 with better engineering. Genuine wins on factual recall; not yet part of anyone’s default architecture.

judged as of 2026-09 · what the labels mean

Theory

Take the key-value memory reading literally and the obvious question follows: why dffd_{ff} entries? If the block is a memory, the useful size is however many facts the model should hold, and 4d4d is an accident of the architecture.

The obstacle is search. Finding the top-kk of NN entries costs O(Nd)O(Nd) — which for N=106N = 10^6 is worse than the dense block it replaces.

Factorising the key space

Split the query in half, and give each half its own small set of subkeys. The full key set is the Cartesian product of the two.

K=C1×C2,C1=C2=N\mathcal{K} = \mathcal{C}_1 \times \mathcal{C}_2, \qquad |\mathcal{C}_1| = |\mathcal{C}_2| = \sqrt{N}
eq. 1 — N keys from 2√N subkeys

A key is a concatenation (ca(1),cb(2))(c^{(1)}_a, c^{(2)}_b), so its score against a query q=(q1,q2)q = (q_1, q_2) is q1ca(1)+q2cb(2)q_1^\top c^{(1)}_a + q_2^\top c^{(2)}_b — separable. And a separable score means the global top-kk can be found from the two halves’ top-kk lists without ever scoring all NN pairs.

topk(K)topk(C1)×topk(C2)\mathrm{top}_k(\mathcal{K}) \subseteq \mathrm{top}_k(\mathcal{C}_1) \times \mathrm{top}_k(\mathcal{C}_2)
eq. 2 — the guarantee that makes it exact

The proof is one line: if atopk(C1)a \notin \mathrm{top}_k(\mathcal{C}_1) then kk subkeys beat it in the first half, and each pairs with bb to give a better full key. So the search is 2N2\sqrt{N} comparisons, then k2k^2 candidates, then a top-kk over those. At N=106N = 10^6 and k=32k = 32: 2048 comparisons instead of a million, and the answer is exact, not approximate.

Keeping the memory in use

The failure mode is usage collapse. Slots that are never selected receive no gradient, so they never improve, so they are never selected — and a layer can end up with a few thousand live entries out of a million.

Lample et al. use batch normalisation on the query, which is one of the few places where batch statistics are exactly the right tool: it is a global constraint, forcing the query distribution to spread over the key space rather than concentrating, and the coupling across the batch is the mechanism rather than a side effect. Multiple heads help too, for the same reason they help in attention — several independent queries cover more of the space than one.

The 2024 revival

Berges et al. scaled the idea to models up to 8B and found the result that makes it interesting: memory layers improve factual benchmarks substantially while leaving reasoning benchmarks roughly unchanged, and a model with memory layers matches a dense model with about twice the compute on the factual tasks.

That split is a claim about what the parameters are for. Facts are lookup and scale with capacity; reasoning is computation and scales with FLOPs. A memory layer buys the first cheaply and the second not at all.

Implementation

python · torch
import torch
from torch import Tensor, nn


class ProductKeyMemory(nn.Module):
    def __init__(self, dim: int, n_keys: int = 1024, k: int = 32, heads: int = 4):
        super().__init__()
        self.k, self.heads, self.n_keys = k, heads, n_keys

        # Two subkey sets; the full key space is their product, n_keys².
        self.subkeys = nn.Parameter(torch.randn(2, n_keys, dim // 2))
        self.values = nn.EmbeddingBag(n_keys**2, dim, mode="sum")
        self.query = nn.Linear(dim, dim, bias=False)
        # Global constraint on the query distribution: see the note above.
        self.qnorm = nn.BatchNorm1d(dim)

    def forward(self, x: Tensor) -> Tensor:                   # [B, D]
        q = self.qnorm(self.query(x))
        q1, q2 = q.chunk(2, dim=-1)

        s1, i1 = (q1 @ self.subkeys[0].T).topk(self.k)        # [B, k]
        s2, i2 = (q2 @ self.subkeys[1].T).topk(self.k)

        # k² candidates, then the true top-k over them.
        scores = (s1[:, :, None] + s2[:, None, :]).flatten(1)
        flat = (i1[:, :, None] * self.n_keys + i2[:, None, :]).flatten(1)
        top, pos = scores.topk(self.k)

        return self.values(flat.gather(1, pos), per_sample_weights=top.softmax(-1))

EmbeddingBag is doing real work there: it gathers and weight-sums in one kernel, so the million-row value table is never materialised as a dense matmul. The table is also the entire memory cost of the layer — N×dN \times d, which at 10610^6 and 1024 is a billion parameters in a single layer, and a good candidate for keeping in a lower precision than the rest of the model.

Memory slots
1 M
Keys compared
2 × 1024
Slots read
32
Lample et al., 2019

Related

References

[1]Lample et al. — Large Memory Layers with Product Keys (2019)arXiv:1907.05242
[2]Berges et al. — Memory Layers at Scale (2024)arXiv:2412.09764
[3]Geva et al. — Transformer Feed-Forward Layers Are Key-Value Memories (2020)arXiv:2012.14913