Chapter 1 · Section 1.2 · 55 min

An embedding is a lookup table

In §1.1 we decided to give every item a learned vector, stored as row ii of a matrix E∈RV×d\mathbf{E} \in \mathbb{R}^{V \times d}, where VV is the vocabulary size and dd the embedding dimension. This section answers the engineering questions. How does a network use that matrix? How do gradients flow into it? Why is the PyTorch layer nn.Embedding an indexing operation rather than a matrix multiply? How big should the numbers be, and how big should dd be?

The punchline fits in one line, and it's worth burning into memory. With ei∈RV\mathbf{e}_i \in \mathbb{R}^V the one-hot vector for item ii,

  ei⊤E  =  Ei,:  \boxed{\;\mathbf{e}_i^\top \mathbf{E} \;=\; \mathbf{E}_{i,:}\;}

In plain words, multiplying a one-hot row vector by the embedding matrix just picks out row ii.

One-hot times a matrix is a lookup

Let's prove it. The jj-th entry of the product ei⊤E\mathbf{e}_i^\top\mathbf{E} is a sum over the VV rows,

(ei⊤E)j  =  ∑k=1V(ei)k Ekj  =  1⋅Eij+∑k≠i0⋅Ekj  =  Eij.(\mathbf{e}_i^\top \mathbf{E})_j \;=\; \sum_{k=1}^{V} (\mathbf{e}_i)_k \, E_{kj} \;=\; 1\cdot E_{ij} + \sum_{k\neq i} 0 \cdot E_{kj} \;=\; E_{ij}.

Every term vanishes except k=ik = i. So the "embedding layer" is exactly a linear layer with no bias applied to a one-hot input. We just never build the one-hot vector, because indexing gives the same answer while doing dd copies instead of VdVd multiply-adds per token.

This matters conceptually. An embedding is not a special kind of object. It's an ordinary weight matrix, trained by ordinary backpropagation, with whatever loss sits downstream.

What does an embedding layer actually do, forward and backward?
Type a sentence using the toy vocabulary (unknown words become <unk>). Click a token to see that one-hot × E just picks a row. The right table shows which rows get gradient, and why repeated tokens add up.
Loading interactive figure…

Let's confirm the equality and measure the speed difference.

Python · cell 1

The exact timings depend on your machine, but the gather is typically one to two orders of magnitude faster here, and the gap grows linearly with VV.

Batches and sequences

In practice, ids has a shape like (B, T), meaning a batch of BB sequences, each TT tokens long. Indexing broadcasts over any leading shape and appends the embedding dimension,

X∈[V]B×T  ⟼  H=E[X]∈RB×T×d,Hb,t,:=EXbt,:.\mathbf{X} \in [V]^{B \times T} \;\longmapsto\; \mathbf{H} = \mathbf{E}[\mathbf{X}] \in \mathbb{R}^{B \times T \times d}, \qquad \mathbf{H}_{b,t,:} = \mathbf{E}_{X_{bt},:}.

In plain words, every integer in the batch is replaced by its row, so a (B, T) grid of IDs becomes a (B, T, d) block of vectors. That one line, H = E[X], is the first layer of essentially every language model. In GPT-2 it's called wte ("word token embedding"), and in Llama it's embed_tokens. You'll write it yourself when you build a GPT.

Python · cell 2

The backward pass is sparse, and it adds up

Now the part that trips people up. Suppose the rest of the network produces a loss L\mathcal{L}, and backprop has already computed the gradient with respect to the embedding outputs, G=∂L/∂H\mathbf{G} = \partial\mathcal{L}/\partial\mathbf{H}, one dd-vector per token. What is ∂L/∂E\partial\mathcal{L}/\partial\mathbf{E}?

Flatten the batch into a list of NN token positions with IDs x1,…,xNx_1, \dots, x_N. Each output row is hn=Exn,:\mathbf{h}_n = \mathbf{E}_{x_n,:}, so row ii of E\mathbf{E} influences exactly those positions where xn=ix_n = i, and it influences each of them with Jacobian ∂hn/∂Ei,:=I\partial\mathbf{h}_n/\partial\mathbf{E}_{i,:} = \mathbf{I} (it is copied verbatim). By the chain rule, summing over every path from Ei,:\mathbf{E}_{i,:} to the loss,

∂L∂Ei,:  =  ∑n=1N∂L∂hn ∂hn∂Ei,:  =  ∑n : xn=ign,\frac{\partial \mathcal{L}}{\partial \mathbf{E}_{i,:}} \;=\; \sum_{n=1}^{N} \frac{\partial \mathcal{L}}{\partial \mathbf{h}_n}\,\frac{\partial \mathbf{h}_n}{\partial \mathbf{E}_{i,:}} \;=\; \sum_{n \,:\, x_n = i} \mathbf{g}_n ,

where gn\mathbf{g}_n is row nn of G\mathbf{G}. In plain words, the gradient for word ii's vector is the sum of the upstream gradients at every position where word ii appears. Words that don't appear in the batch get exactly zero.

∂ The same result in matrix form

Stack the one-hot vectors of the batch into S∈{0,1}N×V\mathbf{S} \in \{0,1\}^{N\times V}, with row nn equal to exn⊤\mathbf{e}_{x_n}^\top. Then H=SE\mathbf{H} = \mathbf{S}\mathbf{E}. For a linear map H=SE\mathbf{H} = \mathbf{S}\mathbf{E} the gradient with respect to the right factor is (see the linear-layer gradient in §0.2)

∂L∂E=S⊤∂L∂H=S⊤G.\frac{\partial \mathcal{L}}{\partial \mathbf{E}} = \mathbf{S}^\top \frac{\partial \mathcal{L}}{\partial \mathbf{H}} = \mathbf{S}^\top \mathbf{G}.

Row ii of S⊤G\mathbf{S}^\top\mathbf{G} is ∑nSni gn=∑n:xn=ign\sum_n S_{ni}\,\mathbf{g}_n = \sum_{n: x_n = i}\mathbf{g}_n, the same as above. Computing it with a real matrix product would cost O(NVd)O(NVd). The scatter-add below costs O(Nd)O(Nd). In autograd terms (§0.3), the backward of a gather is a scatter-add.

A worked example you can check by hand

Take a tiny table with V=4V = 4, d=3d = 3, a batch of IDs [2,0,2][2, 0, 2], and upstream gradients

G=[10−10.50.50.5210].\mathbf{G} = \begin{bmatrix} 1 & 0 & -1 \\ 0.5 & 0.5 & 0.5 \\ 2 & 1 & 0 \end{bmatrix}.

Row 2 appears at positions 1 and 3, so its gradient is [1,0,−1]+[2,1,0]=[3,1,−1][1, 0, -1] + [2, 1, 0] = [3, 1, -1]. Row 0 appears once and gets [0.5,0.5,0.5][0.5, 0.5, 0.5]. Rows 1 and 3 never appear and get [0,0,0][0, 0, 0]. The full gradient is

∂L∂E=[0.50.50.500031−1000].\frac{\partial\mathcal{L}}{\partial\mathbf{E}} = \begin{bmatrix} 0.5 & 0.5 & 0.5 \\ 0 & 0 & 0 \\ 3 & 1 & -1 \\ 0 & 0 & 0 \end{bmatrix}.

Two consequences follow.

  1. The gradient is sparse. In a batch of 4,096 tokens from a 128k vocabulary, at most 4,096 rows (usually far fewer) are nonzero. PyTorch can exploit this with nn.Embedding(..., sparse=True).
  2. Repeated IDs must accumulate. "the" may appear 300 times in a batch, and its row must receive the sum of all 300 gradients. This is where a very common bug lives.

The fancy-indexing trap

In NumPy (and PyTorch), G_E[ids] += grads does not accumulate duplicates. With repeated indices, each write overwrites the previous one, and only one survives. You need np.add.at (in PyTorch, index_add_ or scatter_add_). Here is the worked example above, done both ways.

Python · cell 3

Row 2 should be [3,1,−1][3, 1, -1]. The buggy version keeps only the last write, [2,1,0][2, 1, 0]. A bug like this doesn't crash anything. Training just quietly gets worse, with frequent words learning too slowly, and it is exactly the kind of bug an interviewer loves.

Core

Embedding layer, forward and backward, with a padding row

Implement the two halves of an embedding layer.

  • embedding_forward(E, ids) returns E[ids], for ids of any shape.
  • embedding_backward(ids, grad_out, V, padding_idx=None) returns ∂L/∂E\partial\mathcal{L}/\partial\mathbf{E} with shape (V, d), given grad_out =∂L/∂H=\partial\mathcal{L}/\partial\mathbf{H} of shape ids.shape + (d,). If padding_idx is given, that row must receive zero gradient (this is what nn.Embedding(padding_idx=...) does for <pad> tokens).

The tests include a numerical gradient check. They perturb entries of E\mathbf{E} by ±ϵ\pm\epsilon and compare the change in loss to your analytic gradient. That's the gold-standard way to verify any backward pass (§0.2 derives why the central difference has error of order ϵ2\epsilon^2).

Hidden tests, hints, and AI help, from $1.99 a month.

Now in PyTorch

nn.Embedding is exactly the code you just wrote, plus a few production options that are worth knowing cold.

🔥 In PyTorchfor a machine with PyTorch

Bags of IDs, and their backward pass

Recommenders and bag-of-words models often embed a variable-length list of IDs per example (the items a user clicked, or the words in a review) and pool them into one vector. Storing ragged lists as a padded matrix wastes memory, so the standard format (used by nn.EmbeddingBag) is a flat ids array plus an offsets array saying where each bag starts. For example, ids = [4, 1, 1, 7, 2] with offsets = [0, 3] means bag 0 is [4, 1, 1] and bag 1 is [7, 2].

With mean pooling, bag bb with members xnx_n for nn in a set Bb\mathcal{B}_b of size ∣Bb∣\lvert\mathcal{B}_b\rvert produces ob=1∣Bb∣∑n∈BbExn,:\mathbf{o}_b = \frac{1}{\lvert\mathcal{B}_b\rvert}\sum_{n\in\mathcal{B}_b}\mathbf{E}_{x_n,:}. By the same chain-rule argument as before, each member receives the bag's upstream gradient divided by the bag size,

∂L∂Ei,:=∑b ∑n∈Bb, xn=i1∣Bb∣ ∂L∂ob.\frac{\partial\mathcal{L}}{\partial\mathbf{E}_{i,:}} = \sum_{b}\ \sum_{n \in \mathcal{B}_b,\ x_n = i} \frac{1}{\lvert\mathcal{B}_b\rvert}\,\frac{\partial\mathcal{L}}{\partial\mathbf{o}_b} .

In plain words, every time item ii sits in a bag, it collects that bag's gradient, scaled by one over the bag size. In the example above, if the bag gradients are q0\mathbf{q}_0 and q1\mathbf{q}_1, row 1 (which appears twice in bag 0) gets 23q0\tfrac{2}{3}\mathbf{q}_0, and row 7 gets 12q1\tfrac12\mathbf{q}_1.

Core

EmbeddingBag forward, mean-pooled over ragged bags

Implement embedding_bag_mean(E, ids, offsets) returning shape (num_bags, d), without a Python loop over bags. Empty bags should give a zero vector.

Hidden tests, hints, and AI help, from $1.99 a month.
Interview

EmbeddingBag backward

Now the backward pass. Implement embedding_bag_backward(ids, offsets, grad_out, V, mode="mean") returning ∂L/∂E\partial\mathcal{L}/\partial\mathbf{E} of shape (V, d), where grad_out has shape (num_bags, d). Support mode="sum" (each member gets the bag gradient unchanged) and mode="mean" (divided by the bag size). Empty bags contribute nothing. No Python loops over bags or elements.

This is a real interview question for recommender and ads teams, because the embedding tables there are the largest part of the model and their backward pass is the hot path.

Hidden tests, hints, and AI help, from $1.99 a month.

One table, two jobs, weight tying

A language model ends with an output layer that turns its final hidden vector h∈Rd\mathbf{h} \in \mathbb{R}^d into one score per token, z=Wouth\mathbf{z} = \mathbf{W}_{\text{out}}\mathbf{h} with Wout∈RV×d\mathbf{W}_{\text{out}} \in \mathbb{R}^{V\times d}. That matrix has exactly the shape of the input table, and its row jj plays a similar role, a vector that "means" token jj. Weight tying (Press & Wolf, 2017; Inan et al., 2017) sets Wout=E\mathbf{W}_{\text{out}} = \mathbf{E}, so one table both embeds the input and scores the output. GPT-2 and the original Transformer tie, while many recent large models (Llama 3 8B and 70B among them) keep the two separate. We'll come back to it in §1.5, where word2vec's two tables are the same question in miniature.

Tying changes the backward pass in a way people often get wrong. The table now sits on two paths to the loss, and the chain rule says to add the contributions of all paths.

Take the smallest possible tied model, a bigram predictor. Position nn has input token xnx_n and target token yny_n. The model looks up hn=Exn,:\mathbf{h}_n = \mathbf{E}_{x_n,:}, computes logits zn=Ehn∈RV\mathbf{z}_n = \mathbf{E}\mathbf{h}_n \in \mathbb{R}^V, and pays cross-entropy L=1N∑n−log⁡softmax⁡(zn)yn\mathcal{L} = \frac1N\sum_n -\log\operatorname{softmax}(\mathbf{z}_n)_{y_n}. With Δ∈RN×V\boldsymbol{\Delta} \in \mathbb{R}^{N\times V} holding the familiar "probabilities minus one-hot target" rows divided by NN (§0.2), the two paths give

∂L∂E=Δ⊤H⏟output path, Z=HE⊤  +  S⊤(ΔE)⏟input path, scatter-add of ∂L/∂H,\frac{\partial\mathcal{L}}{\partial\mathbf{E}} = \underbrace{\boldsymbol{\Delta}^\top\mathbf{H}}_{\text{output path, } \mathbf{Z} = \mathbf{H}\mathbf{E}^\top} \;+\; \underbrace{\mathbf{S}^\top(\boldsymbol{\Delta}\mathbf{E})}_{\text{input path, scatter-add of } \partial\mathcal{L}/\partial\mathbf{H}} ,

where H∈RN×d\mathbf{H} \in \mathbb{R}^{N\times d} stacks the hn\mathbf{h}_n and S\mathbf{S} is the one-hot selection matrix from the derivation above. In plain words, every row gets gradient from its role as a possible output (every row, every step, because the softmax touches the whole vocabulary) plus, for the rows that were looked up, gradient from its role as an input. That first term is why a tied table's gradient is dense even when the input lookups are sparse.

Interview

The gradient of a tied embedding

Implement tied_bigram_loss_grad(E, x, y) for the bigram model above. E is (V, d), x and y are (N,) int arrays of inputs and targets. Return (loss, dE) with the mean cross-entropy and its full gradient. Use a numerically stable log-softmax. No Python loops.

A common wrong answer keeps only one of the two paths. The tests check against a numerical gradient, and the failure message tells you which path is missing.

Hidden tests, hints, and AI help, from $1.99 a month.

In PyTorch, tying is one assignment, and autograd adds the two paths for you.

🔥 In PyTorchfor a machine with PyTorch

Initialization, or how big the numbers should be

nn.Embedding initializes every entry from N(0,1)\mathcal{N}(0, 1). Big models usually use something smaller. GPT-2 initializes token embeddings with standard deviation 0.020.02. The original Transformer (Vaswani et al., 2017) multiplies the embedding output by d\sqrt{d} before adding positional encodings. Why does scale matter? Because the next layers see these numbers. If an embedding row h\mathbf{h} has dd independent entries of mean 0 and standard deviation σ\sigma, its expected squared norm is

E[∥h∥2]=∑j=1dE[hj2]=d σ2,\mathbb{E}\big[\lVert \mathbf{h} \rVert^2\big] = \sum_{j=1}^d \mathbb{E}[h_j^2] = d\,\sigma^2 ,

so its length is about d σ\sqrt d\,\sigma. In plain words, the vector gets longer with the square root of the dimension. With σ=1\sigma = 1 and d=4096d = 4096, every vector has length about 64, which can saturate whatever comes next. With σ=0.02\sigma=0.02 the length is about 1.3, and for GPT-2 small (d=768d = 768) about 0.55.

The d\sqrt{d} multiplier goes the other way and scales the vectors up. The paper does not explain why. A common reading is that the shared input and output table is initialized with standard deviation about d−1/2d^{-1/2}, which keeps output logits at a sensible scale, and multiplying by d\sqrt{d} on the way in brings the input entries back to roughly unit size, comparable to the sinusoidal position encodings (whose entries lie in [−1,1][-1, 1]) they are added to. Treat that as a plausible rationale rather than established fact. We'll come back to scale in §5.2 (normalization) and §8.8 (initialization at scale).

Python · cell 4

Choosing the embedding dimension dd

There is no formula, but there are good mental models.

  • Capacity. dd is how many soft features each item can have. Too small and distinct items are forced to overlap. Too large and you waste parameters and memory, and with little data you overfit. Yin and Shen (2018) make this a bias and variance trade-off and show that, for count-based embeddings, the error is minimized at a finite dd that depends on the noise in the data.
  • Geometry says you need fewer dimensions than you'd think. The Johnson–Lindenstrauss lemma (§1.3) says nn points can be placed in O(log⁡n/ε2)O(\log n / \varepsilon^2) dimensions while keeping all pairwise distances within a factor 1±ε1 \pm \varepsilon. That's logarithmic in the number of items, not linear.
  • Rules of thumb, which disagree. Google's TensorFlow feature-column guide suggests d≈V1/4d \approx V^{1/4} for categorical features. The fastai library's default is min⁡(600,round⁡(1.6 V0.56))\min(600, \operatorname{round}(1.6\,V^{0.56})). For V=10,000V = 10{,}000 categories the first gives 10 and the second 278. A factor of 28 between two popular heuristics tells you what they are, which is starting points for a sweep, not laws.
  • What real models use. Word2vec and GloVe used dd from 50 to 300. BERT-base uses 768. Llama-3-8B uses 4096, the same as the width of the whole network.
model or settingVVddembedding parameters
word2vec (Google News)3,000,000300900 M
BERT-base30,52276823 M
GPT-2 small50,25776839 M (tied with the output)
Llama-3-8B128,2564,096525 M, plus 525 M for the untied output head
GPT-3 175B50,25712,288618 M

The Llama row is striking. In an 8B-parameter model, more than a billion parameters live in the input and output token tables. GPT-3's table is larger than Llama's in absolute terms, yet it is only 0.35% of GPT-3's 175 billion parameters, because the rest of a model grows with its depth and with the square of its width while the table grows only linearly with the width. For small models the share is even larger, which is one reason people tie the input and output tables, or factorize them. ALBERT (Lan et al., 2020) replaces the V×dV\times d table with a product of a V×mV\times m table and an m×dm\times d projection. With V=30,000V = 30{,}000, m=128m = 128, and d=4,096d = 4{,}096, that is 3.84M+0.52M≈4.43.84\text{M} + 0.52\text{M} \approx 4.4 M parameters instead of 123 M.

Memory during training is larger still. With Adam in float32, every parameter carries its value, its gradient, and two moment estimates, so 16 bytes. Llama-3-8B's input table alone then needs 525M×16≈8.4525\text{M} \times 16 \approx 8.4 GB. Where the rest of a model's training memory goes is the subject of §10.1.

Warm-up

Count the parameters and the training memory of a token table

Implement table_cost(V, d, tied, factor_m=None) that returns (params, adam_fp32_bytes) for the input embedding plus the output head of a language model. If tied, input and output share one V×dV\times d table. If factor_m is given, the input table is factorized ALBERT-style as V×mV\times m plus m×dm\times d (the output head, if untied, stays a full V×dV \times d matrix, and if tied it is the same factorized table). Count Adam memory as 16 bytes per parameter.

Hidden tests, hints, and AI help, from $1.99 a month.

Read more

Watch next

🎬 Building makemore Part 2: MLP · Andrej Karpathy · starts at 14:00Karpathy builds an embedding table C and indexes it with C[X] in PyTorch.