Skip to content

Tied embeddings

When a language model reuses its embedding table as the output layer, the table plays two roles that want different scales. As a lookup nothing sums over it, so its rows want a scale of 1. As the output layer it sums over d inputs, so it wants 1/√d for unit-variance logits. AnyInit gives the table the output's scale, counting any constant factor the model applies to the logits, and says so in the report. These are the measurements behind that choice.

Setup. A 4-layer pre-LN GPT with no positional embedding, trained for 800 steps on wikitext-2 with the Pythia tokenizer (V = 33,527, ln V = 10.4). AdamW, best of a learning rate sweep, mean of 2 seeds. The rest of the network is initialized identically everywhere.

table initial loss, d = 128 → 1024 val. loss, d = 128 d = 512
A tied, σ = 1 (nn.Embedding default) 110 → 905 6.25 6.85
B tied, σ = 0.02 (GPT-2) 10.4 → 10.7 6.12 5.86
C tied, σ = 1/√d, lookup × √d (Transformer, Gemma) 11.2 → 28.3 5.77 5.71
D tied, σ = 1, logits × 1/√d (PaLM, T5) 11.2 → 28.3 6.00 6.15
D′ D, with the table's learning rate × √d 11.2 → 28.3 5.86 5.66
F tied, σ = 1/√d, no multiplier (AnyInit, without a multiplier) 10.8 → 11.0 5.87 5.88
E untied: lookup σ = 1, output σ = 1/√d 10.9 → 10.9 5.92 5.73

Conclusions.

  • A single scale fails one role. At the lookup's scale (A) the logits have standard deviation √d, the initial loss grows with width, and the model ends 1.1 nats behind at d = 512. At the output's scale (F) the lookup enters the network small, and the cost is about 0.15 nats.
  • A multiplier resolves the conflict. C and D are the same function at initialization. They train differently because Adam's step does not depend on a parameter's scale, so a table at σ = 1 moves √d times slower relative to its size. D′ closes the gap, as Adafactor's parameter scaling does for PaLM.
  • In C and D the logit of the input token itself starts near √d, so the model first predicts its own input. That is unlearned within about 25 steps and does not show in the final loss.
  • The output role dominates the gradient. The norm of the input-side gradient falls from 0.6 of the output side at initialization to 0.01–0.5 by step 100, as Lopardo et al. report.

The models are small and short-trained; differences under 0.05 nats are within seed noise.

Recommendations. AnyInit sets the table's scale. Two things that matter as much are out of its reach: a multiplier in the forward pass, which is architecture, and the table's learning rate, which is the optimizer's.

1. Multiply the lookup by √d. This is C, the best tied variant measured. The table keeps the output's scale and the lookup arrives with unit variance. Scaling the logits by 1/√d instead (PaLM, T5) also serves both roles: AnyInit then gives the table a scale of 1, which needs recommendation 2.

AnyInit reads a constant factor wherever it sits: a number, buffer or parameter in PyTorch, a number in a Keras operation or a Rescaling layer, and in Flax a scalar factor recognized from its values. A factor computed in forward() from other tensors is not a constant; mode="empirical" measures what it does.

PyTorch:

import math

from torch import nn


class TiedLM(nn.Module):
    def __init__(self, vocab: int, d: int, blocks: nn.Module):
        super().__init__()
        self.emb = nn.Embedding(vocab, d)
        self.blocks = blocks
        self.norm = nn.LayerNorm(d)
        self.head = nn.Linear(d, vocab, bias=False)
        self.head.weight = self.emb.weight
        self.lookup_scale = math.sqrt(d)

    def forward(self, idx):
        h = self.blocks(self.emb(idx) * self.lookup_scale)
        return self.head(self.norm(h))

Flax:

import flax.linen as nn
import jax.numpy as jnp


class TiedLM(nn.Module):
    vocab: int
    d: int
    blocks: nn.Module

    @nn.compact
    def __call__(self, idx):
        emb = nn.Embed(self.vocab, self.d)
        h = self.blocks(emb(idx) * jnp.sqrt(self.d))
        return emb.attend(nn.LayerNorm()(h))

Keras, with keras_hub's tied embedding:

import math

import keras
import keras_hub

ids = keras.Input((None,), dtype="int32")
embedding = keras_hub.layers.ReversibleEmbedding(vocab, d)
h = blocks(embedding(ids) * math.sqrt(d))
logits = embedding(keras.layers.LayerNormalization()(h), reverse=True)
model = keras.Model(ids, logits)

2. Match the table's learning rate to its scale. Adam's step does not depend on a parameter's size, so what moves the table is the learning rate relative to its scale. With the lookup multiplier the table is at 1/√d, and the rate of the rest of the model is right. With a logit multiplier it is at σ = 1, and it needs √d times that rate (D′); PaLM gets the same from Adafactor's parameter scaling. Under a schedule, keep the ratio. The snippets are for the σ = 1 case.

PyTorch, where a tied table is listed once among the parameters:

lr, d = 3e-3, model.emb.embedding_dim
table = model.emb.weight
rest = [p for p in model.parameters() if p is not table]
optimizer = torch.optim.AdamW([{"params": rest}, {"params": [table], "lr": lr * d**0.5}], lr=lr)

Flax, with optax:

import optax
from flax import traverse_util

lr, d = 3e-3, 512


def labels(params):
    flat = traverse_util.flatten_dict(params)
    return traverse_util.unflatten_dict(
        {path: "table" if path[-1] == "embedding" else "rest" for path in flat}
    )


optimizer = optax.multi_transform(
    {"rest": optax.adamw(lr), "table": optax.adamw(lr * d**0.5)}, labels
)

Keras (checked with 3.15):

lr = 3e-3
table, rest = keras.optimizers.AdamW(lr * d**0.5), keras.optimizers.AdamW(lr)
optimizer = keras.optimizers.MultiOptimizer(
    lambda variable: table if variable is embedding.embeddings else rest
)

3. Or untie. The untied model (E) matched C at d = 512 for V·d more parameters, and AnyInit scales both of its tables correctly without help.

References.

Code. Needs torch, transformers, huggingface_hub and pandas, and about 50 minutes on one consumer GPU.

"""Tied embeddings: which scale should the shared table get?

A 4-layer pre-LN GPT without positional embeddings, so the table is the only thing
entering the residual stream, trained on wikitext-2 with the Pythia tokenizer.  Needs
torch, transformers, huggingface_hub and pandas; about 50 minutes on one consumer GPU.
"""

import itertools
import math

import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
from huggingface_hub import hf_hub_download
from transformers import AutoTokenizer

DEV = "cuda"
T, B, STEPS, WARM, LAYERS = 128, 32, 800, 50, 4

# name: (table std, lookup multiplier, logit multiplier, tied, untied head std)
SCHEMES = {
    "A": (lambda d: 1.0, lambda d: 1.0, lambda d: 1.0, True, None),  # nn.Embedding default
    "B": (lambda d: 0.02, lambda d: 1.0, lambda d: 1.0, True, None),  # GPT-2
    "C": (lambda d: d**-0.5, lambda d: d**0.5, lambda d: 1.0, True, None),  # Transformer, Gemma
    "D": (lambda d: 1.0, lambda d: 1.0, lambda d: d**-0.5, True, None),  # PaLM, T5
    "F": (lambda d: d**-0.5, lambda d: 1.0, lambda d: 1.0, True, None),  # output scale, no mult
    "E": (lambda d: 1.0, lambda d: 1.0, lambda d: 1.0, False, lambda d: d**-0.5),  # untied
}


def load():
    def tokens(split):
        path = hf_hub_download(
            "Salesforce/wikitext",
            f"wikitext-2-raw-v1/{split}-00000-of-00001.parquet",
            repo_type="dataset",
        )
        return torch.tensor(tok("".join(pd.read_parquet(path)["text"]))["input_ids"])

    tok = AutoTokenizer.from_pretrained("EleutherAI/pythia-70m")
    train, val = tokens("train"), tokens("validation")
    used = torch.unique(torch.cat([train, val]))  # remap to the 33,527 ids that occur
    remap = torch.full((int(used.max()) + 1,), -1)
    remap[used] = torch.arange(len(used))
    return remap[train].to(DEV), remap[val].to(DEV), len(used)


class Block(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.heads = max(d // 64, 2)
        self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.qkv, self.proj = nn.Linear(d, 3 * d, bias=False), nn.Linear(d, d, bias=False)
        self.fc, self.out = nn.Linear(d, 4 * d, bias=False), nn.Linear(4 * d, d, bias=False)

    def forward(self, x):
        b, t, d = x.shape
        qkv = self.qkv(self.ln1(x)).view(b, t, 3, self.heads, d // self.heads)
        q, k, v = qkv.permute(2, 0, 3, 1, 4)
        a = F.scaled_dot_product_attention(q, k, v, is_causal=True)
        x = x + self.proj(a.transpose(1, 2).reshape(b, t, d))
        return x + self.out(F.gelu(self.fc(self.ln2(x))))


class LM(nn.Module):
    def __init__(self, vocab, d, scheme):
        super().__init__()
        s_e, i_m, o_m, self.tied, s_h = SCHEMES[scheme]
        self.in_mult, self.out_mult = i_m(d), o_m(d)
        self.E = nn.Parameter(torch.randn(vocab, d) * s_e(d))
        self.H = None if self.tied else nn.Parameter(torch.randn(vocab, d) * s_h(d))
        self.blocks = nn.ModuleList(Block(d) for _ in range(LAYERS))
        self.lnf = nn.LayerNorm(d)
        for name, p in self.blocks.named_parameters():  # identical in every scheme
            if p.dim() == 2:
                std = p.shape[1] ** -0.5
                if name.endswith(("proj.weight", "out.weight")):
                    std /= math.sqrt(2 * LAYERS)
                nn.init.normal_(p, 0, std)
        self.split = False

    def forward(self, idx):
        e_in = e_out = self.E if self.tied else None
        if self.tied and self.split:  # two views of E, to tell input from output gradient
            e_in, e_out = self.E * 1, self.E * 1
            e_in.retain_grad()
            e_out.retain_grad()
            self.views = (e_in, e_out)
        x = F.embedding(idx, self.E if e_in is None else e_in) * self.in_mult
        for block in self.blocks:
            x = block(x)
        return (self.lnf(x) @ (self.H if e_out is None else e_out).t()) * self.out_mult


def windows(data, count, generator):
    """Positions of ``count`` random windows of T tokens."""
    device = generator.device
    starts = torch.randint(0, len(data) - T - 1, (count,), device=device, generator=generator)
    return starts.to(DEV)[:, None] + torch.arange(T, device=DEV)


def at_init(vocab, val):
    """Logit statistics before any training, per scheme and width."""
    idx = windows(val, 16, torch.Generator().manual_seed(0))
    x, y = val[idx], val[idx + 1]
    for d, scheme in itertools.product((128, 256, 512, 1024), SCHEMES):
        torch.manual_seed(1)
        logits = LM(vocab, d, scheme).to(DEV)(x).detach()
        loss = F.cross_entropy(logits.reshape(-1, vocab), y.reshape(-1)).item()
        own = logits.gather(-1, x.unsqueeze(-1)).mean().item()  # logit of the input token
        print(f"{scheme} d={d:4}  logit std {logits.std():.2f}  own {own:.2f}  loss {loss:.2f}")


def run(train, val, vocab, scheme, d, lr, seed, table_lr=1.0):
    torch.manual_seed(seed)
    m = LM(vocab, d, scheme).to(DEV)
    rest = [p for n, p in m.named_parameters() if n != "E"]
    groups = [{"params": rest, "m": 1.0}, {"params": [m.E], "m": table_lr}]
    opt = torch.optim.AdamW(groups, lr=lr, betas=(0.9, 0.95), weight_decay=0.0)
    gen = torch.Generator(device=DEV).manual_seed(seed)
    for step in range(STEPS):
        decay = 0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * step / STEPS))
        for group in opt.param_groups:
            group["lr"] = lr * group["m"] * min(1, (step + 1) / WARM) * decay
        idx = windows(train, B, gen)
        m.split = m.tied and step in (0, 100, 799)
        with torch.autocast("cuda", torch.bfloat16):
            logits = m(train[idx])
        loss = F.cross_entropy(logits.float().reshape(-1, vocab), train[idx + 1].reshape(-1))
        opt.zero_grad(set_to_none=True)
        loss.backward()
        if m.split:
            g_in, g_out = (v.grad.float().norm().item() for v in m.views)
            print(f"  step {step}: |grad as input| / |grad as output| = {g_in / g_out:.2f}")
        torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
        opt.step()
    m.split = False
    idx = windows(val, 64, torch.Generator().manual_seed(1234))
    with torch.no_grad(), torch.autocast("cuda", torch.bfloat16):
        losses = [
            F.cross_entropy(m(val[i]).float().reshape(-1, vocab), val[i + 1].reshape(-1)).item()
            for i in idx.split(16)
        ]
    return sum(losses) / len(losses)


if __name__ == "__main__":
    train, val, vocab = load()
    at_init(vocab, val)
    jobs = [(s, d, lr, 1.0) for d in (128, 512) for s in SCHEMES for lr in (3e-4, 1e-3, 3e-3, 1e-2)]
    jobs += [("A", d, 3e-2, 1.0) for d in (128, 512)] + [("D", 128, 3e-2, 1.0)]
    jobs += [("D", d, lr, d**0.5) for d in (128, 512) for lr in (3e-4, 1e-3, 3e-3)]  # D'
    for scheme, d, lr, table_lr in jobs:
        losses = [run(train, val, vocab, scheme, d, lr, seed, table_lr) for seed in (0, 1)]
        name = scheme + ("'" if table_lr != 1.0 else "")
        print(f"{name} d={d} lr={lr:g}: validation loss {sum(losses) / 2:.3f}")