LLM Developer DocsLLM Docs

Loading the search index ...

↑ ↓ move, Enter open, Esc close

GitHub

Gradient Checking

How gradcheck.py estimates gradients with central differences, how it compares them with the hand-written backward code, what the tests assert, and how to read a failure.

gradcheck.py

Note

This whole page is test-only mathematics. gradcheck.py is not part of the pipeline: neither training nor generation imports it. Only tests/test_gradcheck.py does.

The project has no autograd. Every backward function is written by hand, so the tests need another way to tell whether a gradient is right. They estimate each gradient from the loss alone, using only forward evaluations, and compare the two.

Central differences

What is it?

Central differenceImplemented

Gradient checking

numerical_grad estimates a gradient without using any backward code. For each entry of x, it raises the entry by h, evaluates f, lowers it by h, evaluates f again, and divides the change by 2h. It then puts the entry back. Only tests call it.

Why does the model need it?

This project has no autograd, so every backward function is written by hand and could be wrong. Central differences use only forward evaluations, so they give an estimate that does not depend on the backward code being checked.

Mathematics

Central difference for one entry #Link to equation: Central difference for one entry
gi=f(x+h ei)−f(x−h ei)2hg_i = \frac{f(x + h\, e_i) - f(x - h\, e_i)}{2h}

The default step is h = 1e-5 (gradcheck.py:3). check_params has its own h argument with the same default, and the tests leave both at their defaults.

Note

In general, a central difference is more accurate than a one-sided one, because its leading error term cancels. The error from the formula shrinks as h gets smaller, but round-off grows, because fp - fm becomes a tiny difference between two nearly equal numbers. So h should be neither too large nor too small. This is a general explanation. The repository only fixes one value, and it does not study this trade-off.

What does each symbol mean?

SymbolMeaningIn codeShape
ffA function of x that returns one numberfscalar
xxThe array being varied. It is changed in place and restoredxany
hhThe step. The default is 1e-5hscalar
eie_iOne at entry i and zero elsewhere. The code writes this by editing x[idx] directlyidxindex into x
f(x+h ei), f(x−h ei)f(x + h\, e_i),\ f(x - h\, e_i)The two evaluationsfp, fmscalar
ggThe estimated gradientgsame as x

Tiny numerical example

Try h = 0.1, then h = 1e-13. For x * x * x, the error grows in both directions. For x * x, the formula's error is exactly 0, so only round-off shows.

The test's quadraticIllustrative ExampleThe hand-picked input of test_numerical_grad_on_quadratic. The function is the sum of squares, so its gradient is 2x.
StepCodeValue
Inputx[1.0000, -2.0000, 3.0000]
Analytic gradient2 * x[2.0000, -4.0000, 6.0000]
Central differencenumerical_grad(f, x)[2.0000, -4.0000, 6.0000]
Analytic against numericalIllustrative ExampleHand-picked functions and numbers that you can edit. Neither function is in the repository: x * x matches the test's quadratic for one entry, and x * x * x is here because h changes its result.

f(x)AnalyticCentral differenceRelative error
x * x2x = 33.0000000000196536.55e-12
x * x * x3x^2 = 6.756.750000000144142.14e-11

Tensor shapes

g starts as np.zeros_like(x), so it has the shape of x, whatever that is. The only caller, test_numerical_grad_on_quadratic, passes a vector of 3 numbers. No test runs numerical_grad on a model parameter. That is the job of check_params, below.

Actual implementation

gradcheck.pynumerical_gradGitHub: gradcheck.py
def numerical_grad(f, x, h = 1e-5):    g = np.zeros_like(x)    for idx in np.ndindex(*x.shape):        old = x[idx]        x[idx] = old + h        fp = f(x)        x[idx] = old - h        fm = f(x)        x[idx] = old        g[idx] = (fp - fm) / (2 * h)    return g

Code and math connection

EquationRoleLinesCode
Central difference for one entryComputes itgradcheck.py:12g[idx] = (fp - fm) / (2 * h)
Central difference for one entryWritten inlinegradcheck.py:31w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h)))
Central difference for one entryTests ittests/test_gradcheck.py:18g = numerical_grad(lambda v: float((v ** 2).sum()), x) # d/dx sum(x^2) = 2x

Where is it used?

Source

gradcheck.py, lines 3-14, numerical_grad

PDF reference

Section 4.4, Eq. 37, p. 8

Used by

Not called by the pipeline.

Tested by: tests.test_gradcheck.test_numerical_grad_on_quadratic

Status
Implemented

Implemented in

NoteInline equivalent: the pipeline does not call gradcheck.numerical_grad. It computes the same formula in place at gradcheck.py:31.
EquationRoleFileFunctionLinesGitHub
Central difference for one entryComputes itgradcheck.pynumerical_grad12GitHub: gradcheck.py, line 12
Central difference for one entryWritten inlinegradcheck.pycheck_params31GitHub: gradcheck.py, line 31

Relative error

What is it?

Relative errorImplemented

Gradient checking

relative_error compares two numbers. It divides their absolute difference by the larger of their two sizes, or by a small constant eps if that is larger still.

Why does the model need it?

In general, gradients can differ in size by many orders of magnitude, so a single absolute limit would suit some entries and not others. A relative measure allows one limit for all of them. The eps guard keeps the division defined when both numbers are 0.

Mathematics

Relative error with the epsilon guard #Link to equation: Relative error with the epsilon guard
rel⁡(a,b)=∣a−b∣max⁡(∣a∣, ∣b∣, ε)\operatorname{rel}(a, b) = \frac{|a - b|}{\max\left(|a|,\, |b|,\, \varepsilon\right)}

The epsilon guard. If a and b are both 0, the denominator would be 0 without eps. With it, the result is 0 / 1e-12, which is 0. This happens in practice: at any entry the loss does not depend on, both the gradient and the central difference are exactly 0.

The guard only helps below 1e-12. If both numbers are near 0, but one is above eps, the result is close to 1. The last row of the example shows this, and it is why the test skips one kind of parameter, as described under What the tests assert.

What does each symbol mean?

SymbolMeaningIn codeShape
aaFirst number. check_params passes the gradient from the backward codeascalar
bbSecond number. check_params passes the central differencebscalar
ε\varepsilonThe guard on the denominator. The default is 1e-12epsscalar

Tiny numerical example

relative_error on six pairsIllustrative ExampleHand-picked pairs. eps is the function's default.
abResultNote
11.00010.00009999An ordinary pair.
2.52.50Equal numbers give 0.
1-12.0002 is the largest value this formula can give.
000The guard keeps this at 0 rather than 0 / 0.
1e-1300.1000Both are below eps, so eps is the denominator.
1e-174e-121.000Both are near 0, but b is above eps, so the result is close to 1.

Tensor shapes

Both arguments are scalars, and so is the result. check_params calls it once for each sampled entry.

Actual implementation

gradcheck.pyrelative_errorGitHub: gradcheck.py
def relative_error(a, b, eps = 1e-12):    return abs(a - b) / max(abs(a), abs(b), eps)

Code and math connection

EquationRoleLinesCode
Relative error with the epsilon guardComputes itgradcheck.py:17return abs(a - b) / max(abs(a), abs(b), eps)
Relative error with the epsilon guardCalls itgradcheck.py:31w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h)))

Where is it used?

Its only caller is check_params, which only a test calls, so the pipeline never reaches it. No test calls it directly.

Source

gradcheck.py, lines 16-17, relative_error

PDF reference

Section 4.4, Eq. 38, p. 8

Used by

Called by: gradcheck.check_params

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Relative error with the epsilon guardComputes itgradcheck.pyrelative_error17GitHub: gradcheck.py, line 17

Checking every parameter

What is it?

Checking every parameterImplemented

Gradient checking

check_params goes through the parameter dictionary one name at a time. For each name it picks a few random entries, compares the gradient from the backward code with a central difference at each one, and keeps the worst relative error. It returns a dictionary from parameter name to worst error.

Why does the model need it?

It checks the whole forward and backward pass against the loss, for every parameter. Sampling keeps the test quick, because each sampled entry costs two evaluations of the loss. Reporting by name says which tensor disagrees.

Mathematics

Worst relative error for one parameter name #Link to equation: Worst relative error for one parameter name
worst⁡[n]=max⁡s=1,…,S rel⁡ ⁣(∂L∂θn[is], L(θn[is]+h)−L(θn[is]−h)2h)\operatorname{worst}[n] = \max_{s = 1,\ldots,S}\ \operatorname{rel}\!\left(\frac{\partial L}{\partial \theta_n}[i_s],\ \frac{L(\theta_n[i_s] + h) - L(\theta_n[i_s] - h)}{2h}\right)

For each name, the function draws samples_per_param random entries, 5 by default. Each entry takes one random integer per axis (gradcheck.py:24). It nudges that entry in place, runs the loss twice, restores the entry, and keeps the largest relative error so far.

What does each symbol mean?

SymbolMeaningIn codeShape
nnA key of the parameter dictionary, such as pos_emb or b0.wqnamestring
θn\theta_nThe parameter with that nameWthat parameter's own shape
SSSamples for each name. The default is 5samples_per_paramscalar
isi_sA random entry, one random integer per axis. Samples can repeat, because they are drawn with replacementidxtuple, one integer per axis
LLThe loss, as a function of the parametersloss_fnscalar
∂L∂θn\frac{\partial L}{\partial \theta_n}The gradient from the backward code, computed once before the callgrads[name]same as W
hhThe step. The default is 1e-5, the same as numerical_gradhscalar
worst⁡[n]\operatorname{worst}[n]The value returned for that name. It starts at 0.0worst[name]scalar

Tiny numerical example

The example is in Reading a failure, where its columns can be compared.

Tensor shapes

grads[name] has the shape of the parameter with that name, because loss_and_grads builds grads with np.zeros_like. Each idx has one integer per axis of that shape.

One name, at the sizes in config.py. The test itself uses a smaller ModelConfiggradcheck.py:24
  1. W = pos_emb(Tmax, d)(32, 32)
  2. grads["pos_emb"](Tmax, d)(32, 32)

Tmax = context_len = 32. d = emb_dim = 32.

Actual implementation

gradcheck.pycheck_paramsGitHub: gradcheck.py
def check_params(loss_fn, params, grads, rng, samples_per_param = 5, h = 1e-5):    worst: dict[str, float] = {}    for name, W in params.items():        w = 0.0        for _ in range(samples_per_param):            idx = tuple(int(rng.integers(s)) for s in W.shape)            old = W[idx]            W[idx] = old + h            fp = loss_fn(params)            W[idx] = old - h            fm = loss_fn(params)            W[idx] = old            w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h)))        worst[name] = w    return worst

Code and math connection

EquationRoleLinesCode
Worst relative error for one parameter nameComputes itgradcheck.py:31w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h)))
Worst relative error for one parameter nameComputes itgradcheck.py:32worst[name] = w
Worst relative error for one parameter nameTests ittests/test_gradcheck.py:26worst = check_params(loss_fn, params, grads, rng)

Where is it used?

Source

gradcheck.py, lines 19-33, check_params

PDF reference

Section 4.4, p. 9

Used by

Not called by the pipeline.

Tested by: tests.test_gradcheck.test_every_tensor_passes

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Worst relative error for one parameter nameComputes itgradcheck.pycheck_params31GitHub: gradcheck.py, line 31
Worst relative error for one parameter nameComputes itgradcheck.pycheck_params32GitHub: gradcheck.py, line 32

What the tests assert

tests/test_gradcheck.py builds a small model, smaller than the one in config.py, and passes check_params a function that returns only the loss:

Code listing: tests.test_gradcheck.tiny_setup
tests/test_gradcheck.pytiny_setupGitHub: tests/test_gradcheck.py
def tiny_setup():    cfg = ModelConfig(vocab_size=7, context_len=8, emb_dim=4,                      num_heads=2, ff_dim=8, num_layers=2)    rng = np.random.default_rng(0)    params = init_params(cfg, rng)              # float64    ids = rng.integers(7, size=5)    targets = rng.integers(7, size=5)    return cfg, params, ids, targets, rng
Code listing: tests.test_gradcheck.test_every_tensor_passes
tests/test_gradcheck.pytest_every_tensor_passesGitHub: tests/test_gradcheck.py
def test_every_tensor_passes():    cfg, params, ids, targets, rng = tiny_setup()    loss_fn = lambda p: loss_and_grads(p, ids, targets, cfg)[0]    _, grads = loss_and_grads(params, ids, targets, cfg)    worst = check_params(loss_fn, params, grads, rng)    for name, err in worst.items():        if name.endswith(".bk"):            continue                       # true gradient is 0, checked below        assert err < 1e-4, (name, err)

The file has three limits, and these are all of them:

TestComparesLimit
test_every_tensor_passesWorst relative error for every name except those ending .bkbelow 1e-4
test_key_bias_gradient_is_zeroLargest absolute value in the gradient of each b{l}.bkbelow 1e-12
test_numerical_grad_on_quadraticnumerical_grad of a sum of squares against 2 * xatol=1e-8 (np.allclose)

The last one also applies the default relative tolerance of np.allclose.

Why .bk is skipped. The key bias adds the same amount to every score in a row, and softmax ignores that, so its true gradient is 0. Both the backward code and the central difference then give only round-off, and as the Relative error section shows, the ratio of two such numbers says nothing useful. So a separate test checks the backward value directly:

tests/test_gradcheck.pytest_key_bias_gradient_is_zeroGitHub: tests/test_gradcheck.py
        assert np.abs(grads[f"b{l}.bk"]).max() < 1e-12

Limits of these checks.

  • Each name gets only 5 random entries, so a mistake in a single entry of a large tensor can go unnoticed.
  • The test runs one setup with one seed, on the tiny model, not at the sizes in config.py.
  • The test test_numerical_grad_on_quadratic checks numerical_grad itself. check_params repeats the same formula in its own code and does not call numerical_grad.

Reading a failure

A failed assertion prints the name and the error, because of the second part of assert err < 1e-4, (name, err). The name is a key of the parameter dictionary, and it tells you which tensor to look at:

Name in the reportWhat it isWhere its gradient is filled
tok_embToken tabletransformer.py:56
pos_embPosition tabletransformer.py:57
b{l}.ln1_g, b{l}.ln1_bFirst LayerNorm of block ltransformer.py:150
b{l}.wq, b{l}.bq, b{l}.wk, b{l}.bk, b{l}.wv, b{l}.bvQuery, key and value of block ltransformer.py:106-108
b{l}.wo, b{l}.boAttention output of block ltransformer.py:95-97
b{l}.ln2_g, b{l}.ln2_bSecond LayerNorm of block ltransformer.py:145
b{l}.ff1_w, b{l}.ff1_bFirst feed-forward layertransformer.py:125
b{l}.ff2_w, b{l}.ff2_bSecond feed-forward layertransformer.py:120
lnf_g, lnf_bFinal LayerNormtransformer.py:183
head_w, head_bHeadtransformer.py:178

The pattern of names says more than one name does. Backward runs from the head to the embeddings, so a wrong gradient that is passed along affects everything computed after it:

  • One name fails. Look at the line that fills that tensor. In the second column below, only b1.wq is above the limit.
  • Many names fail together. Go through the names in backward order, and look between the last one that passes and the first one that fails. In the third column, the value block_backward returns for block 1 is wrong. Block 1 still passes, because its gradients are filled before it returns. Block 0 and both embedding tables fail.
Worst relative error per nameIllustrative ExampleStored results of the real check_params on the hand-picked tiny model of tiny_setup, with 38 names and 380 loss evaluations per column. This is not the trained model. The second and third columns scale a gradient by 1.01 on purpose, in the script that makes this table only. Values far below the limit are round-off and can differ between machines. The test skips the rows marked .bk.
NameCorrect backwardOne tensor wrong: grads["b1.wq"]Block output wrong: what block 1 returns
tok_emb1.1e-81.1e-89.9e-3 above
pos_emb2.2e-92.2e-99.9e-3 above
b0.ln1_g5.0e-105.0e-109.9e-3 above
b0.ln1_b1.1e-91.1e-99.9e-3 above
b0.wq3.9e-83.9e-89.9e-3 above
b0.bq4.2e-104.2e-109.9e-3 above
b0.wk5.5e-105.5e-109.9e-3 above
b0.bk (skipped)1.0e+01.0e+01.0e+0
b0.wv6.1e-96.1e-99.9e-3 above
b0.bv9.5e-109.5e-109.9e-3 above
b0.wo9.1e-109.1e-109.9e-3 above
b0.bo1.8e-91.8e-99.9e-3 above
b0.ln2_g6.4e-106.4e-109.9e-3 above
b0.ln2_b5.8e-105.8e-109.9e-3 above
b0.ff1_w1.1e-81.1e-89.9e-3 above
b0.ff1_b1.0e-91.0e-99.9e-3 above
b0.ff2_w3.5e-93.5e-99.9e-3 above
b0.ff2_b9.2e-109.2e-109.9e-3 above
b1.ln1_g1.6e-91.6e-91.6e-9
b1.ln1_b5.0e-105.0e-105.0e-10
b1.wq1.9e-89.9e-3 above1.9e-8
b1.bq3.8e-93.8e-93.8e-9
b1.wk1.3e-81.3e-81.3e-8
b1.bk (skipped)2.8e-52.8e-52.8e-5
b1.wv4.5e-104.5e-104.5e-10
b1.bv5.1e-105.1e-105.1e-10
b1.wo5.3e-95.3e-95.3e-9
b1.bo7.0e-107.0e-107.0e-10
b1.ln2_g2.7e-92.7e-92.7e-9
b1.ln2_b9.6e-89.6e-89.6e-8
b1.ff1_w1.3e-91.3e-91.3e-9
b1.ff1_b6.8e-106.8e-106.8e-10
b1.ff2_w5.8e-95.8e-95.8e-9
b1.ff2_b1.9e-81.9e-81.9e-8
lnf_g2.9e-102.9e-102.9e-10
lnf_b2.4e-102.4e-102.4e-10
head_w7.5e-107.5e-107.5e-10
head_b5.6e-105.6e-105.6e-10

The .bk rows hold round-off, as explained above, so their values are not a failure.

The assertion stops at the first failure. test_every_tensor_passes loops over worst in the order init_params created the names, and the first failure ends the test. With the third column, the report names only tok_emb, the first name in the dictionary, even though the mistake is elsewhere. To see the pattern, print the whole dictionary that check_params returns.

A check can only point to where a gradient disagrees with the loss. It cannot say whether the mistake is in the forward or the backward code, because the loss comes from the forward code, and the check treats it as correct.