Data Parallelism with PyTorch
This page explains how to distribute an artificial neural network model implemented in PyTorch code using the data parallelism method.
Here, we document the built-in DistributedDataParallel solution, which is the most efficient according to PyTorch documentation. This is a multi-process parallelism that works equally well in single-node and multi-node configurations.
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.
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 from 0 to 11

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
In both examples, the nodes are reserved exclusively. In particular, this gives us access to all the memory on each node.
Implementation of the DistributedDataParallel Solution
To implement the DistributedDataParallel solution in PyTorch, you need to:
-
Define the environment variables related to the master node to configure inter-node communications (required even in single-node) :
MASTER_ADDR, the IP address or hostname of the node corresponding to task 0 (the first in the node list). If you are in a single-node configuration, the valuelocalhostis sufficient.MASTER_PORT, a random port number. To avoid conflicts and by convention, use a port number between10001and20000(for example,12345).
idr_torchOn Jean Zay, a library
idr_torchhas been developed by IDRIS to automatically define theMASTER_ADDRandMASTER_PORTvariables within a running Slurm job. It is available in all our PyTorch environments. To use it, simply import it in your script:import idr_torchOnce
idr_torchis imported, you also have access to the internal variables:idr_torch.rank(global rank of the process),idr_torch.local_rank(local rank of the process within its node),idr_torch.size(number of processes),idr_torch.cpus_per_task(number of CPU cores allocated per process).
infoidr_torchis available on the IDRIS GitHub. -
Initialise the distributed environment (i.e., the communicator) by calling the
init_process_groupfunction:import idr_torchimport torch.distributed as distdist.init_process_group(backend='nccl',init_method='env://',world_size=idr_torch.size,rank=idr_torch.rank)- The backend indicates the library that will be used for communications. The possible backends are
NCCL,GLOO, andMPI.NCCLis the communication library implemented by NVIDIA and is therefore strongly recommended on Jean Zay; init_method='env://'tells PyTorch to look for information about the master node in the environment (environment variablesMASTER_ADDRandMASTER_PORT, see 1.);world_sizecorresponds to the number of GPUs allocated for execution;rankcorresponds to the global rank of the current process.
- The backend indicates the library that will be used for communications. The possible backends are
-
Transfer the model to the memory of the GPU associated with the current process, identified by its local rank (
local_rank):torch.cuda.set_device(idr_torch.local_rank)gpu = torch.device("cuda")model = model.to(gpu) -
Retrieve in a new variable
ddp_modelthe version of the model associated with the current process:from torch.nn.parallel import DistributedDataParallel as DDP[...]ddp_model = DDP(model, device_ids=[idr_torch.local_rank]) -
Use a distributed sampler to split the batches into mini-batches across the different GPUs at each iteration. PyTorch provides the
DistributedSamplerclass for this purpose, which takes as arguments the input dataset, the number of available GPUs, the global rank of the current process, and the shuffling order:train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset,num_replicas=idr_torch.size,rank=idr_torch.rank,shuffle=True)importantThe
DistributedSamplerclass handles shuffling at the beginning of each epoch. To ensure the random seed varies correctly, you must inform the sampler of epoch changes:for epoch in range(n_epochs):train_sampler.set_epoch(epoch) -
Adapt the
DataLoaderto use the distributed sampler and a batch size corresponding to the mini-batch:batch_size_per_gpu = global_batch_size // idr_torch.sizetrain_loader = torch.utils.data.DataLoader(dataset=train_dataset,batch_size=batch_size_per_gpu,shuffle=False, # shuffling delegated to samplersampler=train_sampler)NoteIn this case, the shuffling step is delegated to the distributed sampler.
-
Send the mini-batches and corresponding labels to the GPUs memory:
for (images, labels) in train_loader:images = images.to(gpu)labels = labels.to(gpu)
Saving and Loading Checkpoints
It is possible to implement checkpoints during distributed training on GPUs.
Saving
The model is replicated on each GPU, but saving checkpoints can be performed by a single GPU to limit write operations. By convention, the GPU with rank 0 is used:
if idr_torch.rank == 0:
torch.save(ddp_model.state_dict(), CHECKPOINT_PATH)
Thus, the checkpoint will contain the information from the GPU with rank 0, which is then saved in a format specific to distributed models.
Loading
At the start of training, loading a checkpoint is first performed by the CPU, then the information is sent to the GPU.
By default and by convention, this transfer is done to the memory location that was used during the save step: in our example, only GPU 0 would load the model into memory.
To ensure the information is communicated to all GPUs, you must use the map_location argument of the torch.load loading function to redirect memory storage.
In the example below, the map_location argument orders a redirection of memory storage to the local rank GPU. Since this function is called by all GPUs, each GPU correctly loads the checkpoint into its own memory:
map_location = {'cuda:%d' % 0: 'cuda:%d' % idr_torch.local_rank} # remap storage from GPU 0 to local GPU
ddp_model.load_state_dict(torch.load(CHECKPOINT_PATH), map_location=map_location) # load checkpoint
If, as in the PyTorch tutorial, a checkpoint is loaded immediately after a save, it is necessary to call the dist.barrier() method before loading. Calling dist.barrier() synchronises the GPUs, ensuring that the checkpoint save by GPU rank 0 is completed before the other GPUs attempt to load it.
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. 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.
To do this, you need to:
-
Load the validation data in the same way as the training data, but without random transformations such as data augmentation or shuffling (see the documentation on data loading for distributed training in PyTorch):
# validation dataset loading (ImageNet for example)val_dataset = torchvision.datasets.ImageNet(root=root, split='val', transform=val_transform)# define distributed sampler for validationval_sampler = torch.utils.data.distributed.DistributedSampler(val_dataset,num_replicas=idr_torch.size,rank=idr_torch.rank,shuffle=False)# define dataloader for validationval_loader = torch.utils.data.DataLoader(dataset=val_dataset,batch_size=batch_size_per_gpu,shuffle=False,sampler=val_sampler) -
Switch from "training" mode to "validation" mode to disable certain training-specific features that are costly and unnecessary here:
model.eval()to switch the model to "validation" mode and disable dropout and batchnorm management, etc.with torch.no_grad()to skip gradient computation- optionally,
with autocast()to use AMP (mixed precision)
-
Evaluate the model and compute the metric per batch in the usual way (here, we use the example of loss calculation; the same applies to other metrics):
outputs = model(val_images)followed byloss = criterion(outputs, val_labels)
-
Weight and accumulate the metric per GPU:
val_loss += loss * val_images.size(0) / Nwhereval_images.size(0)is the batch size andNis the total size of the validation dataset. Since batches may not all be the same size (the last batch is sometimes smaller), it is preferable to use the valueval_images.size(0)here.
-
Sum the weighted averages of the metric across all GPUs:
dist.all_reduce(val_loss, op=dist.ReduceOp.SUM)to sum the metric values computed by each GPU and communicate the result to all GPUs. This operation involves inter-GPU communications.
Example after loading validation data
model.eval() # switch into validation mode
val_loss = torch.Tensor([0.]).to(gpu) # initialize val_loss value
N = len(val_dataset) # get validation dataset length
for val_images, val_labels in val_loader: # loop over validation batches
val_images = val_images.to(gpu, non_blocking=True) # transfer images and labels to GPUs
val_labels = val_labels.to(gpu, non_blocking=True)
with torch.no_grad(): # deactivate gradient computation
with autocast(): # activate AMP
outputs = model(val_images) # evaluate model
loss = criterion(outputs, val_labels) # compute loss
val_loss += loss * val_images.size(0) / N # cumulate weighted mean per GPU
dist.all_reduce(val_loss, op=dist.ReduceOp.SUM) # sum weighted means and broadcast value to each GPU
model.train() # switch again into training mode
Application Example
Multi-GPU, Multi-Node Execution with DistributedDataParallel
An example can be found in $DSDIR/examples_IA/Torch_parallel/Example_DataParallelism_Pytorch-eng.ipynb on Jean-Zay; it uses the MNIST database and a simple dense network. 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-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).