Home › Machine learning › Modern layers
algorithm

Embeddings

Problem A neural network has to take categorical inputs, such as words, users, items or board squares, and represent each as a useful numeric vector, without paying for a huge one-hot input.

A learned table mapping discrete ids to dense vectors: gather forward, sparse scatter-add backward, row-wise optimizers to keep huge tables cheap.

forward O(B·d) gather; backward O(B·d) scatter-add into only the touched rows; memory O(V·d)
embeddinglookup-tablesparse-gradientweight-tyinghashing-trickrecommendation
Overview

An embedding layer is a V × d matrix indexed by id: a one-hot matrix multiply without the multiply. The backward pass is a scatter-add of output gradients into the rows that were used, so a batch touches only a few rows of a possibly enormous table.

Key facts

Use when

  • Any categorical input, in text (word and token tables in every LLM), recommendation and ads (user, item and feature-cross tables in DLRM and TorchRec), and semantic search (sentence embedding models).

Avoid when

  • Fewer than about 10 values: one-hot input is as good.
  • Ids unseen in training (new users, items): the row is untrained. Fall back to content features or an "unknown" row.

Deep dive

How it works

In: integer ids [B] (or bags of ids with offsets) · Out: vectors [B, d] · State: the table W (V × d) and per-row optimizer state · Cost: O(B·d) gather, O(B·d) scatter-add, O(V·d) memory

  1. Forward (gather): out[b] = W[ids[b]], a memory copy of d values per id.
  2. Backward (scatter-add): dW[ids[b]] += dout[b]. Duplicate ids in the batch must accumulate, not overwrite.
  3. Update only touched rows. Sparse SGD or row-wise Adagrad (one scalar of state per row instead of d). For LLM token tables, dense AdamW is normal because nearly every row of a 50-150k vocabulary is touched by a large batch.
  4. Bags (pooling): recommenders sum or average a variable-length list of ids per example (EmbeddingBag) using offsets, without materializing the per-id vectors.
  5. Tying: output logits = h · Wᵀ with the same W. The transformer paper scales the input embedding by √d_model when tied.
  6. Hashing trick: row = hash(feature) mod V (optionally a second hash for the sign). Collisions act as noise; size V so the collision rate is tolerable.
ids  [7, 2, 7]          W (V x d)                  dW (only rows 2 and 7 non-zero)
 └─ gather ─► out[0]=W[7], out[1]=W[2], out[2]=W[7]
 ◄─ scatter-add: dW[7] = dout[0] + dout[2],  dW[2] = dout[1]

Code

pub struct Embedding { pub v: usize, pub d: usize, pub w: Vec<f32>, adagrad: Vec<f32> }

impl Embedding {
    pub fn new(v: usize, d: usize, seed: u64) -> Self {
        let mut s = seed;
        let w = (0..v * d).map(|_| {                       // small uniform init, std ~ 0.02
            s ^= s << 13; s ^= s >> 7; s ^= s << 17;
            ((s >> 40) as f32 / (1u64 << 24) as f32 - 0.5) * 0.07
        }).collect();
        Embedding { v, d, w, adagrad: vec![0.0; v] }       // row-wise Adagrad: 1 float per row
    }

    pub fn forward(&self, ids: &[usize]) -> Vec<f32> {
        let mut out = Vec::with_capacity(ids.len() * self.d);
        for &i in ids { out.extend_from_slice(&self.w[i * self.d..(i + 1) * self.d]); }
        out
    }

    /// EmbeddingBag(mean): ids for bag j are ids[offsets[j]..offsets[j+1]].
    pub fn bag_mean(&self, ids: &[usize], offsets: &[usize]) -> Vec<f32> {
        let mut out = vec![0.0f32; (offsets.len() - 1) * self.d];
        for j in 0..offsets.len() - 1 {
            let bag = &ids[offsets[j]..offsets[j + 1]];
            let o = &mut out[j * self.d..(j + 1) * self.d];
            for &i in bag { for c in 0..self.d { o[c] += self.w[i * self.d + c]; } }
            if !bag.is_empty() { o.iter_mut().for_each(|x| *x /= bag.len() as f32); }
        }
        out
    }

    /// Sparse backward + row-wise Adagrad step. Duplicates are merged first (sort + run-length),
    /// so each touched row gets exactly one update with its summed gradient.
    pub fn backward_step(&mut self, ids: &[usize], dout: &[f32], lr: f32, eps: f32) {
        let d = self.d;
        let mut order: Vec<usize> = (0..ids.len()).collect();
        order.sort_unstable_by_key(|&b| ids[b]);
        let mut g = vec![0.0f32; d];
        let mut k = 0;
        while k < order.len() {
            let row = ids[order[k]];
            g.iter_mut().for_each(|x| *x = 0.0);
            while k < order.len() && ids[order[k]] == row {   // scatter-add duplicates
                let b = order[k];
                for c in 0..d { g[c] += dout[b * d + c]; }
                k += 1;
            }
            let g2 = g.iter().map(|x| x * x).sum::<f32>() / d as f32;
            self.adagrad[row] += g2;                            // one accumulator per row
            let step = lr / (self.adagrad[row].sqrt() + eps);
            for c in 0..d { self.w[row * d + c] -= step * g[c]; }
        }
    }

    /// Tied output layer: logits = h · Wᵀ (one row of V logits per hidden vector).
    pub fn tied_logits(&self, h: &[f32]) -> Vec<f32> {
        (0..self.v).map(|r| (0..self.d).map(|c| h[c] * self.w[r * self.d + c]).sum()).collect()
    }
}

/// Hashing trick: FNV-1a of the raw feature string into a fixed number of rows.
pub fn hash_row(feature: &str, rows: usize) -> usize {
    let mut h: u64 = 0xcbf29ce484222325;
    for b in feature.bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); }
    (h % rows as u64) as usize
}

fn main() {
    let mut emb = Embedding::new(1000, 8, 7);
    let ids = [7usize, 2, 7];
    let out = emb.forward(&ids);
    assert_eq!(&out[0..8], &out[16..24]);                  // same id, same vector
    let before = emb.w.clone();
    let dout = vec![1.0f32; ids.len() * 8];
    emb.backward_step(&ids, &dout, 0.1, 1e-8);
    let changed: Vec<usize> = (0..1000).filter(|&r| emb.w[r * 8..(r + 1) * 8] != before[r * 8..(r + 1) * 8]).collect();
    println!("rows updated: {changed:?}");                 // only [2, 7]
    let bag = emb.bag_mean(&[1, 2, 3, 9], &[0, 3, 4]);
    println!("bag 0 mean[0] = {:.4}, logits len {}", bag[0], emb.tied_logits(&out[0..8]).len());
    println!("user:42 -> row {}", hash_row("user:42", 1 << 20));
}

Complexity and constants

  • Forward: one random row read per id. A 4096-dim bf16 row is 8 KiB, so lookups stream at near memory bandwidth. Small rows (d = 16-64 in recommenders, 32-256 B) are latency-bound: one or two cache lines per random access.
  • Backward: sort or atomic scatter-add. On GPUs, frequent ids (the token "the", a popular item) cause atomic contention. Sort-then-reduce avoids it.
  • Memory for a table with Adam: V·d·(2 + 4 + 4 + 4) bytes with bf16 weights and fp32 master plus m and v. Row-wise Adagrad: V·(d·4 + 4), which is why recommenders use it.
  • Matryoshka embeddings: vectors trained so prefixes of 1/2, 1/4, … of the dimensions remain usable.

Efficiency

  • Cost: a lookup reads one row: 8 KiB for 4096 bf16 dimensions (bandwidth-bound), 256 B for d = 64 in fp32 (latency-bound, a few cache lines). Training with bf16 weights, fp32 master and Adam state costs V·d·14 bytes (this page); a 10⁹-row, d = 64 fp32 table is 256 GB (arithmetic), which is why recommender tables are sharded. The bound is memory capacity and bandwidth, not FLOPs.
  • Levers:
    • Row-wise Adagrad — optimizer state of 4 bytes per row instead of 8 bytes per parameter (this page) — huge sparse tables.
    • int8/int4 tables — 4x/8x smaller than fp32 (arithmetic; FBGEMM supports both) — inference.
    • Hashing trick or compositional embeddings — table size independent of vocabulary (Weinberger 2009; Shi 2020) — very large id spaces.
    • Matryoshka training — up to 14x smaller embeddings at equal ImageNet-1K accuracy (Kusupati 2022) — retrieval and storage.
    • Sort-then-reduce backward instead of atomics — avoids contention on frequent ids (this page) — GPU training.
  • Naive → standard → state of the art:
    • One-hot times matrix · O(V·d) per token
    • Row gather, sparse gradient · O(d) per id
    • Sharded, quantized tables with row-wise Adagrad (TorchRec/FBGEMM), Matryoshka prefixes · rows 4-8x smaller, only touched rows updated

Implementation notes

  • Pad V to a multiple of 64 or 128 for tensor-core friendly output matmuls (nanoGPT pads GPT-2's 50257 to 50304).
  • Initialize with small std (0.02 or 1/√d) when tied to the output. PyTorch's nn.Embedding default is N(0, 1), which makes initial logits huge when tied.
  • nn.Embedding(sparse=True) produces sparse gradients that work with SGD, SparseAdam and Adagrad, not with AdamW.
  • Huge tables: shard rows across devices (row-wise or table-wise model parallelism) and exchange looked-up vectors with all-to-all, as in DLRM. Cache hot rows on GPU and keep cold rows in host memory.
  • Serving: quantize tables row-wise to int8 or int4 with a per-row scale and bias. Memory drops 4x (int8) or 8x (int4) versus fp32; recommenders report small accuracy loss at int8.
  • Weight decay on embeddings varies by recipe (nanoGPT decays them, many do not). With decoupled decay on a sparse table, rarely-seen rows shrink toward zero between their updates.

Pitfalls

  • Index out of range from a tokenizer/table vocabulary mismatch: on GPU this is a device-side assert or silent garbage.
  • Overwriting instead of accumulating gradients for repeated ids.
  • Dense Adam on a sparse table: rows not in the batch still move because m keeps a non-zero momentum (and decay shrinks them).
  • Tying embeddings and then scaling the input by √d without matching the init: activations start far too large.
  • Hash tables sized too small for the id space: heavy collisions merge popular and rare ids.

Variants and alternatives

One-hot + linearidentical math, V times more compute.
Compositional / QR embeddingsan id indexes two smaller tables (quotient and remainder) whose rows are combined. Memory O(√V·d).
Character or byte-level modelsno large table, longer sequences.
Contrastive sentence or item embeddingsan encoder output, trained with InfoNCE (loss functions), stored for nearest-neighbour search.

In production

PyTorch nn.Embedding / nn.EmbeddingBag, TorchRec and FBGEMM (sharded tables, row-wise Adagrad, int8/int4 tables), TensorFlow Recommenders, every LLM token embedding. GPT-2 ties input and output embeddings; Llama 2/3 7B-70B do not.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
fastembed-rsRustAnush008/fastembed-rscargo add fastembed————text embeddings from ONNX models, local inference
candleRusthuggingface/candlecargo add candle-core candle-nn————candle_nn::embedding; BERT-style embedding models in candle-transformers
PyTorchPython/C++pytorch/pytorchpip install torch————nn.Embedding, nn.EmbeddingBag
TorchRecPythonmeta-pytorch/torchrecpip install torchrec————sharded embedding tables for recommenders (FBGEMM kernels)
Sentence TransformersPythonhuggingface/sentence-transformerspip install sentence-transformers————text embedding models, Matryoshka training

Sources

  • Mikolov et al., Distributed Representations of Words and Phrases and their Compositionality, NeurIPS 2013. paper
  • Press and Wolf, Using the Output Embedding to Improve Language Models, EACL 2017. paper
  • Weinberger, Dasgupta, Langford, Smola, Attenberg, Feature Hashing for Large Scale Multitask Learning, ICML 2009. paper
  • Naumov et al., Deep Learning Recommendation Model for Personalization and Recommendation Systems, 2019. paper · code
  • Shi et al., Compositional Embeddings Using Complementary Partitions for Memory-Efficient Recommendation Systems, KDD 2020. paper
  • Kusupati et al., Matryoshka Representation Learning, NeurIPS 2022. paper · code
  • Duchi, Hazan, Singer, Adaptive Subgradient Methods for Online Learning and Stochastic Optimization, JMLR 2011. paper

Builds on

Used by

active learning · plannedChoosing which unlabeled examples to label next: uncertainty, margin, diversity and committee sampling
agent memory · planned
code context retrieval · planned
Contrastive learningTeams with huge unlabelled collections (photos, recordings, sensor streams, scans, logs) need vectors where things that belong together sit close, but cannot afford class labels.Deep ranking models for recommendationA feed, shop, store or video site has a few hundred to thousands of retrieved candidates per request and must order them by what this user will do next, using sparse IDs, behaviour history and several kinds of feedback.Entity linkingA pipeline has name strings found in text or records ("Orbit", "aspirin", "J. Smith") and needs the one stable identifier each refers to in a reference catalogue, or to learn that it is in none, so downstream data joins on ids.Entity resolutionTeams merging customer, product or company records from several sources that share no common key must work out which records describe the same real-world thing, without comparing every pair.
feature interaction models · plannedClick/conversion prediction networks for ranking: feature crosses (DCN, DeepFM), embedding bags, calibration
graph neural networks · plannedMessage-passing neural networks over graphs (GCN, GAT, equivariant) for molecules and relations
knowledge graph embeddings · plannedEmbedding entities and relations (TransE, RotatE, ComplEx) for link prediction
Model routing and cascadesA service sends every request to one expensive language model although most are easy, or it has several models, providers and prices to choose from. It must pick the cheapest option per request that still answers well enough.Memory consolidationAn agent that stores every fact it learns across sessions accumulates duplicates, outdated values and contradictions, so retrieval gets slower and noisier. This keeps a long-lived memory store clean as it grows.
schema linking · plannedFinding the tables, columns and values relevant to a question before generating SQL
semantic cache · plannedCaching LLM responses keyed by embedding similarity of prompts, with thresholds and invalidation
Text chunkingSystems that search or answer questions over long documents (contracts, manuals, support tickets, source repositories) can only index or read short passages, but a bad cut separates a fact from the context that gives it meaning.Tool retrieval (selecting tools for a request)An agent has far more tools, actions or skills than fit in its prompt or than the model can choose among reliably. Before each model call something must pick the few that matter for this request and this caller.Transformer policy-value networkA game-playing program needs, for any board position, a ranking of candidate moves and an estimate of who is winning, learned from data, so that tree search can spend its budget on the promising lines.Two-tower retrievalA service with millions of items (products, videos, documents, ads) must pick a few hundred plausible ones for each user or query within milliseconds, so a costlier model can rank them.
vision transformer · plannedViT: patch embeddings, position encodings, encoder blocks; vision-language connectors

Systems (18):

Alternatives and related

Byte-pair encoding (BPE) tokenizationA model that reads text, DNA, music or other symbol streams must turn arbitrary input into a manageable sequence of units from a fixed-size vocabulary, handling any input without unknown symbols and keeping sequences short.Attention variantsTeams serving large sequence models find that memory for cached past context limits how many requests they can handle and how long a context they can support. They must choose an attention design that balances quality against that memory.Rotary position embedding (RoPE)A transformer treats its input as a bag of tokens unless told where each one sits. Language-model designers need a way to give attention awareness of token positions, especially relative distance, that works with cached decoding.HNSWApplications that must find the vectors most similar to a query (embeddings search, RAG, recommendations) among millions of in-memory vectors, while still accepting new inserts, need fast approximate nearest-neighbour lookup.IVF-PQVector search over hundreds of millions to billions of embeddings, where full vectors do not fit in memory, needs compact storage and fast approximate nearest-neighbour lookup, on CPU or GPU.Weight quantizationRunning a large language model for one user at a time is slow and needs a lot of GPU memory, because every weight must be read for each generated token. Engineers want faster, smaller deployments.Loss functionsWhen training a model, you must choose the single number to minimise and compute it without numerical blow-ups. The choice depends on whether the model predicts classes, tokens or continuous values.