Concept units

Data loaders: preparing examples in parallel

Intermediate

Read these first if they are new to you

The idea in one paragraph

Before the GPU can train on a minibatch, the computer must prepare it: read the examples from disk, change them (for example, rotate them at random), and stack them into one array. That work runs on the CPU. If the training program does it itself, the GPU sits idle while it waits. A data loader hands the work to several helper processes, called workers. They prepare the next batches while the GPU trains on the current one, so a batch is ready whenever the GPU asks for it.

A data loader turns a list of examples into a stream of batches

A data loader needs three things:

  • A dataset: an object that knows how many examples there are and can prepare example number i on request.
  • An order: usually a new random shuffle each epoch.
  • A batch size: how many examples to stack into each batch. Stacking the examples into one array is called collating.

The snippet below builds all three in PyTorch. The dataset's two methods are the whole contract: __len__ says how many examples there are, and __getitem__ prepares one. The loader calls them for you. (Reading Python skips classes. The Python Tutorial explains the class syntax in A First Look at Classes.)

loader.py

import torch
# Load two names from torch.utils.data, so the code can write them without that prefix.
from torch.utils.data import Dataset, DataLoader

class Squares(Dataset):
    # Ten examples; example i is the pair (i, i*i).
    def __len__(self):
        return 10
    def __getitem__(self, i):              # prepare one example
        # A tensor, PyTorch's array, that holds i and i * i as 32-bit decimal numbers.
        return torch.tensor([i, i * i], dtype=torch.float32)

if __name__ == "__main__":
    # fix the random shuffle, so each run prints the same order
    torch.manual_seed(0)
    loader = DataLoader(Squares(), batch_size=4, shuffle=True, num_workers=2)
    for epoch in range(2):
        for batch in loader:
            # Print the batch's shape, then the i of each example in it as a list
            # of whole numbers.
            print(epoch, tuple(batch.shape), batch[:, 0].int().tolist())
0 (4, 2) [6, 7, 1, 4]
0 (4, 2) [2, 0, 9, 8]
0 (2, 2) [3, 5]
1 (4, 2) [2, 4, 7, 0]
1 (4, 2) [8, 9, 5, 3]
1 (2, 2) [6, 1]

Each epoch visits all ten examples once, in a new order. The last batch is short, because 10 is not a multiple of 4. num_workers=2 starts two worker processes. A process is a separate running program with its own memory, so each worker can run on its own CPU core at the same time as the others. Some systems start each worker as a fresh Python that reads the file again. The if __name__ == "__main__": line keeps such a worker from running the training code too.

Too few workers leave the GPU waiting

Each worker prepares a whole batch, then starts on another one. The training loop takes the batches in order. If the workers together prepare batches more slowly than the GPU uses them, the GPU waits.

Worked example: how many workers?

Preparing one batch takes 300 ms of one CPU core. Training on one batch takes the GPU 100 ms.

  1. No workers. The training program prepares each batch, then trains on it: 300 + 100 = 400 ms per step. The GPU is busy 100 ms out of every 400 ms, or 25% of the time.
  2. One worker. It prepares the next batch while the GPU trains. A batch is ready every 300 ms, so a step takes 300 ms. The GPU is busy 100 / 300 = 33% of the time.
  3. Three workers. Each makes a batch every 300 ms, so together they make one every 300 / 3 = 100 ms. That matches the GPU. It is now busy all the time: 10 steps per second.
  4. Four workers. Still 100 ms per step. The GPU is now the slowest part, so the extra worker only adds batches to the waiting line.

The rule: with k workers, a step takes the longer of the training time and the preparation time divided by k. So you need about (preparation time) ÷ (training time) workers.

Your turn. Preparation takes 450 ms and training 150 ms. How many workers keep the GPU busy? (Answer: 450 ÷ 150 = 3.)

Try it. The timeline below starts with one worker. Predict how many workers you need for the numbers in the worked example. Then move the slider until the blue training blocks touch with no gaps. Then make preparation slower and find the new number.

The first 2 seconds of training: who works when
preparing a batch (CPU) training on a batch (GPU, 100 ms)

GPU busy time

What to notice. Each number is a batch. Gaps in the GPU row are time the GPU spends waiting for data. Once there are enough workers, the GPU row has no gaps, and more workers change nothing.
This is a simplification

The timeline gives every batch the same preparation time and ignores the time to copy a batch to the GPU. Real loaders also limit how far ahead workers may run: PyTorch lets each worker hold two batches by default. The CPU cores and memory set the real limit, because each worker is a separate process with its own copy of the dataset object.

Check yourself

  1. Preparing a batch takes 500 ms and training on it takes 100 ms. You use 2 workers. How long does a step take, and what fraction of the time is the GPU busy?

    Answer

    The two workers make a batch every 500 / 2 = 250 ms. That is longer than 100 ms, so a step takes 250 ms, and the GPU is busy 100 / 250 = 40% of the time. Five workers would keep it busy.

  2. You go from 8 workers to 16, and training gets no faster. What does that tell you?

    Answer

    The data is no longer the slow part. The GPU's own work, or something else such as copying to the GPU, now sets the pace. Extra workers only use more CPU and memory.

  3. Why does the dataset in the snippet prepare one example in __getitem__, rather than one batch?

    Answer

    So that the loader can choose the order and the batch size. It asks for the examples it wants, in shuffled order, and collates them into a batch itself.

Go further

Optional background, for readers who want more depth.

Where this shows up

Units that build on this one