Tensor Parallelism › Code Implementation

Michael BrenndoerferJanuary 19, 202616 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 223Tensor Parallelism › Code Implementation
Reading section 9 of 12 · Code Implementation · 16 minSection 9 of 12 · 16 min

Book contents

Section 9 of 12 · 16 min read

Tensor Parallelism › Code ImplementationOne section · Sections in this chapter9 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement tensor parallelism from scratch using PyTorch's distributed computing primitives. We'll simulate multiple GPUs using CPU processes, then show how the same logic applies on actual multi-GPU hardware.

Setting Up Distributed Process Groups

First, we set up the distributed environment. In real training, this is handled by the launcher (e.g., torchrun), but for this tutorial, we'll configure it explicitly.

In[5]:
Code
def setup_distributed(rank: int, world_size: int) -> None:
    """Initialize the distributed process group."""
    os.environ["MASTER_ADDR"] = "localhost"
    os.environ["MASTER_PORT"] = "12355"
    dist.init_process_group(
        backend="gloo",  # Use "nccl" for actual GPUs
        rank=rank,
        world_size=world_size,
    )


def cleanup_distributed() -> None:
    """Clean up the distributed process group."""
    dist.destroy_process_group()

The process group is the fundamental abstraction for collective communication in PyTorch. Each GPU (or CPU process in simulation) has a rank within the group, and collective operations like all-reduce act across all ranks in the group. Real Megatron training uses NCCL backend for GPU-to-GPU communication via NVLink or InfiniBand; we use Gloo here for CPU-based simulation.

Column-Parallel Linear Layer

Now let's implement a column-parallel linear layer. Each process holds a column shard of the weight matrix and computes on the full input.

In[6]:
Code
import torch
import torch.nn as nn
import torch.nn.functional as F


class ColumnParallelLinear(nn.Module):
    """
    Linear layer split along the output (column) dimension.

    Each rank holds a shard of shape (in_features, out_features // world_size).
    The input X is replicated on all ranks.
    The output Y is sharded: each rank produces columns [rank * shard_size : (rank+1) * shard_size].
    """

    def __init__(
        self,
        in_features: int,
        out_features: int,
        world_size: int,
        rank: int,
        bias: bool = True,
    ) -> None:
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.world_size = world_size
        self.rank = rank

        # Each rank holds out_features / world_size output columns
        assert out_features % world_size == 0, (
            f"out_features ({out_features}) must be divisible by world_size ({world_size})"
        )
        self.shard_size = out_features // world_size

        # Weight shard for this rank: shape (shard_size, in_features)
        # F.linear expects weight of shape (out_features, in_features)
        self.weight = nn.Parameter(
            torch.randn(self.shard_size, in_features) * (1.0 / in_features**0.5)
        )
        if bias:
            # Bias shard: each rank has the bias for its output columns
            self.bias = nn.Parameter(torch.zeros(self.shard_size))
        else:
            self.bias = None

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: Full input tensor of shape (batch, seq_len, in_features),
               replicated on all ranks.
        Returns:
            Sharded output of shape (batch, seq_len, shard_size),
            each rank has a different column slice.
        """
        # Local matrix multiply: X @ W_shard^T + b_shard
        # F.linear computes x @ weight.T + bias, weight shape: (shard_size, in_features)
        output = F.linear(x, self.weight, self.bias)
        return output

Notice that the forward method contains no communication. This is the defining property of column parallelism: each rank can compute its output shard entirely from local data. The weight shard has shard_size rows (corresponding to the output columns assigned to this rank) and in_features columns (the full input dimension). F.linear computes x @ weight.T + bias, which for our weight shape gives x @ (shard_size, in_features).T = x @ (in_features, shard_size), producing the correct output shard of shape (batch, seq_len, shard_size).

Row-Parallel Linear Layer

The row-parallel layer receives a sharded input (output from a column-parallel layer) and produces a full output via all-reduce.

In[7]:
Code
class RowParallelLinear(nn.Module):
    """
    Linear layer split along the input (row) dimension.

    Each rank holds a shard of shape (in_features // world_size, out_features).
    The input X is sharded: each rank receives its corresponding columns.
    The full output Y is produced by summing partial results via all-reduce.
    """

    def __init__(
        self,
        in_features: int,
        out_features: int,
        world_size: int,
        rank: int,
        bias: bool = True,
    ) -> None:
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.world_size = world_size
        self.rank = rank

        assert in_features % world_size == 0, (
            f"in_features ({in_features}) must be divisible by world_size ({world_size})"
        )
        self.shard_size = in_features // world_size

        # Weight shard for this rank: shape (out_features, shard_size)
        # F.linear expects weight of shape (out_features, in_features)
        self.weight = nn.Parameter(
            torch.randn(out_features, self.shard_size)
            * (1.0 / in_features**0.5)
        )
        # Bias lives on rank 0 only and is added after the all-reduce
        if bias and rank == 0:
            self.bias = nn.Parameter(torch.zeros(out_features))
        else:
            self.bias = None

    def forward(self, x_shard: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x_shard: Sharded input of shape (batch, seq_len, shard_size).
                     Each rank provides its input shard (columns from the previous layer).
        Returns:
            Full output of shape (batch, seq_len, out_features) on all ranks.
        """
        # Local matrix multiply: partial_Y = X_shard @ W_shard^T
        # Shape: (batch, seq_len, out_features)
        partial_output = F.linear(x_shard, self.weight)

        # All-reduce: sum partial outputs across all ranks
        # After this, every rank has the full output
        dist.all_reduce(partial_output, op=dist.ReduceOp.SUM)

        # Add bias (only rank 0 has it; others contribute zero)
        if self.bias is not None:
            partial_output = partial_output + self.bias

        return partial_output

The all-reduce call is the only communication in the row-parallel forward pass. Notice the bias handling: only rank 0 holds the bias term, which is added after the all-reduce. This is correct because the bias is a full-output-dimensional vector (not split), and adding it once (on rank 0) is equivalent to adding it to the summed output. Adding it on every rank would multiply the bias by world_size, which would be incorrect.

Tensor-Parallel Feed-Forward Network

We can now assemble a complete tensor-parallel FFN by chaining column-parallel and row-parallel layers.

In[8]:
Code
class TensorParallelFFN(nn.Module):
    """
    Tensor-parallel feed-forward network using column-row chaining.

    W1 is column-parallel: each rank holds (d_model, 4*d_model/world_size)
    W2 is row-parallel: each rank holds (4*d_model/world_size, d_model)

    Communication: one all-reduce in the forward pass (inside RowParallelLinear)
    """

    def __init__(
        self,
        d_model: int,
        d_ffn: int,
        world_size: int,
        rank: int,
    ) -> None:
        super().__init__()
        self.column_parallel = ColumnParallelLinear(
            in_features=d_model,
            out_features=d_ffn,
            world_size=world_size,
            rank=rank,
        )
        self.row_parallel = RowParallelLinear(
            in_features=d_ffn,
            out_features=d_model,
            world_size=world_size,
            rank=rank,
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: Full input tensor (batch, seq_len, d_model) on all ranks.
        Returns:
            Full output tensor (batch, seq_len, d_model) on all ranks.
        """
        # Column-parallel: full input → sharded activations
        # Each rank computes GeLU(X @ W1_shard)
        hidden = F.gelu(self.column_parallel(x))

        # Row-parallel: sharded activations → full output (with all-reduce)
        output = self.row_parallel(hidden)
        return output

The elegance of this implementation is in what is absent: there is no explicit communication between the column-parallel and row-parallel layers. The sharded output of column_parallel flows directly into row_parallel as a sharded input, with no reshaping, no synchronization, and no data movement. The single dist.all_reduce inside row_parallel handles all necessary communication for the entire two-layer block.

Demonstrating Correctness

To verify our tensor-parallel implementation produces the same result as a standard sequential FFN, we simulate two-GPU behavior by running the full computation twice with rank-0 and rank-1 shards.

In[9]:
Code
def demonstrate_column_parallel_correctness() -> dict:
    """
    Verify that column-parallel + row-parallel chaining gives
    the same result as a standard sequential linear layer.
    This function simulates 2-GPU behavior without actual distributed setup.
    """
    torch.manual_seed(42)

    d_model = 8
    d_ffn = 16
    batch_size = 2
    seq_len = 3
    world_size = 2

    # Create full weight matrices (the reference)
    W1_full = torch.randn(d_model, d_ffn) * (1.0 / d_model**0.5)
    W2_full = torch.randn(d_ffn, d_model) * (1.0 / d_ffn**0.5)
    b1_full = torch.zeros(d_ffn)
    b2_full = torch.zeros(d_model)

    # Full input, replicated on both "GPUs"
    x = torch.randn(batch_size, seq_len, d_model)

    # Reference (non-parallel) computation
    hidden_full = F.gelu(x @ W1_full + b1_full)
    y_reference = hidden_full @ W2_full + b2_full

    # Simulate rank 0
    shard_size_ffn = d_ffn // world_size  # = 8

    W1_shard_0 = W1_full[:, :shard_size_ffn]  # (8, 8)
    W2_shard_0 = W2_full[:shard_size_ffn, :]  # (8, 8)
    b1_shard_0 = b1_full[:shard_size_ffn]

    hidden_0 = F.gelu(x @ W1_shard_0 + b1_shard_0)  # (2, 3, 8)
    partial_y_0 = hidden_0 @ W2_shard_0  # (2, 3, 8)

    # Simulate rank 1
    W1_shard_1 = W1_full[:, shard_size_ffn:]  # (8, 8)
    W2_shard_1 = W2_full[shard_size_ffn:, :]  # (8, 8)
    b1_shard_1 = b1_full[shard_size_ffn:]

    hidden_1 = F.gelu(x @ W1_shard_1 + b1_shard_1)  # (2, 3, 8)
    partial_y_1 = hidden_1 @ W2_shard_1  # (2, 3, 8)

    # All-reduce: sum partial results + bias
    y_parallel = partial_y_0 + partial_y_1 + b2_full

    max_diff = (y_reference - y_parallel).abs().max().item()
    mean_diff = (y_reference - y_parallel).abs().mean().item()

    return {
        "max_absolute_diff": max_diff,
        "mean_absolute_diff": mean_diff,
        "outputs_match": max_diff < 1e-5,
        "y_reference_shape": list(y_reference.shape),
        "y_parallel_shape": list(y_parallel.shape),
        "d_model": d_model,
        "d_ffn": d_ffn,
        "world_size": world_size,
        "shard_size": shard_size_ffn,
    }


results = demonstrate_column_parallel_correctness()
Out[10]:
Console
Tensor Parallelism Correctness Verification
==================================================
Input shape:   (batch=2, seq=3, d_model=8)
Output shape:  [2, 3, 8]
FFN hidden dim: 16 split into 2 shards of 8 each

Max absolute difference (reference vs parallel): 2.38e-07
Mean absolute difference:                        3.34e-08
Outputs match (diff < 1e-5):                     True

The near-zero numerical difference confirms that the column-parallel and row-parallel chaining produces exactly the same result as the sequential computation. Any remaining difference is due to floating-point arithmetic order: when you sum the same values in a different sequence, rounding errors at the bit level can introduce tiny discrepancies that are entirely correct behavior. The correctness proof guarantees mathematical equivalence; the implementation delivers numerical equivalence up to floating-point precision.

Memory Savings Analysis

A key benefit of tensor parallelism is reduced memory per GPU. Let's compute how memory requirements scale with the tensor-parallel degree.

In[11]:
Code
def compute_memory_savings(
    d_model: int,
    num_layers: int,
    ffn_ratio: int = 4,
    bytes_per_param: int = 2,  # fp16
) -> dict:
    """
    Compute weight memory per GPU for different tensor-parallel degrees.
    """
    # Parameter counts for one transformer layer
    # Attention: 4 weight matrices of shape (d_model, d_model) for Q, K, V, O
    attn_params_per_layer = 4 * d_model * d_model

    # FFN: W1 of (d_model, ffn_ratio*d_model), W2 of (ffn_ratio*d_model, d_model)
    ffn_params_per_layer = 2 * d_model * (ffn_ratio * d_model)

    total_params_per_layer = attn_params_per_layer + ffn_params_per_layer
    total_params = total_params_per_layer * num_layers

    total_bytes = total_params * bytes_per_param
    total_gb = total_bytes / (1024**3)

    memory_by_degree = {}
    for degree in [1, 2, 4, 8, 16]:
        params_per_gpu = total_params / degree
        bytes_per_gpu = params_per_gpu * bytes_per_param
        gb_per_gpu = bytes_per_gpu / (1024**3)
        memory_by_degree[degree] = {
            "params_per_gpu": int(params_per_gpu),
            "gb_per_gpu": gb_per_gpu,
            "reduction_factor": degree,
        }

    return {
        "total_params": total_params,
        "total_gb": total_gb,
        "memory_by_degree": memory_by_degree,
        "d_model": d_model,
        "num_layers": num_layers,
    }


# GPT-3 scale: d_model=12288, 96 layers
memory_info = compute_memory_savings(d_model=12288, num_layers=96)
Out[12]:
Console
Model: d_model=12288, 96 layers
Total weight parameters: 173.9B
Total weight memory (fp16): 324.0 GB

 TP Degree |     Params/GPU | Weight GB/GPU
---------------------------------------------
         1 |         173.9B |         324.0
         2 |          87.0B |         162.0
         4 |          43.5B |          81.0
         8 |          21.7B |          40.5
        16 |          10.9B |          20.2

These numbers show why tensor parallelism is essential at GPT-3 scale. With a tensor-parallel degree of 8, each GPU holds roughly 40 GB of weight memory for GPT-3's attention and FFN layers combined, well within the 80 GB capacity of an A100. Without tensor parallelism, a single GPU would need to hold over 320 GB for those weights alone, far exceeding any available device memory. This estimate covers only the core weight matrices; embedding tables, optimizer states, and gradient buffers add substantially more to the total memory burden. The optimizer states for mixed-precision training (maintaining fp32 master weights alongside fp16 working weights) roughly triple the weight memory, which is why real training systems need tensor-parallel degrees of 8 or more even for models much smaller than GPT-3.

Out[13]:
Visualization
Bar chart showing GPU memory reduction as tensor-parallel degree increases from 1 to 16.
Weight memory per GPU as a function of tensor-parallel degree for a GPT-3-scale model (d_model=12288, 96 layers, fp16 weights). Memory scales inversely with the parallel degree, and bars exceeding the 80 GB A100 limit are shown in coral. Degree 4 remains just above the limit at 81 GB; degree 8 is the first configuration shown that fits. The dashed line marks the 80 GB limit.

Communication Overhead Estimation

Understanding when tensor parallelism becomes the bottleneck requires estimating how much time the all-reduce operations consume relative to compute. Let's build this analysis.

In[14]:
Code
def estimate_communication_overhead(
    d_model: int,
    seq_len: int,
    batch_size: int,
    num_layers: int,
    num_gpus: int,
    bandwidth_gb_per_s: float,
    compute_tflops_per_gpu: float,
) -> dict:
    """
    Estimate communication time relative to compute time for tensor parallelism.

    Each transformer layer requires 2 all-reduces (one for attention, one for FFN).
    Each all-reduce transmits one full activation tensor of shape (batch, seq, d_model).
    """
    bytes_per_element = 2  # fp16

    # Size of one activation tensor
    activation_size_bytes = batch_size * seq_len * d_model * bytes_per_element
    activation_size_gb = activation_size_bytes / (1024**3)

    # In a ring all-reduce with N GPUs, each GPU sends and receives 2*(N-1)/N * data
    # For communication time, use the effective bandwidth of the ring
    all_reduce_volume_gb = 2 * (num_gpus - 1) / num_gpus * activation_size_gb
    all_reduce_time_s = all_reduce_volume_gb / bandwidth_gb_per_s

    # Total communication: 2 all-reduces per layer
    total_comm_time_s = 2 * num_layers * all_reduce_time_s

    # Compute time estimate for transformer
    # Each transformer layer: ~24 * d_model^2 * batch * seq FLOPs
    # (4 proj matrices in attention: 2 * 4 * d^2 * batch * seq = 8 * d^2 * batch * seq
    #  + FFN 2 linear layers: 2 * 2 * 4 * d^2 * batch * seq = 16 * d^2 * batch * seq)
    flops_per_layer = 24 * (d_model**2) * batch_size * seq_len
    total_flops = flops_per_layer * num_layers

    # With TP degree = num_gpus, compute is distributed across GPUs
    flops_per_gpu = total_flops / num_gpus
    # A100 achieves ~60% of peak on typical transformer workloads
    effective_tflops = compute_tflops_per_gpu * 0.6
    compute_time_s = flops_per_gpu / (effective_tflops * 1e12)

    comm_to_compute_ratio = total_comm_time_s / compute_time_s

    return {
        "activation_size_mb": activation_size_gb * 1024,
        "all_reduce_volume_mb": all_reduce_volume_gb * 1024,
        "all_reduce_time_ms": all_reduce_time_s * 1000,
        "total_comm_time_ms": total_comm_time_s * 1000,
        "compute_time_ms": compute_time_s * 1000,
        "comm_to_compute_ratio": comm_to_compute_ratio,
        "comm_overhead_pct": comm_to_compute_ratio * 100,
    }


# NVLink (within node): ~600 GB/s bidirectional
nvlink_overhead = estimate_communication_overhead(
    d_model=4096,
    seq_len=2048,
    batch_size=4,
    num_layers=32,
    num_gpus=8,
    bandwidth_gb_per_s=300.0,  # One-directional NVLink
    compute_tflops_per_gpu=312.0,  # A100 fp16
)

# InfiniBand (across nodes): ~25 GB/s
infiniband_overhead = estimate_communication_overhead(
    d_model=4096,
    seq_len=2048,
    batch_size=4,
    num_layers=32,
    num_gpus=8,
    bandwidth_gb_per_s=12.5,  # One-directional InfiniBand
    compute_tflops_per_gpu=312.0,
)
Out[15]:
Console
Communication Overhead Analysis (8 GPUs, d_model=4096, 32 layers)
=================================================================
                                     NVLink   InfiniBand
-----------------------------------------------------------------
Activation size (MB)                   64.0         64.0
All-reduce volume/op (MB)             112.0        112.0
All-reduce time (ms)                  0.365        8.750
Total comm time (ms)                   23.3        560.0
Compute time (ms)                      70.5         70.5
Comm overhead (%)                      33.1        794.5

The contrast between NVLink and InfiniBand overhead illustrates why tensor parallelism is typically restricted to within a single DGX node (8 GPUs). NVLink's high bandwidth keeps communication below compute time through the typical within-node range, while InfiniBand communication already exceeds compute time at degree 2 in this simplified model. The analysis also shows why the minimal-communication design of Megatron's column-row chaining matters: at InfiniBand speeds, even the carefully optimized two-all-reduces-per-layer design is expensive. A naive design requiring four or six all-reduces per layer would be completely impractical.

Out[16]:
Visualization
Line chart showing communication overhead rising faster for InfiniBand than NVLink as tensor-parallel degree increases.
Communication overhead percentage as a function of tensor-parallel degree for NVLink (within-node, 300 GB/s) and InfiniBand (cross-node, 12.5 GB/s) interconnects. The InfiniBand overhead rises steeply with degree, confirming that cross-node tensor parallelism is communication-bound at typical model sizes.
Stacked bar chart comparing compute time versus communication time for NVLink and InfiniBand at varying degrees.
Stacked bars comparing compute time versus communication time for NVLink and InfiniBand at different tensor-parallel degrees. InfiniBand communication time dominates compute from degree 2 onward. NVLink communication remains below compute through degree 8, but the shrinking compute shard makes communication the larger component by degree 16.

Key Parameters

The key parameters for tensor parallelism configuration are:

  • tensor_parallel_degree (tp): Number of GPUs across which each layer is split. Must divide all weight dimensions evenly. Typically 1, 2, 4, or 8 within a single node.
  • pipeline_parallel_degree (pp): Number of stages in pipeline parallelism, used alongside tensor parallelism. A cluster of tp×pptp \times pp GPUs forms one complete model replica.
  • data_parallel_degree (dp): Number of replicas used for data parallelism. Total GPUs =tp×pp×dp= tp \times pp \times dp.
  • sequence_length: Affects activation memory proportionally. Longer sequences amplify the benefit of sequence parallelism.
  • all_reduce_backend: Use nccl for NVIDIA GPU communication (exploits NVLink/InfiniBand), gloo for CPU.