Deep Implementation

Simulate tensor parallelism

When a model is too large for one GPU, you split it across many. Tensor parallelism splits individual weight matrices across GPUs so they compute in parallel. Today I simulate the column and row parallel linear layers from the Megatron-LM paper, showing exactly how the math works and where the communication happens.

A 70B parameter model in FP16 needs 140 GB just for the weights. An H100 has 80 GB of HBM. The model simply does not fit on one GPU. You need to split it, and tensor parallelism (TP) is the most common way to do it for inference.

I covered the theory of model parallelism earlier in this journey. Today I want to get concrete: simulate the actual matrix splits, the partial computations on each GPU, and the all-reduce communication that stitches the results back together. No multi-GPU hardware required. Just NumPy, clear thinking, and the Megatron-LM paper's partitioning scheme.

The core idea

A linear layer computes Y = XW + b, where X is the input activation and W is the weight matrix. Tensor parallelism splits W across N GPUs. There are two ways to split:

The Megatron-LM insight is to pair these: use column parallel for the first linear layer in a transformer block (e.g., the up-projection in the MLP), and row parallel for the second (the down-projection). This way, you only need one all-reduce per transformer block instead of two, because the column-parallel output is already distributed in the right layout for the row-parallel input.

Simulating column parallel linear

Let's start with the simpler case. We have a weight matrix W of shape (in_features, out_features) and we want to split it across N GPUs along the output dimension:

import numpy as np

def column_parallel_linear(x, W, bias, num_gpus):
    """Simulate a column-parallel linear layer.
    
    W is split along columns (axis=1). Each GPU computes its slice
    of the output independently. No communication needed until
    the results are used.
    
    Args:
        x: input activation, shape (batch, in_features)
        W: weight matrix, shape (in_features, out_features)
        bias: bias vector, shape (out_features,)
        num_gpus: number of GPUs to split across
    
    Returns:
        List of partial outputs, one per GPU
    """
    in_features, out_features = W.shape
    assert out_features % num_gpus == 0
    slice_size = out_features // num_gpus

    gpu_outputs = []
    for i in range(num_gpus):
        # Each GPU gets columns [i*slice : (i+1)*slice]
        W_slice = W[:, i * slice_size : (i + 1) * slice_size]
        b_slice = bias[i * slice_size : (i + 1) * slice_size]
        # Local matmul: no communication needed
        y_slice = x @ W_slice + b_slice
        gpu_outputs.append(y_slice)

    return gpu_outputs

# Verify: concatenating all slices equals the full computation
np.random.seed(42)
batch, in_f, out_f = 4, 512, 2048
x = np.random.randn(batch, in_f).astype(np.float32)
W = np.random.randn(in_f, out_f).astype(np.float32)
b = np.random.randn(out_f).astype(np.float32)

y_full = x @ W + b
gpu_outputs = column_parallel_linear(x, W, b, num_gpus=4)
y_concat = np.concatenate(gpu_outputs, axis=1)

print(f"Max error: {np.max(np.abs(y_full - y_concat)):.2e}")
# Max error: 0.00e+00

Column parallel is clean: each GPU computes independently, and the outputs are simply concatenated. No communication during the forward pass. The activation function (GeLU, SiLU) can be applied locally on each GPU because it operates element-wise on each slice.

Simulating row parallel linear

Row parallel splits W along the input dimension. Each GPU holds rows of W and receives a slice of the input. The catch is that each GPU computes a partial sum of the output, and we need an all-reduce to get the final result:

def row_parallel_linear(x_slices, W, bias, num_gpus):
    """Simulate a row-parallel linear layer.
    
    W is split along rows (axis=0). Each GPU gets a slice of the input
    and a horizontal slice of W. The partial outputs are summed via
    all-reduce.
    
    Args:
        x_slices: list of input slices, one per GPU.
                  Each has shape (batch, in_features // num_gpus)
        W: full weight matrix, shape (in_features, out_features)
        bias: bias vector, shape (out_features,)
        num_gpus: number of GPUs
    
    Returns:
        Final output after all-reduce, shape (batch, out_features)
    """
    in_features, out_features = W.shape
    assert in_features % num_gpus == 0
    slice_size = in_features // num_gpus

    partial_sums = []
    for i in range(num_gpus):
        W_slice = W[i * slice_size : (i + 1) * slice_size, :]
        partial = x_slices[i] @ W_slice
        partial_sums.append(partial)

    # All-reduce: sum partial results across GPUs
    # In real hardware, this is an NCCL all-reduce over NVLink
    y = sum(partial_sums) + bias
    return y

The all-reduce is the communication cost. On NVLink-connected GPUs (like 8x H100 in a DGX node), all-reduce for a typical activation tensor takes on the order of tens of microseconds. On PCIe, it is significantly slower. This is why tensor parallelism is practical within a node (NVLink) but not across nodes (network), where pipeline parallelism is preferred.

Pairing them: the Megatron-LM MLP

A standard transformer MLP has two linear layers with an activation function in between:

y = W_down @ activation(W_up @ x + b_up) + b_down

Megatron-LM makes W_up column-parallel and W_down row-parallel. Here is why this is clever:

def megatron_mlp_forward(x, W_up, b_up, W_down, b_down, num_gpus):
    """Simulate a Megatron-LM style tensor-parallel MLP.
    
    W_up: column-parallel (split along output dim)
    W_down: row-parallel (split along input dim)
    
    The column-parallel output is already partitioned correctly
    to serve as input to the row-parallel layer. No reshape or
    communication between the two layers.
    """
    # Step 1: Column-parallel up-projection
    # Each GPU computes a slice of the hidden activations
    gpu_hidden = column_parallel_linear(x, W_up, b_up, num_gpus)

    # Step 2: Apply activation locally on each GPU
    gpu_activated = [np.maximum(0, h) for h in gpu_hidden]  # ReLU for simplicity

    # Step 3: Row-parallel down-projection
    # Each GPU's activated slice is the correct input slice for row-parallel
    y = row_parallel_linear(gpu_activated, W_down, b_down, num_gpus)

    # Only ONE all-reduce happened (inside row_parallel_linear)
    return y
The communication saving

If both layers were independently parallelized, you would need two all-reduces per MLP block (one after each layer). By pairing column-parallel with row-parallel, the output partitioning of the first layer matches the input partitioning of the second, eliminating one all-reduce. Over 80 transformer layers, this halves the total communication volume.

Attention heads: naturally parallel

Tensor parallelism applies to attention even more naturally than to the MLP. Multi-head attention already splits computation across heads. With TP degree N, each GPU handles (num_heads / N) attention heads. The Q, K, V projection is column-parallel (each GPU computes its heads' projections), attention is computed locally per head, and the output projection is row-parallel.

def tensor_parallel_attention(x, W_qkv, W_out, num_heads, num_gpus):
    """Sketch of tensor-parallel multi-head attention.
    
    Each GPU owns (num_heads // num_gpus) attention heads.
    """
    heads_per_gpu = num_heads // num_gpus
    
    for gpu_id in range(num_gpus):
        # Column-parallel QKV projection: each GPU gets its heads
        # No communication needed
        q, k, v = project_qkv(x, W_qkv, gpu_id, heads_per_gpu)
        
        # Local attention computation per head
        # No communication needed (each head is independent)
        attn_out = scaled_dot_product_attention(q, k, v)
        
    # Row-parallel output projection: all-reduce to combine
    # This is the only communication point
    output = row_parallel_output_projection(attn_outputs, W_out)
    return output

The constraint is that num_heads must be divisible by the TP degree. A model with 32 attention heads can use TP=1, 2, 4, 8, 16, or 32. This is why model architectures choose head counts that are powers of two or have many factors.

The communication cost model

Each transformer layer requires two all-reduce operations: one in the MLP and one in the attention output projection. An all-reduce of a tensor with B elements across N GPUs transfers 2 * B * (N-1) / N bytes (using the ring all-reduce algorithm). For a 7B model with hidden dimension 4096 in FP16, each all-reduce moves:

# Activation size: batch_size * seq_len * hidden_dim * 2 bytes
# For batch=1, seq_len=1 (decode), hidden=4096, FP16:
allreduce_bytes = 1 * 1 * 4096 * 2  # 8 KB per all-reduce
# Two all-reduces per layer, 32 layers:
total_bytes = 8192 * 2 * 32  # 512 KB total

# NVLink bandwidth: ~900 GB/s per direction on H100
# Time: 512 KB / 900 GB/s = ~0.5 microseconds
# But latency dominates at small sizes: ~5-10 microseconds per all-reduce

For decode (batch size 1, sequence length 1), the all-reduce data is tiny but the latency of initiating the collective operation dominates. With 32 layers and 2 all-reduces each, that is 64 all-reduce calls at 5-10 microseconds each, adding 320-640 microseconds of communication overhead per token. For a model that generates a token in 10ms on a single GPU, this is a 3-6% overhead, which is acceptable.

For prefill with large batches and long sequences, the data volume grows but so does the compute, keeping the communication-to-computation ratio favorable.

Tensor parallelism splits the work, but communication is the tax. The art is minimizing the number of synchronization points (Megatron pairing) and keeping each one small (all-reduce only activations, never weights).

What I took away

Next up: benchmarking the ops:byte ratio in practice, where I measure actual arithmetic intensity on real workloads and see how closely reality matches the roofline theory from day 6.