Training on several GPUs at once
Read these first if they are new to you
- Minibatches: a few examples per step Intermediate
- PyTorch tensors on the GPU Intermediate
The idea in one paragraph
The usual way to train on several GPUs is called data parallelism. Every GPU holds a full copy of the model. Each minibatch is split into equal shares, one per GPU, and each GPU computes the gradient on its own share. The GPUs then average their gradients, so every GPU has the same average. Each applies the same step with it, so the copies stay identical. The result is the same as one GPU training on the whole batch, only faster.
Split the batch, average the gradients, take the same step
Each GPU is driven by its own copy of the training program, a separate process. The processes are numbered 0, 1, 2, …; the number is called the rank. Each process's data loader reads a different share of the examples, so no example is used twice in an epoch. The averaging step is called an all-reduce: every GPU sends in its gradient, and every GPU gets back the result. Only one process, usually rank 0, writes checkpoints and logs, since all copies are the same.
A batch of 256 examples is split over 4 GPUs, 64 each. One weight is w = 2.0, and the learning rate is η = 0.1.
- The four GPUs compute the gradient for w on their own shares: 0.8, 1.2, 0.6 and 1.4.
- The all-reduce averages them: (0.8 + 1.2 + 0.6 + 1.4) ÷ 4 = 4.0 ÷ 4 = 1.0.
- Every GPU updates its copy: w = 2.0 − 0.1 × 1.0 = 1.9. All four copies now hold 1.9.
Why is the average right? The loss of the batch is the mean over its 256 examples. With four equal shares, that mean is the average of the four share means, and the same holds for the gradients. So the average gradient is the gradient one GPU would get from all 256 examples.
Your turn. Two GPUs compute gradients 0.5 and −0.1 for a weight of 1.0, with η = 0.5. What is the weight after the step? (Answer: the average is 0.2, so 1.0 − 0.5 × 0.2 = 0.9.)
The averaged gradient matches the whole batch
This snippet checks the claim on the CPU. It computes one gradient on a batch of 256 examples, then four gradients on shares of 64 using four copies of the model, and averages them.
same_gradient.py
import copy
import torch
torch.manual_seed(0) # same random draws on every run
model = torch.nn.Linear(4, 1) # a small model: 4 inputs, 1 output
x, y = torch.randn(256, 4), torch.randn(256, 1) # one batch of 256 examples
def grad(m, xb, yb): # gradient of the mean loss
m.zero_grad() # clear the old gradient
((m(xb) - yb) ** 2).mean().backward()
return m.weight.grad.clone() # a copy of the gradient
whole = grad(model, x, y) # one GPU, the whole batch
copies = [copy.deepcopy(model) for _ in range(4)] # four "GPUs", one copy each
# x.chunk(4) cuts x into 4 shares of 64 rows, one for each copy.
shares = [grad(m, xs, ys) for m, xs, ys in zip(copies, x.chunk(4), y.chunk(4))]
average = torch.stack(shares).mean(0) # the averaging step
print("share 0:", shares[0])
print("average:", average)
print("whole: ", whole)
print("largest difference:", (average - whole).abs().max().item())
share 0: tensor([[-0.2048, -0.1499, -0.9062, -1.0957]])
average: tensor([[ 0.0028, 0.6184, -0.9061, -0.7804]])
whole: tensor([[ 0.0028, 0.6184, -0.9061, -0.7804]])
largest difference: 3.5762786865234375e-07
One share alone gives a quite different gradient. The average of the four matches the whole batch. The tiny difference, about 4 × 10−7, comes from adding the same numbers in a different order with float32 rounding (see mixed precision for how floats round).
Each extra GPU helps less, because the averaging time does not shrink
With N GPUs, each GPU computes on 1/N of the batch, so computing takes 1/N as long. The all-reduce adds time that does not shrink, because every GPU must send and receive a full set of gradients. Suppose one GPU takes 400 ms for a batch, and the all-reduce takes 25 ms. (These are made-up round numbers.)
Try it. Predict the speed-up with 4 GPUs. Then move the slider to find the number of GPUs at which the GPUs spend as much time averaging as computing.
Real libraries start averaging some gradients while the backward pass is still computing others, which hides part of the averaging time. The all-reduce time also grows when the GPUs sit in different machines. Layers that compute statistics over the batch, such as batch normalization, see only their own GPU's share. Their results then differ from one-GPU training, unless the library synchronizes those statistics across GPUs. In practice, each GPU often keeps its own share size, so the whole batch grows with the number of GPUs, and the learning rate may need to change with it. Models too big for one GPU need a different method, which splits the model itself across GPUs.
Check yourself
-
Eight GPUs train with a batch of 512 examples. How many examples does each GPU see per step?
Answer
512 ÷ 8 = 64.
-
Why not let each GPU take a step with its own gradient and skip the averaging?
Answer
Each copy would move in a different direction, and the copies would drift apart into four different models. Averaging first keeps one model, trained on all the examples of the batch.
-
Two GPUs get shares of 96 and 32 examples, with gradients 1.0 and 3.0. A plain average gives 2.0. What is the gradient of the whole batch of 128?
Answer
Weight each share by its size: (96 × 1.0 + 32 × 3.0) ÷ 128 = 192 ÷ 128 = 1.5. The plain average is only right when the shares are equal, which is why the batch is split evenly.
Go further
Optional background, for readers who want more depth.
-
The second part of PyTorch's video series: the same picture as above, in PyTorch's own terms.
-
Places data parallelism among the other ways to split training, for when a model no longer fits on one GPU.
Where this shows up
No unit or explanation builds on this one yet.