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:

  1. ViT Model Overview

  2. Benchmarking the ViT on a single GPU

  3. Enabling domain parallelism with ShardTensor

  4. Training 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
Listing 10 Example 1: ViT Model#
# 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
Listing 11 Convolutional Patch Embedding in 2D#
# 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
Listing 12 Convolutional Patch Embedding in 3D#
# 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
Listing 13 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
Listing 14 Simple Multi-layer Perceptron#
# 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
Listing 15 Multi-head Attention with pytorch scaled dot product 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
Listing 16 After a few warmup steps, use 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
Listing 17 Use 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
Listing 18 Combine the above two functions to measure performance and memory usage for a given model and inputs.#
# 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

  1. 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.

  2. Shard Input Data

    Distribute the input tensor across the spatial dimension using ShardTensor.

  3. Handle Parameters

    Keep parameters as plain tensors for DDP and let ShardTensor automatically promote them when they meet sharded activations. If parameters must also be sharded, such as spatial embeddings, convert selected spatial parameters with distribute_tensor and apply FSDP2 with fully_shard over the data-parallel mesh. In both cases, call sync_module_over_mesh before wrapping the model so plain parameters and buffers agree across the domain mesh.

  4. Compile the Model

    Use torch.compile normally. Keep sequence-sharded ring attention outside compiled regions.

  5. 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.