Domain Decomposition, ShardTensor, and Data Parallelism#
In this tutorial, we will see how to combine domain parallelism,
ShardTensor, and data parallelism in a training or inference recipe.
Before starting this
tutorial, we recommend that you read the other domain parallelism
tutorials:
This tutorial demonstrates how to train or evaluate a simple ViT with
PhysicsNeMo’s ShardTensor alongside either PyTorch Distributed Data
Parallel (DDP) or Fully Sharded Data Parallel 2 (FSDP2). DDP is appropriate
when model parameters fit on each device and remain plain tensors. FSDP2,
through torch.distributed.fsdp.fully_shard, is available when model
parameters must also be sharded. Here’s what’s in the tutorial:
ViT Model Overview
Benchmarking the ViT on a single GPU
Enabling domain parallelism with
ShardTensorTraining and evaluating the model with domain parallelism
To demonstrate the ability to compile a model and the flexibility of the training
example recipe, we’ll also use a CNN-style model in the benchmarking below.
ShardTensor is model-agnostic and can support many architectures.
Basic ViT Model#
The model we’ll use for this tutorial is a straightforward ViT. It’s very similar to the original vision transformer from “An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale” Dosovitskiy et al.. The model consists of two main conceptual pieces:
a convolutional tokenizer: it is a convolution with stride==kernel_size (so, non-overlapping image pieces) followed by a reshape to a sequence-like tensor with channels last.
a transformer block with residual attention and a residual MLP.
The overall model architecture is straightforward. The input image is tokenized using the convolutional tokenizer, a positional embedding is added, and then a series of transformer blocks are applied. At the end of the transformer layers, all of the tokens are averaged together. The entire architecture has one final layer to project the embedding dimension onto the output dimension.
Note
This isn’t really how you might implement a transformer for a vision classification task in practice - there are better, more sophisticated techniques. Since the original ViT publication, technical advances such as Convolution Transformers, Shifted Windows, Neighborhood Attention, and others have outperformed basic ViTs like this for classification. We encourage you to pick the model architecture most suitable for your task. To demonstrate the domain parallel techniques, we’ve picked a “Standard” vision transformer here.
Here’s the core of the model:
Model Implementation
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.nn as nn
from .PatchEmbed2d import PatchEmbedding2d
from .PatchEmbed3d import PatchEmbedding3d
from .TransformerBlock import TransformerBlock
class HybridViT(nn.Module):
"""
Hybrid Vision Transformer with conv patch embedding and multiple transformer layers.
Args:
img_size: Input image size
patch_size: Size of patches for tokenization
in_channels: Number of input channels
num_classes: Number of classes for classification
embed_dim: Embedding dimension (same for all layers)
num_heads: Number of attention heads for each stage
depth: Number of transformer layers
mlp_ratio: MLP ratios for each layer
qkv_bias: Whether to use bias in QKV projections
"""
def __init__(
self,
img_size: int = [256, 256],
patch_size: int = 8,
in_channels: int = 3,
num_classes: int = 1000,
embed_dim: int = 768,
num_heads: int = 6,
depth: int = 16,
mlp_ratio: float = 4.0,
qkv_bias: bool = True,
) -> None:
super().__init__()
# Use the image size to select the padding:
if len(img_size) == 2:
self.patch_embed = PatchEmbedding2d(
img_size=img_size,
patch_size=patch_size,
in_channels=in_channels,
embed_dim=embed_dim,
)
elif len(img_size) == 3:
self.patch_embed = PatchEmbedding3d(
img_size=img_size,
patch_size=patch_size,
in_channels=in_channels,
embed_dim=embed_dim,
)
# Positional embeddings (for patches + CLS token)
self.pos_embed = nn.Parameter(
torch.zeros(1, self.patch_embed.num_patches, embed_dim)
)
# Build transformer stages (all operating on same resolution)
self.stages = nn.ModuleList(
[
TransformerBlock(
dim=embed_dim,
num_heads=num_heads,
mlp_ratio=mlp_ratio,
qkv_bias=qkv_bias,
)
for _ in range(depth)
]
)
# Classification head
self.head = (
nn.Linear(embed_dim, num_classes) if num_classes > 0 else nn.Identity()
)
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
"""Extract features through all stages.
Args:
x: Input tensor of shape (B, C, H, W)
Returns:
CLS token features of shape (B, embed_dim)
"""
B = x.shape[0]
# Patch embedding
x = self.patch_embed(x) # B, N, C
# Add positional embeddings
x = x + self.pos_embed
# Apply transformer stages
for stage in self.stages:
x = stage(x)
# Return the mean of all tokens
return x.mean(dim=(1,))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Full forward pass for classification.
Args:
x: Input tensor of shape (B, C, H, W)
Returns:
Classification logits of shape (B, num_classes)
"""
x = self.forward_features(x)
x = self.head(x)
return x
For more information on the components, expand the following sections to see the code:
Patch Embedding Implementations
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.nn as nn
from einops import rearrange
class PatchEmbedding2d(nn.Module):
"""Single patch embedding layer that tokenizes and embeds input 2D images."""
def __init__(
self,
img_size: tuple[int],
patch_size: int = 16,
in_channels: int = 3,
embed_dim: int = 768,
) -> None:
super().__init__()
for i in img_size:
assert i % patch_size == 0, (
f"Image size {i} must be divisible by patch size {patch_size}"
)
self.img_size = img_size
self.patch_size = patch_size
self.num_patches = (img_size[0] // patch_size) * (img_size[1] // patch_size)
# Single convolution that acts as both tokenizer and linear embedding
self.conv = nn.Conv2d(
in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
)
self.norm = nn.LayerNorm(embed_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Convert image to patch embeddings.
Args:
x: Input tensor of shape (B, C, H, W)
Returns:
Patch embeddings of shape (B, num_patches, embed_dim)
"""
x = self.conv(x)
# Rearrange to apply LayerNorm correctly: BCHW -> B(HW)C
x = rearrange(x, "b c h w -> b (h w) c")
x = self.norm(x)
# Keep in BHWC format for efficient downstream processing
x = nn.functional.relu(x)
return x
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.nn as nn
from einops import rearrange
class PatchEmbedding3d(nn.Module):
"""Single patch embedding layer that tokenizes and embeds input 3D images."""
def __init__(
self,
img_size: tuple[int],
patch_size: int = 16,
in_channels: int = 3,
embed_dim: int = 768,
) -> None:
super().__init__()
for i in img_size:
assert i % patch_size == 0, (
f"Image size {i} must be divisible by patch size {patch_size}"
)
self.img_size = img_size
self.patch_size = patch_size
self.num_patches = (
(img_size[0] // patch_size)
* (img_size[1] // patch_size)
* (img_size[2] // patch_size)
)
# Single convolution that acts as both tokenizer and linear embedding
self.conv = nn.Conv3d(
in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
)
self.norm = nn.LayerNorm(embed_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Convert image to patch embeddings.
Args:
x: Input tensor of shape (B, C, H, W, D)
Returns:
Patch embeddings of shape (B, num_patches, embed_dim)
"""
x = self.conv(x)
# Rearrange to apply LayerNorm correctly: BCHWD -> B(HWD)C
x = rearrange(x, "b c h w d -> b (h w d) c")
x = self.norm(x)
# Keep in BHWC format for efficient downstream processing
x = nn.functional.relu(x)
return x
Transformer Block
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from torch import nn
from .MultiHeadAttention import MultiHeadAttention
from .MLP import MLP
class TransformerBlock(nn.Module):
"""Standard transformer block with multi-head attention and MLP."""
def __init__(
self,
dim: int,
num_heads: int,
mlp_ratio: float = 4.0,
qkv_bias: bool = False,
norm_layer: nn.Module = nn.LayerNorm,
) -> None:
super().__init__()
self.norm1 = norm_layer(dim)
self.attn = MultiHeadAttention(dim, num_heads=num_heads, qkv_bias=qkv_bias)
self.norm2 = norm_layer(dim)
mlp_hidden_dim = int(dim * mlp_ratio)
self.mlp = MLP(
in_features=dim, hidden_features=mlp_hidden_dim, out_features=dim
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Apply transformer block with residual connections.
Args:
x: Input tensor of shape (B, N, C)
Returns:
Transformed tensor of shape (B, N, C)
"""
# Attention block with residual connection
x = x + self.attn(self.norm1(x))
# MLP block with residual connection
x = x + self.mlp(self.norm2(x))
return x
MLP
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from torch import nn
class MLP(nn.Module):
"""MLP as used in Vision Transformer."""
def __init__(
self, in_features: int, hidden_features: int, out_features: int
) -> None:
super().__init__()
out_features = out_features or in_features
hidden_features = hidden_features or in_features
# Two-layer MLP with activation
self.fc1 = nn.Linear(in_features, hidden_features)
self.act = nn.GELU()
self.fc2 = nn.Linear(hidden_features, out_features)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Apply MLP transformation.
Args:
x: Input tensor of shape (B, N, C)
Returns:
Transformed tensor of shape (B, N, out_features)
"""
x = self.fc1(x)
x = self.act(x)
x = self.fc2(x)
return x
Multi-head Attention
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from torch import nn
class MultiHeadAttention(nn.Module):
"""Standard multi-head attention using PyTorch's scaled_dot_product_attention."""
def __init__(self, dim: int, num_heads: int = 8, qkv_bias: bool = False) -> None:
super().__init__()
assert dim % num_heads == 0
self.num_heads = num_heads
self.head_dim = dim // num_heads
# Combined QKV projection for efficiency
self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
self.proj = nn.Linear(dim, dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Apply multi-head self-attention.
Args:
x: Input tensor of shape (B, N, C)
Returns:
Attention output of shape (B, N, C)
"""
B, N, C = x.shape
# Project to Q, K, V and reshape for multi-head attention
qkv = (
self.qkv(x)
.reshape(B, N, 3, self.num_heads, self.head_dim)
.permute(2, 0, 3, 1, 4)
)
q, k, v = qkv[0], qkv[1], qkv[2] # B, num_heads, N, head_dim
# Use PyTorch's optimized scaled dot product attention
x = nn.functional.scaled_dot_product_attention(
q, k, v, dropout_p=0.0, is_causal=False
)
x = x.transpose(1, 2).reshape(B, N, C)
x = self.proj(x)
return x
Running the ViT#
The training script for this tutorial has no data or labels, only synthetic
data. We loop over image sizes, initialize the ViT model, and then evaluate
its performance (computational performance, not model accuracy)
using a basic loop. We measure both inference and training performance
using torch.cuda.Event objects to capture timing information and
average over a few iterations. Each of those pieces has been packaged
into basic functions so that you can run and reproduce this code:
How to measure model performance
torch.cuda.Event objects to
capture timing information, and average over a few iterations.## SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.nn as nn
from torch.amp import autocast
import contextlib
import numpy as np
def benchmark_model(
model,
x,
target,
optimizer,
num_warmup=5,
num_iterations=10,
use_mixed_precision=False,
inference_only=False,
):
"""Benchmark forward pass and training step performance.
Args:
model: The model to benchmark
x: Input tensor
target: Target tensor for loss computation
optimizer: Optimizer for training step
num_warmup: Number of warmup iterations
num_iterations: Number of benchmark iterations
use_mixed_precision: Whether to use mixed precision training
inference_only: If True, skip training benchmarks entirely
Returns:
Tuple of (forward_time, training_time) in seconds.
training_time is None when inference_only=True.
"""
# Making a flexible context here to enable us to flip mixed precision on/off easily.
if use_mixed_precision:
context = autocast("cuda")
else:
context = contextlib.nullcontext()
# HEADS UP:
# You would use a grad scalar to do stable mixed precision in real training!
# https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.GradScaler
# With only a few iterations of training here, on synthetic data, we won't worry about it.
# Warmup runs
for _ in range(num_warmup):
# Inference only
with torch.no_grad():
with context:
_ = model(x)
# Training warmup step
if not inference_only:
optimizer.zero_grad()
with context:
output = model(x)
loss = nn.CrossEntropyLoss()(output, target)
loss.backward()
optimizer.step()
# Benchmark forward pass
torch.cuda.synchronize()
forward_times = []
for _ in range(num_iterations):
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record()
with torch.no_grad():
with context:
_ = model(x)
end_event.record()
torch.cuda.synchronize()
elapsed_time = (
start_event.elapsed_time(end_event) / 1000.0
) # Convert ms to seconds
forward_times.append(elapsed_time)
avg_forward_time = np.mean(forward_times)
# Benchmark training step (skip if inference-only)
avg_training_time = None
if not inference_only:
torch.cuda.synchronize()
training_times = []
for _ in range(num_iterations):
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record()
optimizer.zero_grad()
with context:
output = model(x)
loss = nn.CrossEntropyLoss()(output, target)
loss.backward()
optimizer.step()
end_event.record()
torch.cuda.synchronize()
elapsed_time = (
start_event.elapsed_time(end_event) / 1000.0
) # Convert ms to seconds
training_times.append(elapsed_time)
avg_training_time = np.mean(training_times)
return avg_forward_time, avg_training_time
Measuring memory usage
torch.cuda.reset_peak_memory_stats() and
torch.cuda.max_memory_allocated() to measure memory usage.## SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.nn as nn
from torch.amp import autocast
import torch.distributed._functional_collectives as funcol
from torch.distributed.tensor import DTensor
import contextlib
def _wait_pending_collectives(tensor):
"""Wait on any in-flight functional collective held by ``tensor``.
ShardTensor issues async functional collectives that are only waited when
their result is first used. The memory probes below discard their
results, so without this the pending work stays registered and PyTorch
prints "unwaited collective calls" warnings at process exit.
"""
if tensor is None:
return
if isinstance(tensor, DTensor):
tensor = tensor.to_local()
if isinstance(tensor, funcol.AsyncCollectiveTensor):
tensor.wait()
def get_model_memory_usage(
model, x, target=None, optimizer=None, mode="inference", use_mixed_precision=False
):
"""Estimate model memory usage for inference or training.
Args:
model: The model to measure
x: Input tensor
target: Target tensor (required for training mode)
optimizer: Optimizer (required for training mode)
mode: 'inference' or 'training'
use_mixed_precision: Whether to use mixed precision
Returns:
Peak memory usage in GB
"""
if use_mixed_precision:
context = autocast("cuda")
else:
context = contextlib.nullcontext()
torch.cuda.reset_peak_memory_stats()
if mode == "inference":
with torch.no_grad():
with context:
output = model(x)
_wait_pending_collectives(output)
elif mode == "training":
if target is None or optimizer is None:
raise ValueError("target and optimizer must be provided for training mode")
optimizer.zero_grad()
with context:
output = model(x)
loss = nn.CrossEntropyLoss()(output, target)
loss.backward()
# This pass measures memory only: the loss is never read and no
# optimizer.step() consumes the gradients, so drain their async
# collectives here.
_wait_pending_collectives(loss)
for param in model.parameters():
_wait_pending_collectives(param.grad)
return torch.cuda.max_memory_allocated() / 1024**3 # GB
End to End Benchmarking
# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import torch.optim as optim
from torch.distributed.tensor import DTensor
from .measure_perf import benchmark_model
from .measure_memory import get_model_memory_usage
def end_to_end_benchmark(args, model, inputs, full_img_size, device, num_classes):
"""Run a full latency and memory benchmark for one model configuration.
Measures forward/training time and peak memory for both inference and
training modes, then tears down the model and frees GPU memory.
Any error raised during benchmarking propagates to the caller with its
full traceback (errors are no longer caught and converted to sentinels).
Args:
args: Parsed CLI arguments (controls warmup iters, precision, etc.).
model: The nn.Module to benchmark.
inputs: Tuple of (input_tensor, target_tensor).
full_img_size: Original image dimensions used to label results.
device: Torch device the model lives on.
num_classes: Number of output classes (unused directly but passed
for consistency with callers that construct the model).
Returns:
Dict with keys: image_size, params, forward_time, training_time,
inference_memory, training_memory, mixed_precision.
"""
x, target = inputs
inference_only = getattr(args, "inference_only", False)
# Count parameters
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
# Create optimizer (only needed for training)
optimizer = None
if not inference_only:
# AdamW's foreach path batches every param of a group into single
# _foreach_* ops, which cannot mix plain tensors with DTensors (or
# DTensors on different meshes). Foreach batching never crosses param
# groups, so split params by tensor type / mesh to keep each group
# homogeneous while retaining the fast foreach path.
param_groups = {}
for p in model.parameters():
key = p.device_mesh if isinstance(p, DTensor) else None
param_groups.setdefault(key, []).append(p)
optimizer = optim.AdamW(
[{"params": params} for params in param_groups.values()],
lr=1e-3,
weight_decay=0.05,
)
# Benchmark model
forward_time, training_time = benchmark_model(
model,
x,
target,
optimizer,
num_warmup=args.num_warmup,
num_iterations=args.num_iterations,
use_mixed_precision=args.use_mixed_precision,
inference_only=inference_only,
)
# Memory usage - always measure inference
inference_memory = get_model_memory_usage(
model, x, mode="inference", use_mixed_precision=args.use_mixed_precision
)
# Only measure training memory if not inference-only
training_memory = None
if not inference_only:
training_memory = get_model_memory_usage(
model,
x,
target,
optimizer,
mode="training",
use_mixed_precision=args.use_mixed_precision,
)
# Store results
results = {
"image_size": full_img_size[0],
"params": num_params,
"forward_time": forward_time,
"training_time": training_time,
"inference_memory": inference_memory,
"training_memory": training_memory,
"mixed_precision": args.use_mixed_precision and torch.cuda.is_available(),
"compile": getattr(args, "compile", False),
}
# Clear cache to free memory
if torch.cuda.is_available():
torch.cuda.empty_cache()
del model
if optimizer is not None:
del optimizer
return results
Users of PyTorch’s DDP are familiar with the techniques of wrapping their
model with a DDP object, rather than making modifications to their
model directly. To minimize the amount of model code modification you must
do, ShardTensor works directly with a plain nn.Module. When an
operation combines a plain parameter with a ShardTensor activation, the
parameter is automatically represented as replicated over the domain mesh.
During the backward pass, ShardTensor reduces the resulting parameter
gradient over that mesh.
The rest of the tutorial walks through the main script to highlight how components of the script change to enable domain parallelism.
Setting Up the Environment#
There are extra imports for DDP, FSDP2, and ShardTensor:
import torch
import torch.nn as nn
import torch
import torch.nn as nn
# Use PhysicsNeMo's distributed manager to simplify initialization
from physicsnemo.distributed import DistributedManager
# Add DDP import
from torch.nn.parallel import DistributedDataParallel as DDP
import torch
import torch.nn as nn
# Use PhysicsNeMo's distributed manager to simplify initialization
from physicsnemo.distributed import DistributedManager
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.distributed.tensor.placement_types import ( # noqa: E402
Replicate,
Shard,
)
# PhysicsNeMo utilities for sharded inputs and model synchronization.
from physicsnemo.domain_parallel import (
scatter_tensor,
sync_module_over_mesh,
)
import torch
import torch.nn as nn
# Use PhysicsNeMo's distributed manager to simplify initialization
from physicsnemo.distributed import DistributedManager
from torch.distributed.fsdp import fully_shard
from torch.distributed.tensor import distribute_tensor
from torch.distributed.tensor.placement_types import ( # noqa: E402
Replicate,
Shard,
)
# PhysicsNeMo utilities for sharded inputs and model synchronization.
from physicsnemo.domain_parallel import (
scatter_tensor,
sync_module_over_mesh,
)
Run Configuration#
The configuration is the same for all four cases:
args = parse_args()
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
device = torch.device('cuda')
# Generate image sizes based on start, stop, and step
if args.dimension == 2:
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
elif args.dimension == 3:
image_sizes = list(range(args.image_size_start, min(args.image_size_stop + 1, 513), args.image_size_step))
# Should we use mixed precision?
precision_mode = "FP16" if args.use_mixed_precision and torch.cuda.is_available() else "FP32"
args = parse_args()
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
device = torch.device('cuda')
# Generate image sizes based on start, stop, and step
if args.dimension == 2:
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
elif args.dimension == 3:
image_sizes = list(range(args.image_size_start, min(args.image_size_stop + 1, 513), args.image_size_step))
# Should we use mixed precision?
precision_mode = "FP16" if args.use_mixed_precision and torch.cuda.is_available() else "FP32"
args = parse_args()
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
device = torch.device('cuda')
# Generate image sizes based on start, stop, and step
if args.dimension == 2:
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
elif args.dimension == 3:
image_sizes = list(range(args.image_size_start, min(args.image_size_stop + 1, 513), args.image_size_step))
# Should we use mixed precision?
precision_mode = "FP16" if args.use_mixed_precision and torch.cuda.is_available() else "FP32"
args = parse_args()
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
device = torch.device('cuda')
# Generate image sizes based on start, stop, and step
if args.dimension == 2:
image_sizes = list(range(args.image_size_start, args.image_size_stop + 1, args.image_size_step))
elif args.dimension == 3:
image_sizes = list(range(args.image_size_start, min(args.image_size_stop + 1, 513), args.image_size_step))
# Should we use mixed precision?
precision_mode = "FP16" if args.use_mixed_precision and torch.cuda.is_available() else "FP32"
Distributed Configuration#
Here physicsnemo.distributed.DistributedManager is used to set up the 1D or 2D parallelization:
# Initialize distributed manager first
DistributedManager.initialize()
dm = DistributedManager()
# Set device based on local rank
device = dm.device
torch.cuda.set_device(device)
# Initialize distributed manager first
DistributedManager.initialize()
dm = DistributedManager()
# Set via commandline and argparse:
ddp_size = args.ddp_size
domain_size = args.domain_size
# Set device based on local rank
device = dm.device
torch.cuda.set_device(device)
# Initialize distributed manager first
DistributedManager.initialize()
dm = DistributedManager()
# Set via commandline and argparse:
ddp_size = args.ddp_size
domain_size = args.domain_size
# Set device based on local rank
device = dm.device
torch.cuda.set_device(device)
# Use the physics nemo distribute manager to quickly and easily set up a pytorch DeviceMesh:
mesh = dm.initialize_mesh(
mesh_shape=(ddp_size, domain_size,), # -1 works the same way as reshaping
mesh_dim_names = ["ddp","domain"]
)
ddp_mesh = mesh["ddp"]
domain_mesh = mesh["domain"]
# Initialize distributed manager first
DistributedManager.initialize()
dm = DistributedManager()
# Set via commandline and argparse:
ddp_size = args.ddp_size
domain_size = args.domain_size
# Set device based on local rank
device = dm.device
torch.cuda.set_device(device)
# Create one mesh axis for FSDP2 and one for domain parallelism.
mesh = dm.initialize_mesh(
mesh_shape=(ddp_size, domain_size,), # -1 works the same way as reshaping
mesh_dim_names = ["ddp","domain"]
)
ddp_mesh = mesh["ddp"]
domain_mesh = mesh["domain"]
Preparing the Inputs#
We use synthetic inputs for this tutorial. The global batch size is assumed to be configured on the command line - and when using domain parallelism, divide the global batch size by the number of model replications.
For 2D parallelism, we divide the global batch size by the replicate count, but also apply a scatter to shard each single example across multiple GPUs.
Because we are parallelizing over the batch and domain, one batch of data
is scattered over an image axis. Review Shard(2) below. Review
the BCHW(D) format in PyTorch. In this example, we are targeting H:
if args.dimension == 2:
full_img_size = (img_size, img_size)
elif args.dimension == 3:
full_img_size = (img_size, img_size, img_size)
if args.dimension == 2:
full_img_size = (img_size, img_size)
elif args.dimension == 3:
full_img_size = (img_size, img_size, img_size)
# Create synthetic data - scale the batch size down by DDP size.
x = torch.randn(args.batch_size // ddp_size, 3, * full_img_size, device=device)
target = torch.randint(0, num_classes, (args.batch_size // ddp_size,), device=device)
if args.dimension == 2:
full_img_size = (img_size, img_size)
elif args.dimension == 3:
full_img_size = (img_size, img_size, img_size)
# Create synthetic data - scale the batch size down by DDP size.
x = torch.randn(args.batch_size // ddp_size, 3, * full_img_size, device=device)
target = torch.randint(0, num_classes, (args.batch_size // ddp_size,), device=device)
# Domain Parallel NOTE: we're generating data once per GPU but only keeping the data once per domain.
# In a real application, you'd do this properly - each GPU would read its own shard of the data.
if args.domain_size > 1:
# When scattering the data, we need to know the global rank of the source
# But by definition, we use the domain_rank == 0 as the source. Convert:
global_rank_of_source = torch.distributed.get_global_rank(domain_mesh.get_group(), 0)
# Scatter the input data across the domain:
x = scatter_tensor(
x,
global_rank_of_source,
domain_mesh,
placements=(Shard(2),), # Shard along the 2nd dimension (B C **H** W) which is the Height
global_shape = x.shape, # This will be inferred if not provided!
dtype = x.dtype, # This will be inferred if not provided!
)
target = scatter_tensor(
target,
global_rank_of_source,
domain_mesh,
placements=(Replicate(),), # REPLICATE the target
global_shape = target.shape, # This will be inferred if not provided!
dtype = target.dtype, # This will be inferred if not provided!
)
if args.dimension == 2:
full_img_size = (img_size, img_size)
elif args.dimension == 3:
full_img_size = (img_size, img_size, img_size)
# Create synthetic data - scale the batch size down by DDP size.
x = torch.randn(args.batch_size // ddp_size, 3, * full_img_size, device=device)
target = torch.randint(0, num_classes, (args.batch_size // ddp_size,), device=device)
# Domain Parallel NOTE: we're generating data once per GPU but only keeping the data once per domain.
# In a real application, you'd do this properly - each GPU would read its own shard of the data.
if args.domain_size > 1:
# When scattering the data, we need to know the global rank of the source
# But by definition, we use the domain_rank == 0 as the source. Convert:
global_rank_of_source = torch.distributed.get_global_rank(domain_mesh.get_group(), 0)
# Scatter the input data across the domain:
x = scatter_tensor(
x,
global_rank_of_source,
domain_mesh,
placements=(Shard(2),), # Shard along the 2nd dimension (B C **H** W) which is the Height
global_shape = x.shape, # This will be inferred if not provided!
dtype = x.dtype, # This will be inferred if not provided!
)
target = scatter_tensor(
target,
global_rank_of_source,
domain_mesh,
placements=(Replicate(),), # REPLICATE the target
global_shape = target.shape, # This will be inferred if not provided!
dtype = target.dtype, # This will be inferred if not provided!
)
Configure the Model#
Build the same plain model in every configuration. ShardTensor
automatically promotes plain parameters when they interact with sharded
activations, so the model itself remains a standard nn.Module.
# Base model
model = HybridViT(
img_size=full_img_size,
in_channels=3,
num_classes=num_classes,
)
model = model.to(device)
# Base model
model = HybridViT(
img_size=full_img_size,
in_channels=3,
num_classes=num_classes,
)
model = model.to(device)
# Wrap the model with DDP over the world process group.
model = DDP(model, device_ids=[dm.local_rank], output_device=dm.local_rank)
# The model remains a vanilla nn.Module.
model = HybridViT(
img_size=full_img_size,
in_channels=3,
num_classes=num_classes,
)
model = model.to(device)
# DDP synchronizes only over the data-parallel axis.
# Synchronize parameters and buffers over the domain axis once.
sync_module_over_mesh(model, domain_mesh)
if ddp_size > 1:
model = DDP(
model,
device_ids=[dm.local_rank],
output_device=dm.local_rank,
process_group=ddp_mesh.get_group(),
)
# The model remains a vanilla nn.Module.
model = HybridViT(
img_size=full_img_size,
in_channels=3,
num_classes=num_classes,
)
model = model.to(device)
# This statically shaped parameter follows the sequence-sharded
# activation. Other parameters remain plain and replicated.
model.pos_embed = nn.Parameter(
distribute_tensor(
model.pos_embed.data,
device_mesh=domain_mesh,
placements=[Shard(1)],
)
)
# FSDP2 synchronizes only over the data-parallel axis.
# Synchronize plain parameters and buffers over the domain axis once.
sync_module_over_mesh(model, domain_mesh)
# FSDP2 shards parameters over the data-parallel axis.
fully_shard(model, mesh=ddp_mesh)
The positional embedding is laid out as (1, num_patches, channels) and
therefore grows with the domain-sharded sequence. On the FSDP2 path, explicitly
shard that parameter with distribute_tensor so its placement matches the
activation. Parameters have static, even shapes, so DTensor is the appropriate
representation for them; ShardTensor is used for activations whose local
sizes may be uneven or data-dependent.
For a model with multiple spatial parameters, apply the same conversion recursively:
def shard_spatial_params(model, domain_mesh):
for module in model.modules():
for name, param in list(module.named_parameters(recurse=False)):
if "pos_embed" not in name:
continue
module.register_parameter(
name,
nn.Parameter(
distribute_tensor(
param.data,
device_mesh=domain_mesh,
placements=[Shard(1)],
),
requires_grad=param.requires_grad,
),
)
Parameters not explicitly converted remain plain tensors. This is the
recommended DDP path: ShardTensor promotes them to replicated distributed
tensors only when needed. Use FSDP2 when the parameters themselves must be
sharded.
Compile the Model#
ShardTensor supports torch.compile as of PhysicsNeMo v2.2.0:
model = torch.compile(model)
Several operations with cuda stream overlaps are still being migrated to compilable operations, such as sequence-sharded ring attention. These must remain outside a compiled region for now.
After adding a few extra imports, setting up a DeviceMesh, sharding the
inputs, and configuring data parallelism, everything else proceeds as usual.
You can run the benchmark with the same code across all four implementations:
results = end_to_end_benchmark(args, model, (x, target), full_img_size, device, num_classes)
if dm.rank == 0:
print_and_save_results(results, args, precision_mode, dm.world_size)
results = end_to_end_benchmark(args, model, (x, target), full_img_size, device, num_classes)
if dm.rank == 0:
print_and_save_results(results, args, precision_mode, dm.world_size)
results = end_to_end_benchmark(args, model, (x, target), full_img_size, device, num_classes)
if dm.rank == 0:
print_and_save_results(results, args, precision_mode, dm.world_size)
results = end_to_end_benchmark(args, model, (x, target), full_img_size, device, num_classes)
if dm.rank == 0:
print_and_save_results(results, args, precision_mode, dm.world_size)
Note
The full training script and all worker functions, configurable by domain size and DDP size, are available on PhysicsNeMo GitHub examples.
Benchmark Results#
Benchmark results can be useful for deciding when to use ShardTensor or
DDP/FSDP2. We recommend that you use ShardTensor when you can’t fit
batch_size==1 on a single GPU.
512x512x512 3D Image with a Convolutional Network#
At a resolution of 512 pixels on a side, we will show the memory and performance
scaling of ShardTensor with DDP on a convolutional network on synthetic data.
This model has about 32 million parameters, and the results were collected on
NVIDIA 4xGB200 systems.
We can keep the per-GPU batch size fixed, scale out with DDP, and get good scaling.
We can also scale in two directions and see that latency, at fixed global
batch size, decreases; however, ShardTensor isn’t ideal in this regime:
Training Throughput (Images / second) ShardTensor shows an
improvement in throughput as the GPUs processing each image increases.
Note that, without compile, the very large image size is not optimal
on a single GPU.
GPUS / Image |
B=1 |
B=2 |
B=4 |
|---|---|---|---|
1 |
0.405 |
0.795 |
1.595 |
2 |
1.319 |
2.629 |
|
4 |
2.506 |
Training Memory Usage (GB) ShardTensor also shows an expected
reduction in memory used during training as each GPU shares the
intermediate activations.
GPUS / Image |
B=1 |
B=2 |
B=4 |
|---|---|---|---|
1 |
150.437 |
150.557 |
150.557 |
2 |
78.423 |
78.542 |
|
4 |
39.797 |
Training Throughput (Images / second) With torch.compile,
performance on a single GPU as well as distributed performance improves.
GPUS / Image |
B=1 |
B=2 |
B=4 |
|---|---|---|---|
1 |
1.028 |
1.989 |
3.942 |
2 |
1.924 |
||
4 |
3.598 |
Training Memory Usage (GB) torch.compile also provides some
memory optimizations through kernel fusions, though they can be harder
to achieve in the distributed case when the fused kernels have a necessary
collective in between: memory reductions with compile do not scale as well.
GPUS / Image |
B=1 |
B=2 |
B=4 |
|---|---|---|---|
1 |
124.937 |
119.056 |
119.056 |
2 |
77.003 |
||
4 |
39.128 |
ShardTensor, in most operations, does add a little overhead. Most of the
kernels that benefit from domain parallelism require communication between
GPUs and efficiency increases as the computational size increases from 1024
squared to 2048 squared:
Latency per step (s) The processing time increases linearly with the number of tokens in each layer, but tokens scale as the resolution squared.
GPUs |
Inference @ 512 |
Train @ 512 |
Inference, compiled |
Train, compiled |
|---|---|---|---|---|
1 |
1.76618 |
2.47058 |
0.26468 |
0.97248 |
2 |
0.25657 |
0.75825 |
0.14291 |
0.51965 |
4 |
0.13674 |
0.39906 |
0.07703 |
0.27796 |
Speedup After a certain data size, ShardTensor is always
faster with more GPUs. But, larger images show bigger benefits.
GPUs |
Inference @ 512 |
Train @ 512 |
Inference, compiled |
Train, compiled |
|---|---|---|---|---|
1 |
1.00 |
1.00 |
1.00 |
1.00 |
2 |
6.88 |
3.26 |
1.85 |
1.87 |
4 |
12.92 |
6.19 |
3.44 |
3.50 |
Memory Usage (GB) Like latency, memory usage in training scales roughly like the number of tokens. For inference, it’s driven mostly by model size.
GPUs |
Inference @ 512 |
Train @ 512 |
Inference, compiled |
Train, compiled |
|---|---|---|---|---|
1 |
9.579 |
150.437 |
9.579 |
124.937 |
2 |
5.882 |
78.423 |
5.131 |
77.003 |
4 |
3.253 |
39.797 |
2.88 |
39.128 |
Memory Reduction (%) For highest resolution data, we obtain close to linear reduction in memory with more GPUs.
GPUs |
Inference @ 512 |
Train @ 512 |
Inference, compiled |
Train, compiled |
|---|---|---|---|---|
1 |
N/A |
N/A |
N/A |
N/A |
2 |
38.6% |
47.9% |
46.4% |
38.4% |
4 |
66.0% |
73.5% |
69.9% |
68.7% |
If you are tracking the memory scaling performance of this model, you’ll see that the training memory at higher resolution is roughly proportional to the total number of pixels in the image. At 51.4 GB of training memory for 2048x2048 sized images, we expect the next doubling (4096x4096 pixels) to require more than 200 GB of memory per GPU.
Using ShardTensor, we can run it out of the box on 8 GPUs and we see about
26 GB of memory used per GPU, as expected. You can also run large-scale 3D
vision models like this. However, because memory usage scales with the cube of
the resolution (rather than the square, as in 2D), memory issues arise
even faster.
ViT: DDP and FSDP2 Integration#
ShardTensor is composable with both DDP and FSDP2. The differences
between the two interfaces become most apparent at very large models. As a
demonstration, for a ViT model on \(2048 \times 2048\) inputs in FP32 on four GPUs with
torch.compile disabled, we can compare the latency and memory usage
for both training and inference:
Domain GPUs |
Data GPUs |
Parameter strategy |
Batch size |
Forward time [s] |
Training time [s] |
Inference memory [GB/GPU] |
Training memory [GB/GPU] |
|---|---|---|---|---|---|---|---|
1 |
4 |
DDP |
4 |
4.09 |
16.4 |
5.25 |
52.1 |
1 |
4 |
FSDP2 |
4 |
4.09 |
16.4 |
5.71 |
51.7 |
2 |
2 |
DDP |
2 |
2.01 |
8.19 |
5.34 |
27.5 |
2 |
2 |
FSDP2 |
2 |
2.01 |
8.18 |
4.74 |
26.6 |
4 |
1 |
DDP |
1 |
1.07 |
4.26 |
3.64 |
14.4 |
4 |
1 |
FSDP2 |
1 |
1.07 |
4.26 |
3.54 |
14.5 |
Domain GPUs |
Data GPUs |
Parameter strategy |
Batch size |
Forward time [s] |
Training time [s] |
Inference memory [GB/GPU] |
Training memory [GB/GPU] |
|---|---|---|---|---|---|---|---|
1 |
4 |
DDP |
4 |
8.48 |
33.6 |
14.6 |
107 |
1 |
4 |
FSDP2 |
4 |
8.48 |
33.5 |
13.8 |
105 |
2 |
2 |
DDP |
2 |
4.11 |
16.6 |
14.8 |
58.4 |
2 |
2 |
FSDP2 |
2 |
4.11 |
16.6 |
12.7 |
55.3 |
4 |
1 |
DDP |
1 |
2.15 |
8.43 |
10.6 |
31.3 |
4 |
1 |
FSDP2 |
1 |
2.16 |
8.46 |
11.2 |
32.3 |
In all cases, the memory used by FSDP2 and DDP is relatively similar,
especially compared to the differences seen between the domain parallelism ranks.
However, at extreme model scales, FSDP2 will certainly be useful, and use
is encouraged.
What about Activation Checkpointing?#
ShardTensor and Activation Checkpointing serve similar purposes: reducing
memory used by activations. Currently, composing these two techniques is largely
untested and not expected to work in all cases. This is something we look forward
to exploring more as models and data sets continue to grow.
Review of Tutorial Steps#
This tutorial covered the key steps to enable ShardTensor in your model.
ShardTensor performance and broad layer support are still evolving. Many
key models will work out of the box, while others contain operations that are
not yet fully supported. If you have specific requests for support, open an
issue on GitHub and
review the tutorial for Implementing New Layers for ShardTensor.
Summary of the Workflow for 2D Domain Parallelism
Define the Device Mesh
Split the mesh into two dimensions: one for data parallelism (DDP or FSDP2) and one for spatial decomposition (
ShardTensor).Example:
mesh = dm.initialize_mesh((-1, 2), mesh_dim_names=["data", "spatial"])
For multilevel parallelism, the mesh can be extended to additional dimensions. A DeviceMesh can be conceptualized as an N-dimensional tensor where each element is one GPU, and each dimension of the tensor is one axis of parallelism.
Shard Input Data
Distribute the input tensor across the spatial dimension using
ShardTensor.Handle Parameters
Keep parameters as plain tensors for DDP and let
ShardTensorautomatically promote them when they meet sharded activations. If parameters must also be sharded, such as spatial embeddings, convert selected spatial parameters withdistribute_tensorand apply FSDP2 withfully_shardover the data-parallel mesh. In both cases, callsync_module_over_meshbefore wrapping the model so plain parameters and buffers agree across the domain mesh.Compile the Model
Use
torch.compilenormally. Keep sequence-sharded ring attention outside compiled regions.Scale Spatial Dimensions
Larger spatial dimensions can be processed efficiently by distributing computation across devices.
You are now ready to scale your models and data to very high resolutions
using ShardTensor.