LLM Developer DocsLLM Docs

Loading the search index ...

↑ ↓ move, Enter open, Esc close

GitHub

Full Model

The complete forward pass and the complete backward pass, a gradient-flow diagram tied to real calls, the gradient dictionary, and every parameter key with its shape.

transformer.py

This page joins the earlier pages into one pass through the model, in each direction. It adds no new mathematics. Each step is one call to a function explained elsewhere, and the order of those calls is what this page documents.

The numbers on this page are labelled Illustrative Example. They come from a tiny model whose parameters are random draws from the real init_params, so its logits and loss say nothing about the trained model.

Forward pass

What is it?

Whole model, forwardImplemented

Whole model

forward takes one sequence of ids. It adds the embeddings, runs every block in turn, applies the final LayerNorm and projects with the head. It returns the logits and a cache, and applies no softmax to the logits.

Why does the model need it?

This is the function that turns token ids into one score per vocabulary token, at every position. Training, validation and generation all call it.

Mathematics

forward first checks the length:

The sequence must fit the position table #Link to equation: The sequence must fit the position table
T≤Tmax⁡T \le T_{\max}

Next it computes the first X. Embeddings has the equation. Then come the blocks, which Transformer Block explains:

Blocks in sequence #Link to equation: Blocks in sequence
X(l+1)=Block⁡l ⁣(X(l)),l=0,…,L−1X^{(l+1)} = \operatorname{Block}_l\!\big(X^{(l)}\big), \qquad l = 0, \dots, L - 1
Final LayerNorm #Link to equation: Final LayerNorm
Xf=LN⁡f ⁣(X(L))X_f = \operatorname{LN}_f\!\big(X^{(L)}\big)
The head #Link to equation: The head
Z=XfWh+1bh⊤Z = X_f W_h + \mathbf{1} b_h^\top

The last two together:

The last two steps together #Link to equation: The last two steps together
Logits=LN⁡f ⁣(X(L)) Wh+bh\mathrm{Logits} = \operatorname{LN}_f\!\big(X^{(L)}\big)\, W_h + b_h

What does each symbol mean?

SymbolMeaningIn codeShape
TTThe length of the sequencelen(ids)scalar
Tmax⁡T_{\max}The number of rows in the position tablecfg.context_lenscalar
X(l)X^{(l)}The residual stream before block l. X^{(0)} is the output of embed_forwardX(T, d)
LLThe number of blocks. On this page L always means this, and the loss is written as the word losscfg.num_layersscalar
Block⁡l\operatorname{Block}_lblock_forward with the parameters whose keys start with b{l}.block_forward(T, d) to (T, d)
LN⁡f\operatorname{LN}_fThe final LayerNormlayer_norm_forward with lnf_g, lnf_b(T, d) to (T, d)
XfX_fThe output of the final LayerNormXf(T, d)
Wh,bhW_h, b_hWeights and bias of the headhead_w, head_b(d, V) and (V,)
Z,LogitsZ, \mathrm{Logits}One score per vocabulary token, at each positionlogits(T, V)
VVThe vocabulary size. It depends on the corpuscfg.vocab_sizescalar

Tiny numerical example

The whole forward passIllustrative ExampleA tiny model: the parameters are random draws from the real init_params, not learned values. The other inputs are hand-picked. Sizes here: V = 5, T_max = 4, d = 4, H = 2, L = 2. Logits from unlearned parameters mean nothing.

ids = [2, 0, 2]

X after embed_forward, shape (3, 4)
-0.1603-0.3330.60431.3778
0.9295-0.62870.5279-0.4679
-1.20610.2360.2131.5067
X after block 0, shape (3, 4)
-0.8929-1.0414-0.1521.2843
-0.1405-0.8955-1.439-0.1701
-0.90390.5315-0.42141.1834
X after block 1, shape (3, 4)
-0.291-1.73980.93761.9181
1.31710.0703-0.4637-0.5218
0.1232-0.2280.59341.661
Xf, shape (3, 4)
-0.3631-1.42120.53411.2502
1.6455-0.0408-0.763-0.8417
-0.5825-1.07620.07881.5799
logits, shape (3, 5)
0.3811-0.34830.53611.94871.7362
-0.22831.54230.0637-1.56030.4553
0.5927-0.44190.74341.99371.3703

Tensor shapes

config.py has no value for V, because it depends on the corpus, so V stays a symbol. T is at most 16 in training and generation, and the assertion allows up to 32.

One sequence through the modeltransformer.py:166
  1. ids(T,)(16,)
  2. X(T, d)(16, 32)
  3. Xf(T, d)(16, 32)
  4. logits(T, V)(16, V)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32. V depends on the corpus. It is not a value in config.py.

Actual implementation

transformer.pyforwardGitHub: transformer.py
def forward(params, ids, cfg: ModelConfig):    assert len(ids) <= cfg.context_len, "sequence longer than the position table"    X = embed_forward(params, ids)    block_caches = []    for l in range(cfg.num_layers):        X, c = block_forward(X, params, l, cfg)        block_caches.append(c)    Xf, c_lnf = layer_norm_forward(X, params["lnf_g"], params["lnf_b"], cfg.ln_eps)    logits = Xf @ params["head_w"] + params["head_b"]    return logits, dict(blocks=block_caches, lnf=c_lnf, Xf=Xf)

What it returns. forward returns the logits and a cache (transformer.py:168). Logits are not probabilities, and forward applies no softmax to them. That happens outside, in one of two places:

  • The loss. cross_entropy_forward computes log probabilities from the logits, and returns their exponential as probs, in training (transformer.py:173) and in validation (train.py:31). It does not call softmax_forward, and computes the same result in log form.
  • The sampler. sample_next calls softmax_forward on the logits divided by the temperature (generate.py:11).

The only softmax_forward call inside forward turns attention scores into attention weights (transformer.py:83). That is not applied to logits.

Only loss_and_grads uses the cache. Validation (train.py:30) and generation (generate.py:32) throw it away, though forward builds it each time.

Passed asHoldsBuilt atRead by
cache["Xf"]Xf: The output of the final LayerNorm, the input of the head.transformer.py:168linear_backward, at transformer.py:176
cache["lnf"]Xhat: The normalised rows.s: One over the root of variance plus epsilon, per row.ops.py:47layer_norm_backward, at transformer.py:181
cache["blocks"][l]ln1: The cache of LayerNorm 1.att: The cache of attention.ln2: The cache of LayerNorm 2.ff: The cache of the feed-forward layer.transformer.py:139block_backward, at transformer.py:187

Code and math connection

EquationRoleLinesCode
The sequence must fit the position tableComputes ittransformer.py:156assert len(ids) <= cfg.context_len, "sequence longer than the position table"
Blocks in sequenceComputes ittransformer.py:162X, c = block_forward(X, params, l, cfg)
Final LayerNormComputes ittransformer.py:165Xf, c_lnf = layer_norm_forward(X, params["lnf_g"], params["lnf_b"], cfg.ln_eps)
The headWritten inlinetransformer.py:166logits = Xf @ params["head_w"] + params["head_b"]
The last two steps togetherComputes ittransformer.py:165Xf, c_lnf = layer_norm_forward(X, params["lnf_g"], params["lnf_b"], cfg.ln_eps)
The last two steps togetherWritten inlinetransformer.py:166logits = Xf @ params["head_w"] + params["head_b"]
The last two steps togetherTests ittests/test_transformer.py:83la, _ = forward(params, a, cfg)

Where is it used?

PDF reference

Section 4.5, Eq. 54, p. 11

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
The sequence must fit the position tableComputes ittransformer.pyforward156GitHub: transformer.py, line 156
Blocks in sequenceComputes ittransformer.pyforward162GitHub: transformer.py, line 162
Final LayerNormComputes ittransformer.pyforward165GitHub: transformer.py, line 165
The headWritten inlinetransformer.pyforward166GitHub: transformer.py, line 166
The last two steps togetherComputes ittransformer.pyforward165GitHub: transformer.py, line 165
The last two steps togetherWritten inlinetransformer.pyforward166GitHub: transformer.py, line 166

Backward pass

What is it?

Whole model, backwardImplemented

Whole model

loss_and_grads runs forward and the cross-entropy loss on one sequence, then the whole backward pass. It goes through the head, the final LayerNorm, the blocks last to first, and the embeddings. It returns the loss and a dictionary with one gradient per parameter, under the parameter's own key.

Why does the model need it?

The optimizer needs one gradient per parameter, and each backward function needs the gradient produced by the step after it in the forward pass. This function calls them in that order and keeps the results together.

Mathematics

Every derivative here was derived on an earlier page. The chain starts where the loss meets the logits, from Softmax & Cross-Entropy:

The combined backward, where E_y is one-hot at each correct token #Link to equation: The combined backward, where E_y is one-hot at each correct token
dZ=P−EyTdZ = \frac{P - E_y}{T}

The head is a linear layer, so the formulas from Linear / ReLU / Initialisation apply, with X = Xf, W = head_w and dY = dZ:

Gradient on the weights #Link to equation: Gradient on the weights
dW=X⊤dYdW = X^{\top} dY
Gradient on the bias, the sum of dY over rows #Link to equation: Gradient on the bias, the sum of dY over rows
db=∑tdYtdb = \sum_{t} dY_{t}
Gradient on the input #Link to equation: Gradient on the input
dX=dY W⊤dX = dY\, W^{\top}

The next lines state only the order of the calls. They add no new derivative: the first is the three formulas above, and the others are derived on LayerNorm, Transformer Block and Embeddings.

Order of calls, the head #Link to equation: Order of calls, the head
dXf, dWh, dbh=Head⁡back(dZ)dX_f,\ dW_h,\ db_h = \operatorname{Head}^{\text{back}}(dZ)
Order of calls, the final LayerNorm #Link to equation: Order of calls, the final LayerNorm
dX(L), dγf, dβf=LN⁡fback(dXf)dX^{(L)},\ d\gamma_f,\ d\beta_f = \operatorname{LN}_f^{\text{back}}(dX_f)
Order of calls, the blocks in reverse #Link to equation: Order of calls, the blocks in reverse
dX(l)=Block⁡lback ⁣(dX(l+1)),l=L−1,…,0dX^{(l)} = \operatorname{Block}_l^{\text{back}}\!\big(dX^{(l+1)}\big), \qquad l = L - 1, \dots, 0
Order of calls, the embeddings #Link to equation: Order of calls, the embeddings
d tok_emb, d pos_emb=Embed⁡back ⁣(dX(0))d\,\mathrm{tok\_emb},\ d\,\mathrm{pos\_emb} = \operatorname{Embed}^{\text{back}}\!\big(dX^{(0)}\big)

What does each symbol mean?

SymbolMeaningIn codeShape
dZdZGradient of the loss on the logitsdZ(T, V)
P,EyP, E_yThe probabilities, and one-hot rows of the targetsprobs, and the -= 1.0 in cross_entropy_backward(T, V)
dXfdX_fGradient on the output of the final LayerNormdXf(T, d)
dWh,dbhdW_h, db_hGradients on the headdW, db(d, V) and (V,)
dγf,dβfd\gamma_f, d\beta_fGradients on the final LayerNormdg, db(d,)
dX(l)dX^{(l)}Gradient on the residual stream before block l. The code reuses the name dXdX(T, d)
Head⁡back,LN⁡fback\operatorname{Head}^{\text{back}}, \operatorname{LN}_f^{\text{back}}linear_backward with the head, and layer_norm_backward with the final LayerNorm's cachelinear_backward, layer_norm_backwardfunctions
Block⁡lback,Embed⁡back\operatorname{Block}_l^{\text{back}}, \operatorname{Embed}^{\text{back}}block_backward for block l, and embed_backwardblock_backward, embed_backwardfunctions
X,W,dYX, W, dYThe linear layer's names on the page that derives it. At the head they are Xf, head_w and dZX, W, dY(T, d), (d, V) and (T, V)
d tok_emb,d pos_embd\,\mathrm{tok\_emb}, d\,\mathrm{pos\_emb}Gradients on the two embedding tablesgrads["tok_emb"], grads["pos_emb"](V, d) and (T_max, d)

Tiny numerical example

The gradient on its way backIllustrative ExampleA tiny model: the parameters are random draws from the real init_params, not learned values. The other inputs are hand-picked. ids = [2, 0, 2], targets = [0, 2, 1].

loss = 2.5621

dZ, shape (3, 5)
-0.30390.01420.03440.14120.1141
0.03190.1872-0.29070.00840.0631
0.0381-0.31980.04430.15460.0829
dXf, shape (3, 4)
0.1688-0.03640.24270.2007
0.20940.02650.1092-0.022
-0.2382-0.09530.15740.2309
dX after the final LayerNorm, shape (3, 4)
0.0427-0.03540.0359-0.0432
0.0189-0.06960.1104-0.0597
-0.22760.08070.1849-0.0381
dX after block 1, shape (3, 4)
-0.00220.0050.0405-0.0433
0.0062-0.03290.0706-0.044
-0.20870.01390.10120.0936
dX after block 0, shape (3, 4)
-0.08930.1395-0.07790.0277
0.00710.02630.025-0.0584
-0.19680.0370.05690.1029
KeyShapeLargest entry
tok_emb(5, 4)2.86e-1
pos_emb(4, 4)1.97e-1
b0.ln1_g(4,)2.00e-1
b0.ln1_b(4,)1.94e-1
b0.wq(4, 4)1.05e-1
b0.bq(4,)9.06e-2
b0.wk(4, 4)2.87e-1
b0.bk(4,)1.39e-17
b0.wv(4, 4)2.10e-1
b0.bv(4,)2.36e-1
b0.wo(4, 4)3.59e-1
b0.bo(4,)2.43e-1
b0.ln2_g(4,)7.30e-2
b0.ln2_b(4,)4.44e-2
b0.ff1_w(4, 6)1.40e-1
b0.ff1_b(6,)1.21e-1
b0.ff2_w(6, 4)1.56e-1
b0.ff2_b(4,)2.12e-1
b1.ln1_g(4,)1.07e-1
b1.ln1_b(4,)1.86e-1
b1.wq(4, 4)2.62e-2
b1.bq(4,)2.19e-2
b1.wk(4, 4)7.08e-2
b1.bk(4,)2.08e-17
b1.wv(4, 4)2.55e-1
b1.bv(4,)1.80e-1
b1.wo(4, 4)3.51e-1
b1.bo(4,)2.18e-1
b1.ln2_g(4,)1.25e-1
b1.ln2_b(4,)1.77e-1
b1.ff1_w(4, 6)1.85e-1
b1.ff1_b(6,)2.40e-1
b1.ff2_w(6, 4)2.57e-1
b1.ff2_b(4,)3.31e-1
lnf_g(4,)6.34e-1
lnf_b(4,)5.09e-1
head_w(4, 5)6.45e-1
head_b(5,)3.04e-1
Checking the backward numericallyIllustrative ExampleA test-time idea, not part of the pipeline: gradcheck.numerical_grad is only called by tests. It nudges each number up and down by h = 1e-5 and measures how the loss changes. Tests compare the central differences here with that real function.
GradientFrom the backward formulaFrom central differencesLargest gap
grads["tok_emb"][0.0071, 0.0263, 0.0250, -0.0584][0.0000, 0.0000, 0.0000, 0.0000][-0.2861, 0.1765, -0.0209, 0.1306][0.0000, 0.0000, 0.0000, 0.0000][0.0000, 0.0000, 0.0000, 0.0000][0.0071, 0.0263, 0.0250, -0.0584][0.0000, 0.0000, 0.0000, 0.0000][-0.2861, 0.1765, -0.0209, 0.1306][0.0000, 0.0000, 0.0000, 0.0000][0.0000, 0.0000, 0.0000, 0.0000]5.9e-10
grads["pos_emb"][-0.0893, 0.1395, -0.0779, 0.0277][0.0071, 0.0263, 0.0250, -0.0584][-0.1968, 0.0370, 0.0569, 0.1029][0.0000, 0.0000, 0.0000, 0.0000][-0.0893, 0.1395, -0.0779, 0.0277][0.0071, 0.0263, 0.0250, -0.0584][-0.1968, 0.0370, 0.0569, 0.1029][0.0000, 0.0000, 0.0000, 0.0000]5.5e-11
grads["b0.wk"][-0.2875, 0.1618, 0.0006, -0.0032][0.0849, -0.0466, -0.0000, 0.0009][-0.0604, 0.0338, 0.0001, -0.0007][0.2630, -0.1489, -0.0007, 0.0030][-0.2875, 0.1618, 0.0006, -0.0032][0.0849, -0.0466, -0.0000, 0.0009][-0.0604, 0.0338, 0.0001, -0.0007][0.2630, -0.1489, -0.0007, 0.0030]2.0e-10
grads["b0.ff1_w"][0.0020, -0.0642, 0.1397, 0.0000, 0.0000, -0.0112][-0.0014, 0.1045, -0.1138, 0.0000, 0.0000, 0.0075][-0.0015, 0.0791, 0.0038, 0.0000, 0.0000, 0.0082][0.0008, -0.1194, -0.0296, 0.0000, 0.0000, -0.0046][0.0020, -0.0642, 0.1397, 0.0000, 0.0000, -0.0112][-0.0014, 0.1045, -0.1138, 0.0000, 0.0000, 0.0075][-0.0015, 0.0791, 0.0038, 0.0000, 0.0000, 0.0082][0.0008, -0.1194, -0.0296, 0.0000, 0.0000, -0.0046]4.5e-11
grads["b1.ln2_g"][-0.0470, 0.0010, -0.1254, -0.0685][-0.0470, 0.0010, -0.1254, -0.0685]1.9e-11
grads["b1.wq"][0.0004, -0.0009, -0.0111, 0.0164][-0.0002, 0.0003, 0.0047, -0.0073][0.0017, -0.0040, -0.0198, -0.0127][-0.0019, 0.0045, 0.0262, 0.0036][0.0004, -0.0009, -0.0111, 0.0164][-0.0002, 0.0003, 0.0047, -0.0073][0.0017, -0.0040, -0.0198, -0.0127][-0.0019, 0.0045, 0.0262, 0.0036]4.0e-11
grads["lnf_g"][0.4220, 0.1531, 0.0587, 0.6343][0.4220, 0.1531, 0.0587, 0.6343]1.9e-11
grads["head_w"][0.1406, 0.4892, -0.5165, -0.1275, 0.0142][0.3896, 0.3164, -0.0846, -0.3673, -0.2540][-0.1836, -0.1605, 0.2436, 0.0812, 0.0193][-0.3466, -0.6451, 0.3576, 0.4136, 0.2205][0.1406, 0.4892, -0.5165, -0.1275, 0.0142][0.3896, 0.3164, -0.0846, -0.3673, -0.2540][-0.1836, -0.1605, 0.2436, 0.0812, 0.0193][-0.3466, -0.6451, 0.3576, 0.4136, 0.2205]3.9e-11
grads["head_b"][-0.2339, -0.1184, -0.2120, 0.3042, 0.2602][-0.2339, -0.1184, -0.2120, 0.3042, 0.2602]1.9e-11

Two rows of that table are worth a closer look. Each .bk entry is round-off around 0, because softmax ignores a constant added to a row, as Attention explains. The tiny pos_emb has more rows than this sequence has tokens, so its last row has a gradient of exactly 0, as Embeddings explains.

Tensor shapes

The gradient from the logits to the first block's inputtransformer.py:187
  1. dZ(T, V)(16, V)
  2. dXf(T, d)(16, 32)
  3. dX(T, d)(16, 32)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. V depends on the corpus. It is not a value in config.py. d = emb_dim = 32.

Parameter gradients of the headtransformer.py:176
  1. head_w(d, V)(32, V)
  2. head_b(V,)

d = emb_dim = 32. V depends on the corpus. It is not a value in config.py.

Parameter gradients of the embedding tablestransformer.py:188
  1. tok_emb(V, d)(V, 32)
  2. pos_emb(Tmax, d)(32, 32)

V depends on the corpus. It is not a value in config.py. d = emb_dim = 32. Tmax = context_len = 32.

Actual implementation

transformer.pyloss_and_gradsGitHub: transformer.py
def loss_and_grads(params, ids, targets, cfg: ModelConfig):    logits, cache = forward(params, ids, cfg)    loss, probs = cross_entropy_forward(logits, targets)    grads = {k: np.zeros_like(v) for k, v in params.items()}    dZ = cross_entropy_backward(probs, targets)    dXf, dW, db = linear_backward(cache["Xf"], params["head_w"], dZ)    grads["head_w"] += dW    grads["head_b"] += db    dX, dg, db = layer_norm_backward(dXf, cache["lnf"], params["lnf_g"])    grads["lnf_g"] += dg    grads["lnf_b"] += db    for l in reversed(range(cfg.num_layers)):        dX = block_backward(dX, cache["blocks"][l], params, l, cfg, grads)    embed_backward(dX, ids, grads)    return loss, grads

The order is forward, cross_entropy_forward, cross_entropy_backward, linear_backward for the head, layer_norm_backward for the final LayerNorm, block_backward for each block with the last block first (transformer.py:186), and embed_backward.

Code and math connection

EquationRoleLinesCode
Order of calls, the headComputes ittransformer.py:176dXf, dW, db = linear_backward(cache["Xf"], params["head_w"], dZ)
Order of calls, the final LayerNormComputes ittransformer.py:181dX, dg, db = layer_norm_backward(dXf, cache["lnf"], params["lnf_g"])
Order of calls, the blocks in reverseComputes ittransformer.py:186for l in reversed(range(cfg.num_layers)):
Order of calls, the blocks in reverseComputes ittransformer.py:187dX = block_backward(dX, cache["blocks"][l], params, l, cfg, grads)
Order of calls, the embeddingsComputes ittransformer.py:188embed_backward(dX, ids, grads)
Order of calls, the embeddingsTests ittests/test_transformer.py:94assert grads.keys() == params.keys()

Where is it used?

test_every_tensor_passes compares its gradients with central differences, and test_overfit_one_window checks that its gradients can drive the loss down on one window (tests/test_transformer.py:110).

Source

transformer.py, lines 171-190, loss_and_grads

PDF reference

Section 4.5, p. 11

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Order of calls, the headComputes ittransformer.pyloss_and_grads176GitHub: transformer.py, line 176
Order of calls, the final LayerNormComputes ittransformer.pyloss_and_grads181GitHub: transformer.py, line 181
Order of calls, the blocks in reverseComputes ittransformer.pyloss_and_grads186GitHub: transformer.py, line 186
Order of calls, the blocks in reverseComputes ittransformer.pyloss_and_grads187GitHub: transformer.py, line 187
Order of calls, the embeddingsComputes ittransformer.pyloss_and_grads188GitHub: transformer.py, line 188

How this reaches a parameter update. batch_loss_and_grads calls this once per window and averages the dictionaries (train.py:22). train.main then clips the average (train.py:62) and passes it to opt.step (train.py:66). AdamW / Learning Rate / Clipping covers those steps.

Gradient flow

Each arrow below is one statement of loss_and_grads, with its text taken from the code at the line shown. Select a box to see the function that produced it, and the lines in loss_and_grads. Transformer Block has the same diagram for the inside of a block.

Gradient flow through the whole model, in the order of loss_and_grads
  1. logits, cache = forward(params, ids, cfg)transformer.py:172

    Also returns the cache that the backward steps read.

  2. loss, probs = cross_entropy_forward(logits, targets)transformer.py:173

    The shape is that of probs. loss is one number.

  3. grads = {k: np.zeros_like(v) for k, v in params.items()}transformer.py:174No function call

    Not a function call. One array of zeros per parameter, under the same key.

  4. dZ = cross_entropy_backward(probs, targets)transformer.py:175
  5. dXf, dW, db = linear_backward(cache["Xf"], params["head_w"], dZ)transformer.py:176

    Fills grads: head_w, head_b

  6. dX, dg, db = layer_norm_backward(dXf, cache["lnf"], params["lnf_g"])transformer.py:181

    Fills grads: lnf_g, lnf_b

  7. dX = block_backward(dX, cache["blocks"][l], params, l, cfg, grads)transformer.py:187

    Fills grads: all 16 entries named b1.*

  8. dX = block_backward(dX, cache["blocks"][l], params, l, cfg, grads)transformer.py:187

    Fills grads: all 16 entries named b0.*

  9. embed_backward(dX, ids, grads)transformer.py:188

    Fills grads: tok_emb, pos_emb

Selected: logits, Forward pass, at transformer.py:172

transformer.pyforwardGitHub: transformer.py
def forward(params, ids, cfg: ModelConfig):    assert len(ids) <= cfg.context_len, "sequence longer than the position table"    X = embed_forward(params, ids)    block_caches = []    for l in range(cfg.num_layers):        X, c = block_forward(X, params, l, cfg)        block_caches.append(c)    Xf, c_lnf = layer_norm_forward(X, params["lnf_g"], params["lnf_b"], cfg.ln_eps)    logits = Xf @ params["head_w"] + params["head_b"]    return logits, dict(blocks=block_caches, lnf=c_lnf, Xf=Xf)
transformer.pyloss_and_gradsGitHub: transformer.py
def loss_and_grads(params, ids, targets, cfg: ModelConfig):    logits, cache = forward(params, ids, cfg)    loss, probs = cross_entropy_forward(logits, targets)    grads = {k: np.zeros_like(v) for k, v in params.items()}    dZ = cross_entropy_backward(probs, targets)    dXf, dW, db = linear_backward(cache["Xf"], params["head_w"], dZ)    grads["head_w"] += dW    grads["head_b"] += db    dX, dg, db = layer_norm_backward(dXf, cache["lnf"], params["lnf_g"])    grads["lnf_g"] += dg    grads["lnf_b"] += db    for l in reversed(range(cfg.num_layers)):        dX = block_backward(dX, cache["blocks"][l], params, l, cfg, grads)    embed_backward(dX, ids, grads)    return loss, grads

The gradient dictionary

loss_and_grads starts by making one array of zeros per parameter, under the same key and with the same shape:

transformer.pyloss_and_gradsGitHub: transformer.py
    grads = {k: np.zeros_like(v) for k, v in params.items()}

Every backward step then adds to that dictionary. It adds in place, so block_backward and embed_backward return without handing grads back: their caller's dictionary already holds the result. The head and the final LayerNorm are added by loss_and_grads itself, and the gradient that goes on to the next step travels separately, as dX.

Within one call each key is written at one place in the code. Only tok_emb can receive several additions there: embed_backward adds one row per position, so a repeated id accumulates.

Because the keys match, Adam.step can look up the gradient of each parameter by name. A test checks that the keys and shapes match (tests/test_transformer.py:94).

Parameter keys

The dictionary is flat: one string key per array. The keys follow three rules:

  • A suffix per array. The feed-forward layers and the head end in _w for weights and _b for biases. A LayerNorm ends in _g for its scale and _b for its shift.
  • Attention's four projections come from a loop over q, k, v and o, so "w" + n gives wq and "b" + n gives bq, and so on (transformer.py:31-33).
  • Block entries start with a prefix that holds the block index, counted from 0 (transformer.py:28). block_forward and block_backward build the same prefix (transformer.py:132), so b0.wq is the query weights of the first block. The PDF writes this prefix bl. (p. 9). This site follows the code.

The table lists each name once, with b{l}. standing for every block. Names and shapes come from calling init_params. head_w is a separate matrix, not tied to tok_emb.

NameShapeWith configScalarsRole
tok_embtransformer.py:21(V, d)(V, 32)V × d32VToken table: one row per vocabulary id.Starts as Xavier uniform. Weight decay.
pos_embtransformer.py:22(T_max, d)(32, 32)T_max × d1,024Position table: one row per position.Starts as Xavier uniform. Weight decay.
b{l}.ln1_gtransformer.py:29(d,)(32,)d32 per blockLayerNorm 1 scale, before attention.Starts as ones. No decay.
b{l}.ln1_btransformer.py:29(d,)(32,)d32 per blockLayerNorm 1 shift, before attention.Starts as zeros. No decay.
b{l}.wqtransformer.py:32(d, d)(32, 32)d × d1,024 per blockQuery projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bqtransformer.py:33(d,)(32,)d32 per blockQuery projection bias.Starts as zeros. No decay.
b{l}.wktransformer.py:32(d, d)(32, 32)d × d1,024 per blockKey projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bktransformer.py:33(d,)(32,)d32 per blockKey projection bias.Starts as zeros. No decay.
b{l}.wvtransformer.py:32(d, d)(32, 32)d × d1,024 per blockValue projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bvtransformer.py:33(d,)(32,)d32 per blockValue projection bias.Starts as zeros. No decay.
b{l}.wotransformer.py:32(d, d)(32, 32)d × d1,024 per blockAttention output projection weights.Starts as Xavier uniform. Weight decay.
b{l}.botransformer.py:33(d,)(32,)d32 per blockAttention output projection bias.Starts as zeros. No decay.
b{l}.ln2_gtransformer.py:35(d,)(32,)d32 per blockLayerNorm 2 scale, before feed-forward.Starts as ones. No decay.
b{l}.ln2_btransformer.py:35(d,)(32,)d32 per blockLayerNorm 2 shift, before feed-forward.Starts as zeros. No decay.
b{l}.ff1_wtransformer.py:36(d, F)(32, 128)d × F4,096 per blockFirst feed-forward layer weights.Starts as Xavier uniform. Weight decay.
b{l}.ff1_btransformer.py:36(F,)(128,)F128 per blockFirst feed-forward layer bias.Starts as zeros. No decay.
b{l}.ff2_wtransformer.py:37(F, d)(128, 32)d × F4,096 per blockSecond feed-forward layer weights.Starts as Xavier uniform. Weight decay.
b{l}.ff2_btransformer.py:37(d,)(32,)d32 per blockSecond feed-forward layer bias.Starts as zeros. No decay.
lnf_gtransformer.py:39(d,)(32,)d32Final LayerNorm scale.Starts as ones. No decay.
lnf_btransformer.py:39(d,)(32,)d32Final LayerNorm shift.Starts as zeros. No decay.
head_wtransformer.py:40(d, V)(32, V)V × d32VLinear head weights. A separate matrix, not tied to tok_emb.Starts as Xavier uniform. Weight decay.
head_btransformer.py:40(V,)(V,)VVLinear head bias.Starts as zeros. No decay.

config.py has num_layers = 2, so the b{l}. rows appear once per block in the real dictionary:

Every key as it appears in the dictionary: 38 entries at num_layers = 2
KeyShape with config
tok_emb(V, 32)
pos_emb(32, 32)
b0.ln1_g(32,)
b0.ln1_b(32,)
b0.wq(32, 32)
b0.bq(32,)
b0.wk(32, 32)
b0.bk(32,)
b0.wv(32, 32)
b0.bv(32,)
b0.wo(32, 32)
b0.bo(32,)
b0.ln2_g(32,)
b0.ln2_b(32,)
b0.ff1_w(32, 128)
b0.ff1_b(128,)
b0.ff2_w(128, 32)
b0.ff2_b(32,)
b1.ln1_g(32,)
b1.ln1_b(32,)
b1.wq(32, 32)
b1.bq(32,)
b1.wk(32, 32)
b1.bk(32,)
b1.wv(32, 32)
b1.bv(32,)
b1.wo(32, 32)
b1.bo(32,)
b1.ln2_g(32,)
b1.ln2_b(32,)
b1.ff1_w(32, 128)
b1.ff1_b(128,)
b1.ff2_w(128, 32)
b1.ff2_b(32,)
lnf_g(32,)
lnf_b(32,)
head_w(32, V)
head_b(V,)

The number of scalars, worked out from these shapes, with V left as a symbol:

Implemented
N=2Vd+Tmax⁡d+2d+V+L(4d2+2dF+9d+F)N = 2V d + T_{\max} d + 2d + V + L\left(4d^{2} + 2d F + 9d + F\right)
N=65 V+26,496N = 65\,V + 26{,}496