Skip to main content

Data Parallelism with PyTorch Lightning

This page explains how to distribute an artificial neural network model implemented in PyTorch Lightning code using the data parallelism method.

A practical example is provided as a Notebook at the bottom of the page to give you access to a functional implementation of the explanations below.

important

Before going further, it is essential to have a basic understanding of PyTorch Lightning usage. The PyTorch Lightning documentation is very comprehensive and provides many examples.

Multi-Process Configuration with Slurm

For multi-node configurations, it is necessary to use the multi-processing managed by Slurm (execution via the Slurm command srun). For single-node configurations, it is possible to use, as indicated in the PyTorch documentation, torch.multiprocessing.spawn. However, it is possible and more practical to use Slurm multi-processing in all cases, whether single-node or multi-node. This is what we document on this page.

In Slurm, when a script is launched with the srun command, it is automatically distributed across all predefined tasks. For example, if we reserve 4 nodes, requesting 3 GPUs per node, we obtain:

  • 4 nodes, indexed from 0 to 3
  • 3 GPUs/node indexed from 0 to 2 on each node
  • 4 x 3 = 12 processes in total, enabling the execution of 12 tasks with ranks indexed from 0 to 11

Multi-process in Slurm
Illustration of a Slurm reservation of 4 nodes and 3 GPUs per node, i.e., 12 processes.
Inter-node collective communications are managed by the NCCL library.

Here are two example Slurm scripts for Jean-Zay:

  • For a reservation of N quadri-GPU V100 nodes via the default GPU partition:
#!/bin/bash
#SBATCH --job-name=torch-multi-gpu
#SBATCH --nodes=N # total number of nodes (N to be defined)
#SBATCH --ntasks-per-node=4 # number of tasks per node (here 4 tasks, i.e., 1 task per GPU)
#SBATCH --gres=gpu:4 # number of GPUs reserved per node (here 4, i.e., all GPUs)
#SBATCH --cpus-per-task=10 # number of cores per task (thus 4x10 = 40 cores, i.e., all cores)
#SBATCH --hint=nomultithread
#SBATCH --time=20:00:00
#SBATCH --output=torch-multi-gpu%j.out
##SBATCH --account=abc@v100

module load pytorch-gpu/py3/2.5.0

srun python myscript.py
  • For a reservation of N octo-GPU A100 nodes:
#!/bin/bash
#SBATCH --job-name=torch-multi-gpu
#SBATCH --nodes=N # total number of nodes (N to be defined)
#SBATCH --ntasks-per-node=8 # number of tasks per node (here 8 tasks, i.e., 1 task per GPU)
#SBATCH --gres=gpu:8 # number of GPUs reserved per node (here 8, i.e., all GPUs)
#SBATCH --cpus-per-task=8 # number of cores per task (thus 8x8 = 64 cores, i.e., all cores)
#SBATCH --hint=nomultithread
#SBATCH --time=20:00:00
#SBATCH --output=torch-multi-gpu%j.out
#SBATCH -C a100
##SBATCH --account=abc@a100

module load arch/a100
module load pytorch-gpu/py3/2.5.0

srun python myscript.py
Note

In both examples, the nodes are reserved exclusively. In particular, this gives us access to all the memory on each node.

PyTorch Lightning Configuration and Distribution Strategy

The multi-GPU training documentation allows you to discover most of the possible configurations and strategies (dp, ddp, ddp_spawn, ddp2, horovod, deepspeed, fairscale, etc.).

On Jean Zay, we recommend using the ddp strategy as it has the fewest restrictions in PyTorch Lightning. It is the same as PyTorch's DistributedDataParallel but through the Lightning wrapper.

Note

Lightning selects the NCCL backend by default. This is the most efficient on Jean Zay, but GLOO and MPI can also be used.

This strategy is managed in the Trainer arguments. Here is an example implementation that directly retrieves Slurm environment variables:

trainer_args = {'accelerator': 'gpu',
'devices': int(os.environ['SLURM_GPUS_ON_NODE']),
'num_nodes': int(os.environ['SLURM_NNODES']),
'strategy': 'ddp'}
trainer = pl.Trainer(**trainer_args)
trainer.fit(model)
note

The few performance tests conducted on Jean Zay show that the Lightning wrapper, in the best case, results in a time performance loss of around 5% compared to PyTorch's DistributedDataParallel. This order of magnitude is confirmed in Lightning's reference tests. Lightning aims to be a high-level interface, so it is normal to trade some performance for ease of implementation.

Saving and Loading Checkpoints

TMPDIR Redirection

When using a recent version of Lightning, it is necessary to add

export TMPDIR=$JOBSCRATCH

at the beginning of your submission script to avoid a quota error on the default temporary directory /tmp, which is very small on Jean Zay compute nodes.

Saving

By default, Lightning automatically saves checkpoints.

In multi-GPU mode, Lightning ensures that the save is only performed by the main process. No specific code needs to be added.

trainer = Trainer(strategy="ddp")
model = MyLightningModule(hparams)
trainer.fit(model)
# Saves only on the main process
trainer.save_checkpoint("example.ckpt")
Note

To avoid save conflicts, Lightning recommends always using the save_checkpoint() function. If you use a custom function, remember to decorate it with rank_zero_only().

from pytorch_lightning.utilities import rank_zero_only

@rank_zero_only
def homemade_save_checkpoint(path):
...

homemade_save_checkpoint("example.ckpt")

Loading

At the start of training, loading a checkpoint is first performed by the CPU, then the information is sent to the GPU. Again, no specific code needs to be added; Lightning handles everything.

# Import model weights for new training
model = LitModel.load_from_checkpoint(PATH)

# Or restore previous training (model, epoch, step, LR schedulers, apex, etc...)
model = LitModel()
trainer = Trainer()
trainer.fit(model, ckpt_path="some/path/to/my_checkpoint.ckpt")

Distributed Validation

The validation step, executed after each epoch or after a fixed number of training iterations, can be distributed across all GPUs involved in model training. Validation is, by default, distributed in Lightning as soon as you switch to multi-GPU. When data parallelism is used and the validation dataset is substantial, this distributed validation solution across GPUs appears to be the most efficient and fastest.

Here, the challenge is to compute metrics (loss, accuracy, etc.) per batch and per GPU, then weight and average them across the entire validation dataset.

If you use native metrics or those based on torchmetrics, nothing needs to be done.

class MyModel(LightningModule):
def __init__(self):
...
self.accuracy = torchmetrics.Accuracy()

def training_step(self, batch, batch_idx):
x, y = batch
preds = self(x)
...
# log step metric
self.accuracy(preds, y)
self.log("train_acc_step", self.accuracy, on_epoch=True)
...

Otherwise, you need to perform synchronisation during logging:

def validation_step(self, batch, batch_idx):
x, y = batch
logits = self(x)
loss = self.loss(logits, y)
# Add sync_dist=True to sync logging across all GPU workers (may have performance impact)
self.log("validation_loss", loss, on_step=True, on_epoch=True, sync_dist=True)
def test_step(self, batch, batch_idx):
x, y = batch
tensors = self(x)
return tensors

def test_epoch_end(self, outputs):
mean = torch.mean(self.all_gather(outputs))

# When logging only on rank 0, don't forget to add
# ``rank_zero_only=True`` to avoid deadlocks on synchronization.
if self.trainer.is_global_zero:
self.log("my_reduced_metric", mean, rank_zero_only=True)

Application Example

Multi-GPU, Multi-Node Execution with "ddp" in PyTorch Lightning

An example can be found in $DSDIR/examples_IA/Torch_parallel/Example_DataParallelism_PyTorch_Lightning-eng.ipynb on Jean-Zay; it uses the CIFAR database and a ResNet50. The example is a Notebook that allows you to create an execution script. It should be copied to your personal space (ideally in your $WORK).

cp $DSDIR/examples_IA/Torch_parallel/Example_DataParallelism_PyTorch_Lightning-eng.ipynb $WORK

You can then run the Notebook from a Jean Zay frontend machine by selecting a PyTorch kernel (see our documentation on accessing JupyterHub for more information on using Notebooks on Jean Zay).

Documentation and Sources