An embedding is a lookup table
In §1.1 we decided to give every item a learned vector, stored as row of a matrix , where is the vocabulary size and 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 be?
The punchline fits in one line, and it's worth burning into memory. With the one-hot vector for item ,
In plain words, multiplying a one-hot row vector by the embedding matrix just picks out row .
One-hot times a matrix is a lookup
Let's prove it. The -th entry of the product is a sum over the rows,
Every term vanishes except . 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 copies instead of 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.
Let's confirm the equality and measure the speed difference.
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 .
Batches and sequences
In practice, ids has a shape like (B, T), meaning a batch of sequences, each tokens long. Indexing broadcasts over any leading shape and appends the embedding dimension,
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.
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 , and backprop has already computed the gradient with respect to the embedding outputs, , one -vector per token. What is ?
Flatten the batch into a list of token positions with IDs . Each output row is , so row of influences exactly those positions where , and it influences each of them with Jacobian (it is copied verbatim). By the chain rule, summing over every path from to the loss,
where is row of . In plain words, the gradient for word 's vector is the sum of the upstream gradients at every position where word 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 , with row equal to . Then . For a linear map the gradient with respect to the right factor is (see the linear-layer gradient in §0.2)
Row of is , the same as above. Computing it with a real matrix product would cost . The scatter-add below costs . 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 , , a batch of IDs , and upstream gradients
Row 2 appears at positions 1 and 3, so its gradient is . Row 0 appears once and gets . Rows 1 and 3 never appear and get . The full gradient is
Two consequences follow.
- 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). - 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.
Row 2 should be . The buggy version keeps only the last write, . 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.
Embedding layer, forward and backward, with a padding row
Implement the two halves of an embedding layer.
embedding_forward(E, ids)returnsE[ids], foridsof any shape.embedding_backward(ids, grad_out, V, padding_idx=None)returns with shape(V, d), givengrad_outof shapeids.shape + (d,). Ifpadding_idxis given, that row must receive zero gradient (this is whatnn.Embedding(padding_idx=...)does for<pad>tokens).
The tests include a numerical gradient check. They perturb entries of by 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 ).
Now in PyTorch
nn.Embedding is exactly the code you just wrote, plus a few production options that are worth knowing cold.
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 with members for in a set of size produces . By the same chain-rule argument as before, each member receives the bag's upstream gradient divided by the bag size,
In plain words, every time item 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 and , row 1 (which appears twice in bag 0) gets , and row 7 gets .
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.
EmbeddingBag backward
Now the backward pass. Implement embedding_bag_backward(ids, offsets, grad_out, V, mode="mean") returning 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.
One table, two jobs, weight tying
A language model ends with an output layer that turns its final hidden vector into one score per token, with . That matrix has exactly the shape of the input table, and its row plays a similar role, a vector that "means" token . Weight tying (Press & Wolf, 2017; Inan et al., 2017) sets , 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 has input token and target token . The model looks up , computes logits , and pays cross-entropy . With holding the familiar "probabilities minus one-hot target" rows divided by (§0.2), the two paths give
where stacks the and 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.
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.
In PyTorch, tying is one assignment, and autograd adds the two paths for you.
Initialization, or how big the numbers should be
nn.Embedding initializes every entry from . Big models usually use something smaller. GPT-2 initializes token embeddings with standard deviation . The original Transformer (Vaswani et al., 2017) multiplies the embedding output by before adding positional encodings. Why does scale matter? Because the next layers see these numbers. If an embedding row has independent entries of mean 0 and standard deviation , its expected squared norm is
so its length is about . In plain words, the vector gets longer with the square root of the dimension. With and , every vector has length about 64, which can saturate whatever comes next. With the length is about 1.3, and for GPT-2 small () about 0.55.
The 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 , which keeps output logits at a sensible scale, and multiplying by on the way in brings the input entries back to roughly unit size, comparable to the sinusoidal position encodings (whose entries lie in ) 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).
Choosing the embedding dimension
There is no formula, but there are good mental models.
- Capacity. 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 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 points can be placed in dimensions while keeping all pairwise distances within a factor . That's logarithmic in the number of items, not linear.
- Rules of thumb, which disagree. Google's TensorFlow feature-column guide suggests for categorical features. The fastai library's default is . For 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 from 50 to 300. BERT-base uses 768. Llama-3-8B uses 4096, the same as the width of the whole network.
| model or setting | embedding parameters | ||
|---|---|---|---|
| word2vec (Google News) | 3,000,000 | 300 | 900 M |
| BERT-base | 30,522 | 768 | 23 M |
| GPT-2 small | 50,257 | 768 | 39 M (tied with the output) |
| Llama-3-8B | 128,256 | 4,096 | 525 M, plus 525 M for the untied output head |
| GPT-3 175B | 50,257 | 12,288 | 618 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 table with a product of a table and an projection. With , , and , that is 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 GB. Where the rest of a model's training memory goes is the subject of §10.1.
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 table. If factor_m is given, the input table is factorized ALBERT-style as plus (the output head, if untied, stays a full matrix, and if tied it is the same factorized table). Count Adam memory as 16 bytes per parameter.
Read more
- PyTorch documentation. torch.nn.Embedding. Initialization,
padding_idx,max_norm, and which optimizers support sparse gradients. - Raschka (2024). Build a Large Language Model (From Scratch), Chapter 2. Token embeddings, positions, and a notebook showing that an embedding equals one-hot times a matrix.
- Press & Wolf (2017). Using the Output Embedding to Improve Language Models. Weight tying, and why the output table is the better one to keep.
- Lan et al. (2020). ALBERT. Factorized embedding parameterization, separating the vocabulary table from the hidden width.
- Yin & Shen (2018). On the Dimensionality of Word Embedding. A principled bias and variance view of choosing .
- Karpathy. Neural Networks, Zero to Hero. The makemore lectures build an embedding table live.