the pytorch path · 0/12
start the path

the PyTorch path · Own the state, own the loop · lesson 02 of 3

Between backward and step

Two lines commonly sit between backward and step, and each one makes an arithmetic claim about a batch that was never actually formed. Get either wrong and nothing raises. The loss curve just goes somewhere else.

the goal Given an accumulation schedule and a clipping call, say which divisor makes the accumulated gradient equal the full-batch gradient, where the clip has to sit and why, and what one non-finite entry does to every other gradient in the model.

mastery work · this chapter0/3
  1. go →
  2. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

Accumulation is an arithmetic claim

Gradient accumulation is usually introduced as a way around a memory limit, and the mechanical part is trivial: skip zero_grad, let .grad sum across several backward passes, step once at the end. The claim underneath is the interesting half, because you are asserting that the sum you built equals the gradient of a batch you never had room for.

It does, on one condition. mse_loss and cross_entropy average over their own batch by default, so four micro-batches of 16 give you four averages over 16 rows, and summing those is four times the average over 64 rows. Divide each micro-batch loss by four before backward and the identity holds.

Run both and the difference is not subtle. With the divisor, the accumulated gradient and the full-batch gradient agree to 2.4e-07, which is float32 rounding on a sum of 64 terms. Without it, the largest element is off by 4.8, and the gradient norms sit at a ratio of exactly 4.0.

run it (verified, torch 2.2.2 CPU): four micro-batches against one batch of 64, with the divisor and without
import torch
from torch import nn


def fresh():
    torch.manual_seed(0)
    return nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 1))


def grads(m):
    return [p.grad.detach().clone() for p in m.parameters()]


def gap(a, b):
    return max((u - v).abs().max().item() for u, v in zip(a, b))


torch.manual_seed(1)
x = torch.randn(64, 8)
y = x.sum(dim=1, keepdim=True)

m = fresh()
nn.functional.mse_loss(m(x), y).backward()
full = grads(m)                                  # one batch of 64

m = fresh()
for i in range(4):
    s = slice(i * 16, (i + 1) * 16)
    (nn.functional.mse_loss(m(x[s]), y[s]) / 4).backward()
print(gap(full, grads(m)))                       # 2.384185791015625e-07

m = fresh()
for i in range(4):
    s = slice(i * 16, (i + 1) * 16)
    nn.functional.mse_loss(m(x[s]), y[s]).backward()
print(gap(full, grads(m)))                       # 4.807855606079102
§ 02

The tail batch breaks the divisor

Dividing by the number of micro-batches is a shortcut that happens to be correct when every micro-batch is the same size. Datasets are rarely so obliging, and the last group in an epoch is the one that catches people.

Split 64 rows as 24, 24 and 16 and divide each loss by three, and the result misses the full-batch gradient by 0.202 in its largest element. Weight each micro-batch by its own sample count over the total instead, 24/64 and 24/64 and 16/64, and the gap drops back to 2.4e-07.

The rule that survives every batch shape: the weight on a micro-batch is its share of the samples, not its share of the micro-batches. A loop that hardcodes loss / accum_steps is asserting the two are the same thing.

continuing the block above (verified, torch 2.2.2 CPU): a 24/24/16 split, weighted two ways
def accumulate(weights):
    m = fresh()
    off = 0
    for n, w in zip([24, 24, 16], weights):
        s = slice(off, off + n)
        off += n
        (nn.functional.mse_loss(m(x[s]), y[s]) * w).backward()
    return grads(m)


print(gap(full, accumulate([1 / 3, 1 / 3, 1 / 3])))          # 0.20217061042785645
print(gap(full, accumulate([24 / 64, 24 / 64, 16 / 64])))    # 2.384185791015625e-07
§ 03

What accumulation does not reproduce

The identity covers gradients and stops there. Anything in the model that computes a statistic over the rows in front of it sees micro-batches, because micro-batches are what it was given.

Batch normalization is the clear case. One BatchNorm1d fed 64 rows at once and another fed the same 64 rows in four passes end up with running means that differ by 0.049, and their num_batches_tracked counters read 1 and 4. Nothing about that is a bug in accumulation. The layer normalized over 16 rows because 16 rows is what arrived.

There is a distributed consequence too, and its mechanism is chapter eight's. Bucketed all-reduce fires as soon as a bucket's gradients are ready, so a plain accumulation loop under DDP pays a reduction per micro-batch rather than one per optimizer step. DistributedDataParallel.no_sync is the context manager that suppresses it for every micro-batch but the last.

run it (verified, torch 2.2.2 CPU): the same 64 rows, one batch against four
import torch
from torch import nn

torch.manual_seed(1)
x = torch.randn(64, 8)

one = nn.BatchNorm1d(8)
one(x)                                        # a single batch of 64

four = nn.BatchNorm1d(8)
for i in range(4):
    four(x[i * 16 : (i + 1) * 16])            # the same 64 rows, four at a time

print((one.running_mean - four.running_mean).abs().max().item())
# 0.048792045563459396
print(int(one.num_batches_tracked), int(four.num_batches_tracked))   # 1 4
§ 04

One norm over every parameter

clip_grad_norm_ does not clip tensors. It concatenates every gradient you hand it into one conceptual vector, takes that vector's norm, and if the norm exceeds your threshold it multiplies every gradient by the same scalar. Direction is preserved exactly; only magnitude moves.

Two details of the interface are easy to miss. The value it returns is the norm before clipping, not after, which makes it the cheapest gradient-norm logger available and a confusing one if you assume otherwise. And it returns that value whether or not it clipped anything.

The per-tensor norms afterward are the tell that this is a global operation. Clip a model whose total norm is 206.35 down to 1.0 and the four parameters come back at 0.380, 0.087, 0.921 and 0.010: nowhere near 1.0 individually, exactly 1.0 together.

run it (verified, torch 2.2.2 CPU): the returned value is the norm before the clip
import torch
from torch import nn


def fresh():
    torch.manual_seed(0)
    return nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 1))


def norm(m):
    return torch.cat([p.grad.flatten() for p in m.parameters()]).norm().item()


torch.manual_seed(1)
x = torch.randn(64, 8)
y = x.sum(dim=1, keepdim=True) * 50.0            # far enough away to make big grads

m = fresh()
nn.functional.mse_loss(m(x), y).backward()
print(norm(m))                                                        # 206.35205078125
print(nn.utils.clip_grad_norm_(m.parameters(), max_norm=1.0).item())  # 206.35205078125
print(norm(m))                                                        # 1.0
print([round(p.grad.norm().item(), 6) for p in m.parameters()])
# [0.380417, 0.086949, 0.920664, 0.010048]
§ 05

The clip goes after the last micro-batch

Put those two sections together and the placement question answers itself. Clipping rescales a vector, and the vector you meant to clip is the accumulated gradient. Clipping each partial sum rescales four different vectors, and their sum is not the clipped total.

The measurement makes the trap visible. Clip every micro-batch and the final norm reads 1.0. Clip once at the end and it also reads 1.0. The two gradient sets differ by 0.245 in their largest element, so the norm you were watching cannot tell you which loop you wrote.

Mixed precision adds one more ordering constraint on top. Chapter nine covers the scaler itself; what it imposes here is that gradients have to be unscaled before they are clipped, because a norm measured on scaled gradients is not the quantity your threshold was chosen against.

continuing the block above (verified, torch 2.2.2 CPU): same threshold, same final norm, different gradients
def accumulate(clip_each):
    m = fresh()
    for i in range(4):
        s = slice(i * 16, (i + 1) * 16)
        (nn.functional.mse_loss(m(x[s]), y[s]) / 4).backward()
        if clip_each:
            nn.utils.clip_grad_norm_(m.parameters(), max_norm=1.0)
    if not clip_each:
        nn.utils.clip_grad_norm_(m.parameters(), max_norm=1.0)
    return [p.grad.clone() for p in m.parameters()]


per_micro = accumulate(True)
once = accumulate(False)
print(torch.cat([g.flatten() for g in per_micro]).norm().item())   # 1.0
print(torch.cat([g.flatten() for g in once]).norm().item())        # 1.0
print(max((a - b).abs().max().item() for a, b in zip(per_micro, once)))
# 0.24483658373355865
§ 06

An infinity does not stop it

One inf in one gradient entry, out of 321 in this small model, and the whole clip goes quiet in a specific way. The total norm comes back inf, the coefficient is computed as max_norm / (total_norm + 1e-6), which is 0.0, and torch.clamp(clip_coef, max=1.0) leaves it there.

Multiplying by zero then produces 320 zeros and one nan, because inf * 0 is nan. Your step runs on a model whose entire gradient is zero except for one poisoned entry, and the only signal is the return value you probably discarded.

error_if_nonfinite=True turns this into a RuntimeError naming the norm order and telling you the flag exists. It defaults to False on torch 2.2.2, and the current reference still documents the default as False, so the loud version is the one you have to ask for.

run it (verified, torch 2.2.2 CPU): one inf entry, and what the other 320 become
import torch
from torch import nn

torch.manual_seed(0)
m = nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 1))
torch.manual_seed(1)
x = torch.randn(64, 8)
nn.functional.mse_loss(m(x), x.sum(dim=1, keepdim=True) * 50.0).backward()

m[0].weight.grad[0, 0] = float("inf")            # one entry out of 321
print(nn.utils.clip_grad_norm_(m.parameters(), max_norm=1.0).item())   # inf

flat = torch.cat([p.grad.flatten() for p in m.parameters()])
print(flat.numel(), int((flat == 0).sum()), int(flat.isnan().sum()))   # 321 320 1
before you move on

Check yourself

01 Four micro-batches of 16 replace one batch of 64. What has to happen to each micro-batch loss?

Each one is divided by four before backward, because a mean loss over 16 rows summed four times is four times the mean over 64. With the divisor the two gradients agree to 2.4e-07 in float32; without it the norms sit at a ratio of exactly 4.0.

02 Your accumulation group splits 64 rows as 24, 24 and 16. What does dividing each loss by three give you?

A gradient weighted per micro-batch rather than per sample, off the full-batch gradient by 0.202 in its largest element. Weighting each loss by its own sample count over the total, 24/64 and 24/64 and 16/64, brings it back to 2.4e-07.

03 Clipping ran on every micro-batch and the final norm still reads exactly 1.0. What is wrong?

The direction of the gradient. Clipping rescales one vector, so rescaling four partial sums separately gives a total that is not the clipped full-batch gradient. On this run the two disagree by 0.245 in the largest element while both read norm 1.0.

assigned

Readings