Skip to content

Distributed Training: DataParallel to DistributedDataParallel

Overview

Training large models requires distributing computation across GPUs/TPUs: - DataParallel: Simple, single-machine multi-GPU - DistributedDataParallel (DDP): Multi-machine, production-grade - Gradient accumulation: Simulating large batches - Synchronization: Ensuring all processes agree

Understanding these patterns is essential for scaling ML systems.


Single-GPU Training Baseline

Standard Training Loop

import torch
import torch.nn as nn
import torch.optim as optim

# Model and optimizer
model = nn.Linear(10, 5)
optimizer = optim.SGD(model.parameters(), lr=0.01)
criterion = nn.MSELoss()

# Single GPU training
for epoch in range(10):
    for batch_x, batch_y in train_loader:
        # Forward
        output = model(batch_x)
        loss = criterion(output, batch_y)

        # Backward
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

# Characteristics:
# - Model on single GPU
# - Batch processed by single GPU
# - Simple, but limited by single GPU memory

DataParallel: Simple Multi-GPU

How It Works

import torch
import torch.nn as nn

# Single GPU model
model = nn.Linear(10, 5)

# Wrap with DataParallel
device_ids = [0, 1, 2, 3]  # GPUs 0, 1, 2, 3
model = nn.DataParallel(model, device_ids=device_ids)

# Model automatically splits batch across GPUs
batch_size = 32  # Total batch
# GPU 0: 8 samples
# GPU 1: 8 samples
# GPU 2: 8 samples
# GPU 3: 8 samples

# Forward/backward works same as single GPU
for epoch in range(10):
    for batch_x, batch_y in train_loader:
        output = model(batch_x)  # Auto-splits batch
        loss = criterion(output, batch_y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

DataParallel Mechanics

# Simplified DataParallel forward pass:

def forward(self, input):
    # Scatter input across GPUs
    inputs = scatter(input, self.device_ids)  # Split batch
    # inputs = [batch[0:8].to(gpu0), batch[8:16].to(gpu1), ...]

    # Run replicas in parallel
    outputs = []
    for i, (input, device) in enumerate(zip(inputs, self.device_ids)):
        replica = self.module.to(device)
        output = replica(input)
        outputs.append(output)

    # Gather outputs back to device 0
    output = gather(outputs, self.device_ids[0])
    return output

Limitations of DataParallel

# DataParallel issues:

# 1. Bottleneck: Master GPU (0) does gathering/scattering
#    Other GPUs wait for master to finish

# 2. Single-process: Only one Python process
#    Can't utilize multi-core CPU for data loading

# 3. Slower than DistributedDataParallel for large models

# Example: With 4 GPUs
model = nn.DataParallel(model, device_ids=[0, 1, 2, 3])

# GPU 0: Master (gathers gradients)
# GPU 1, 2, 3: Compute (wait for master)
# CPU: Single process (bottleneck for data loading)

# → Not recommended for production training

DistributedDataParallel: Production-Grade

Setup

import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel as DDP

def setup_ddp(rank, world_size):
    """Initialize distributed training."""
    os.environ['MASTER_ADDR'] = 'localhost'
    os.environ['MASTER_PORT'] = '12355'

    # Initialize process group
    dist.init_process_group(
        backend='nccl',  # NVIDIA collective communication library
        rank=rank,
        world_size=world_size,
    )

def cleanup_ddp():
    """Clean up distributed training."""
    dist.destroy_process_group()

def train_ddp(rank, world_size, model_class, train_loader):
    """Training function for single process."""
    # Setup
    setup_ddp(rank, world_size)

    # Create model and wrap with DDP
    model = model_class()
    model.to(rank)  # Move to current GPU
    model = DDP(model, device_ids=[rank])

    optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

    # Training loop
    for epoch in range(10):
        # Important: shuffle differently on each rank
        train_loader.sampler.set_epoch(epoch)

        for batch_x, batch_y in train_loader:
            batch_x = batch_x.to(rank)
            batch_y = batch_y.to(rank)

            # Forward
            output = model(batch_x)
            loss = criterion(output, batch_y)

            # Backward
            optimizer.zero_grad()
            loss.backward()

            # DDP automatically synchronizes gradients
            optimizer.step()

    cleanup_ddp()

# Launch training on 4 GPUs
if __name__ == '__main__':
    world_size = 4
    mp.spawn(train_ddp, args=(world_size, MyModel, train_loader), nprocs=world_size)

DataLoader Setup for DDP

import torch
from torch.utils.data import DataLoader, DistributedSampler

def create_ddp_dataloaders(train_dataset, val_dataset, batch_size, num_workers):
    """Create dataloaders for DDP."""

    # Sampler handles splitting data across ranks
    train_sampler = DistributedSampler(
        train_dataset,
        num_replicas=torch.distributed.get_world_size(),
        rank=torch.distributed.get_rank(),
        shuffle=True,
        drop_last=True,  # Important: same batch size on all ranks
    )

    train_loader = DataLoader(
        train_dataset,
        batch_size=batch_size,
        sampler=train_sampler,
        num_workers=num_workers,
        pin_memory=True,  # Faster GPU transfer
    )

    # Validation: typically single-process or synchronized
    val_loader = DataLoader(
        val_dataset,
        batch_size=batch_size,
        shuffle=False,
        num_workers=num_workers,
    )

    return train_loader, val_loader

Gradient Synchronization

How DDP Synchronizes Gradients

import torch
from torch.nn.parallel import DistributedDataParallel as DDP

# DDP automatically:
# 1. After backward(), collects gradients from all processes
# 2. Performs all_reduce() to average gradients
# 3. Each process gets same averaged gradients
# 4. All processes update parameters identically

# Example with 2 processes:

# Process 0 has gradients: [2.0, 3.0, 1.0]
# Process 1 has gradients: [4.0, 1.0, 5.0]

# After DDP all_reduce (average):
# Process 0: [3.0, 2.0, 3.0]  (sum / 2)
# Process 1: [3.0, 2.0, 3.0]  (sum / 2)

# Both processes update parameters identically

Manual Synchronization

import torch.distributed as dist

# Get own gradient
local_grad = model[0].weight.grad

# Create tensor to hold reduced gradient
reduced_grad = local_grad.clone()

# All-reduce: sum across all processes
dist.all_reduce(reduced_grad, op=dist.ReduceOp.SUM)

# Average
reduced_grad /= torch.distributed.get_world_size()

# Update model
model[0].weight.grad = reduced_grad

Communication Patterns

All-Reduce (Most Common)

# Each process contributes, result goes to all processes
# 4 processes, each has local value:

# Process 0: [1, 2, 3]
# Process 1: [4, 5, 6]
# Process 2: [7, 8, 9]
# Process 3: [10, 11, 12]

# After all_reduce (SUM):
# Process 0: [22, 26, 30]  (sum of all)
# Process 1: [22, 26, 30]
# Process 2: [22, 26, 30]
# Process 3: [22, 26, 30]

dist.all_reduce(tensor, op=dist.ReduceOp.SUM)

Reduce (One Process Receives)

# Only process 0 gets result
dist.reduce(tensor, dst=0, op=dist.ReduceOp.SUM)

# Used for collecting validation metrics from all processes

Broadcast (One Process Sends)

# Process 0 sends to all
dist.broadcast(tensor, src=0)

# Used for distributing model updates or hyperparameters

Gather/Scatter

# Gather: collect from all processes
if rank == 0:
    gathered_list = [torch.zeros_like(tensor) for _ in range(world_size)]
else:
    gathered_list = None

dist.gather(tensor, gather_list=gathered_list, dst=0)

# Scatter: distribute to all processes
dist.scatter(tensor, scatter_list=scatter_list, src=0)

Advanced: Gradient Accumulation with DDP

Effective Batch Size > Physical Batch Size

import torch
import torch.nn as nn

# Training with gradient accumulation
model = nn.Linear(10, 5)
model = DDP(model)

optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
criterion = nn.MSELoss()

# Simulate batch size 128 with gradient accumulation
effective_batch_size = 128
actual_batch_size = 32
accumulation_steps = effective_batch_size // actual_batch_size

for epoch in range(10):
    train_loader.sampler.set_epoch(epoch)

    for batch_idx, (batch_x, batch_y) in enumerate(train_loader):
        # Forward
        output = model(batch_x)

        # Loss scaled by accumulation steps
        loss = criterion(output, batch_y) / accumulation_steps

        # Backward: gradients accumulate
        loss.backward()

        # Update after K steps
        if (batch_idx + 1) % accumulation_steps == 0:
            optimizer.step()
            optimizer.zero_grad()

Mixed Precision with DDP

from torch.cuda.amp import autocast, GradScaler

model = nn.Linear(10, 5)
model = DDP(model)

optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
scaler = GradScaler()  # Scales loss to prevent underflow

for epoch in range(10):
    for batch_x, batch_y in train_loader:
        # Forward in float16
        with autocast(device_type='cuda'):
            output = model(batch_x)
            loss = criterion(output, batch_y)

        # Backward with loss scaling
        scaler.scale(loss).backward()

        # Unscale before update
        scaler.unscale_(optimizer)

        # Gradient clipping (optional)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        # Update
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad()

Multi-Node Training

Launch Across Multiple Machines

# Machine 1 (Master)
# python train.py --rank 0 --world_size 8 --master_addr 192.168.1.100

# Machine 2
# python train.py --rank 4 --world_size 8 --master_addr 192.168.1.100

import argparse
import os

def setup_ddp_multinode(rank, world_size, master_addr, master_port):
    """Setup multi-node DDP."""
    os.environ['MASTER_ADDR'] = master_addr
    os.environ['MASTER_PORT'] = str(master_port)

    dist.init_process_group(
        backend='nccl',
        rank=rank,
        world_size=world_size,
        timeout=timedelta(minutes=30),
    )

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--rank', type=int)
    parser.add_argument('--world_size', type=int)
    parser.add_argument('--master_addr', type=str)
    parser.add_argument('--master_port', type=int, default=29500)
    args = parser.parse_args()

    setup_ddp_multinode(args.rank, args.world_size, 
                        args.master_addr, args.master_port)

    # Training code
    model = nn.Linear(10, 5).cuda(args.rank)
    model = DDP(model, device_ids=[args.rank])

    # ... training loop ...

Practical Example: Complete DDP Training

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset, DistributedSampler
from torch.nn.parallel import DistributedDataParallel as DDP
import torch.distributed as dist
import torch.multiprocessing as mp

class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(10, 20)
        self.fc2 = nn.Linear(20, 5)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        return self.fc2(x)

def train(rank, world_size):
    # Setup
    os.environ['MASTER_ADDR'] = 'localhost'
    os.environ['MASTER_PORT'] = '12355'
    dist.init_process_group("nccl", rank=rank, world_size=world_size)

    # Create model
    model = SimpleModel()
    model.to(rank)
    model = DDP(model, device_ids=[rank])

    # Data
    dataset = TensorDataset(
        torch.randn(1000, 10),
        torch.randint(0, 5, (1000,))
    )
    sampler = DistributedSampler(dataset, rank=rank, world_size=world_size)
    loader = DataLoader(dataset, batch_size=32, sampler=sampler)

    # Optimizer
    optimizer = optim.SGD(model.parameters(), lr=0.01)
    criterion = nn.CrossEntropyLoss()

    # Training
    for epoch in range(10):
        sampler.set_epoch(epoch)

        for batch_x, batch_y in loader:
            batch_x, batch_y = batch_x.to(rank), batch_y.to(rank)

            output = model(batch_x)
            loss = criterion(output, batch_y)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

        if rank == 0:
            print(f"Epoch {epoch}: loss={loss.item():.4f}")

    dist.destroy_process_group()

if __name__ == '__main__':
    world_size = 4
    mp.spawn(train, args=(world_size,), nprocs=world_size)

Synchronization Issues

Common Pitfall: Rank 0 Behaves Differently

# WRONG: Only rank 0 updates learning rate
if rank == 0:
    adjust_learning_rate(optimizer, epoch)

# At next step, rank 0 has different lr than others
# Divergence in training!

# CORRECT: All ranks adjust identically
# (or broadcast from rank 0)

adjust_learning_rate(optimizer, epoch)

Avoiding Deadlocks

# WRONG: Different code paths on different ranks
if rank == 0:
    # Long validation
    validate(model, val_loader)

# Rank 1, 2, 3 reach barrier and wait forever!

# CORRECT: All ranks follow same code path
validate(model, val_loader)

# Or synchronize explicitly
dist.barrier()

Debugging DDP

Check Process Group

if dist.is_available() and dist.is_initialized():
    print(f"Rank: {dist.get_rank()}")
    print(f"World size: {dist.get_world_size()}")
    print(f"Backend: {dist.get_backend()}")

Hang Detection

# Set timeout to detect hangs
dist.init_process_group(
    backend='nccl',
    timeout=timedelta(minutes=30),
)

# Hangs will raise exception after 30 minutes

Gradient Verification

# Check gradients match across processes
if dist.is_initialized():
    all_grads = []
    for param in model.parameters():
        local_grad = param.grad.clone()
        gathered = [torch.zeros_like(local_grad) for _ in range(world_size)]
        dist.all_gather(gathered, local_grad)
        all_grads.append(gathered)

    # Verify all ranks have identical gradients
    for rank in range(1, world_size):
        for i, grads in enumerate(all_grads):
            assert torch.allclose(grads[0], grads[rank])

Summary: DDP vs DataParallel

Feature DataParallel DDP
GPUs Single machine Multiple machines
Processes Single Multiple
Scalability Poor Excellent
Communication No explicit sync All-reduce
Complexity Simple Moderate
Production Not recommended Standard
Speed Slower Fastest