Distributed Training#

Scaling a training run across multiple GPUs or nodes in ALCHEMI comes down to one object: DistributedManager. It gathers the distributed runtime state a job needs — process rank, local rank, world size, device selection, process groups, and DistributedDataParallel defaults — behind a single handle, re-exported from PhysicsNeMo as nvalchemi.distributed.DistributedManager.

Routing that state through one object buys a shared context: passing a manager to TrainingStrategy gives every ALCHEMI hook the same view of the distributed runtime, so no hook has to read environment variables or initialize communication on its own. The same script then runs unchanged whether launched on one process or many. Advanced workflows can still drive torch.distributed directly, but the manager is the recommended entry point.

Basic pattern#

A distributed script differs from a single-process one in only two places: you bring up the runtime, and you hand the strategy a manager alongside a DDPHook. Call DistributedManager.initialize() once to start the distributed runtime, construct a manager from it, and pass that instance to the strategy. From there DDPHook does the wiring during setup: it selects the rank-local device, wraps the optimized models in torch.nn.parallel.DistributedDataParallel, and installs a distributed sampler for supported dataloaders.

from nvalchemi.distributed import DistributedManager
from nvalchemi.training import TrainingStrategy
from nvalchemi.training.hooks import DDPHook

DistributedManager.initialize()
manager = DistributedManager()

strategy = TrainingStrategy(
    ...,
    distributed_manager=manager,
    hooks=[
        DDPHook(),
    ],
)

strategy.run(train_loader)

Launch the script with the process launcher for your environment. For a simple single-node PyTorch launch:

$ torchrun --nproc_per_node=4 train.py

Note

DistributedManager.initialize() also supports single-process execution. When the world size is one, DDPHook becomes a no-op, so the same script runs unchanged locally or under a distributed launcher.

For a complete single-node dummy training script, see Distributed Training: DDPHook with a Dummy MLP. It can be launched with:

$ uv run --extra cu12 torchrun --standalone --nproc_per_node=2 \
    examples/intermediate/06_ddp_mlp_training.py --backend auto

Data loaders and samplers#

The one part of distributed training that needs care beyond the manager is data loading: each data-parallel rank must see a different slice of the training data, or the ranks would redundantly train on the same samples. How that sharding is arranged depends on the sampler, and the subsections below walk the cases in increasing order of control — the automatic default, a custom distributed sampler, and multi-dataset batch sampling.

Automatic configuration via DDPHook#

For regular nvalchemi data pipes, DDPHook installs the distributed sampler during strategy setup with no extra configuration; what it installs depends on the dataset type. For a single Dataset, the hook wraps a DistributedSampler in a BatchSampler so the loader keeps emitting complete batches. For MultiDataset, it installs MultiDatasetBatchSampler so the per-dataset batch composition and rank sharding are handled together. Either way, the hook infers num_replicas, rank, shuffle, and drop_last from the distributed manager and dataloader, and uses seed=0 unless overridden.

from nvalchemi.data.datapipes import DataLoader, Dataset
from nvalchemi.distributed import DistributedManager
from nvalchemi.training import TrainingStrategy
from nvalchemi.training.hooks import DDPHook

DistributedManager.initialize()
manager = DistributedManager()

dataset = Dataset(reader, device=manager.device)
train_loader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    pin_memory=True,
)

strategy = TrainingStrategy(
    ...,
    distributed_manager=manager,
    hooks=[DDPHook()],
)
strategy.run(train_loader)

This is the preferred starting point for a single dataset, and like the basic pattern it stays single-process friendly: when manager.world_size == 1, DDPHook leaves the loader unchanged.

Use sampler_kwargs to override arguments passed to the default sampler:

DDPHook(
    sampler_kwargs={
        "shuffle": False,
        "seed": 1234,
    },
)

Custom distributed sampler#

When the default sampler does not fit, you can supply your own, and DDPHook gets out of the way accordingly. If a dataloader already has a distributed-aware sampler, the hook preserves it instead of replacing it. A sampler counts as distributed-aware when it satisfies DistributedSamplerProtocol: it exposes num_replicas, rank, and set_epoch(epoch). Native PyTorch DistributedSampler satisfies this protocol.

For a sampler class or factory that accepts PyTorch-style distributed sampler arguments, pass it to DDPHook. The hook supplies num_replicas, rank, shuffle, seed, and drop_last defaults before applying your sampler_kwargs.

DDPHook(
    sampler_cls=MyDistributedSampler,
    sampler_kwargs={
        "seed": 1234,
    },
)

If your sampler uses different constructor names, pass those names explicitly in sampler_kwargs.

DDPHook(
    sampler_cls=MyDistributedSampler,
    sampler_kwargs={
        "replicas": manager.world_size,
        "worker_rank": manager.rank,
    },
)

Multidataset batch sampling#

Training on several datasets at once raises a second question on top of sharding: how batches are composed across the child datasets. When DDPHook sees a nvalchemi DataLoader backed by MultiDataset and no custom sampler class was supplied, it installs MultiDatasetBatchSampler automatically, keeping per-dataset batch composition and distributed sharding in the same sampler.

Pass MultiDatasetBatchSampler options through DDPHook.sampler_kwargs when you need a specific allocation policy, such as balanced batches or a fixed number of samples per child dataset. The hook still supplies num_replicas, rank, shuffle, and drop_last defaults before applying your overrides. As with the single-dataset case, DDPHook preserves an existing distributed-aware batch sampler instead of replacing it, so manual construction remains available for fully custom samplers.

from nvalchemi.data.datapipes import (
    AtomicDataZarrReader,
    DataLoader,
    Dataset,
    MultiDataset,
)
from nvalchemi.distributed import DistributedManager
from nvalchemi.training import TrainingStrategy
from nvalchemi.training.hooks import DDPHook

DistributedManager.initialize()
manager = DistributedManager()

dataset = MultiDataset(
    Dataset(AtomicDataZarrReader("dataset_a.zarr"), device=manager.device),
    Dataset(AtomicDataZarrReader("dataset_b.zarr"), device=manager.device),
)

train_loader = DataLoader(
    dataset,
    batch_size=64,
    prefetch_factor=16,
    pin_memory=True,
)

strategy = TrainingStrategy(
    ...,
    distributed_manager=manager,
    hooks=[
        DDPHook(
            sampler_kwargs={
                "epoch_policy": "max_size",
                "replacement": True,
                "seed": 1234,
            },
        ),
    ],
)
strategy.run(train_loader)

Internally, MultiDatasetBatchSampler first builds the global batch order according to its per-dataset allocation policy, then splits that order across data-parallel ranks. With drop_last=False, it pads the batch order so each rank emits the same number of batches, matching PyTorch DistributedSampler behavior; with drop_last=True, it truncates the uneven tail instead.

Whichever sampler is in play, call set_epoch() yourself, or let TrainingStrategy call it during training, so distributed samplers reshuffle deterministically from epoch to epoch.

API details#

This guide covers the training-facing surface of the manager. For the complete API, including process-group methods and the distributed configuration knobs, see the PhysicsNeMo DistributedManager API.