The LLM StackFrom Silicon to Agents
Part III — Pretraining at Scale
31 min read·Updated ·▶ Run the code (Colab)·⚡ Run on GPU (2x A100)

3.7 Megatron-LM, DeepSpeed & Parallelism in Practice

Knowing the theory of tensor, pipeline, and data parallelism is one thing. Knowing how to wire them together on a 512-GPU cluster, pick the right degrees, and then confirm that your hardware is actually doing useful work is another. This chapter bridges that gap. We take the parallelism primitives introduced in Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP and Distributed Training II: Tensor, Pipeline, Sequence & Expert Parallelism, and show how Megatron-LM and DeepSpeed compose them into production training runs.

By the end of this chapter you will understand the Megatron-Core abstraction layer, the full ZeRO hierarchy and its offload variants, the 4-D (DP × TP × PP × EP) parallelism space, how to reason about Model FLOP Utilization (MFU) and Hardware FLOP Utilization (HFU), and exactly which configuration levers to pull for a 70B-parameter run.

Megatron-LM: A Framework Built Around 3-D Parallelism

Megatron-LM, developed at NVIDIA, was the first framework to train models beyond 100B parameters in a systematic way. The 2021 paper by Narayanan et al. (“Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM”) introduced the idea of combining tensor parallelism (TP), pipeline parallelism (PP), and data parallelism (DP) in a principled way. The current codebase ships as two packages:

  • Megatron-LM — the outer loop: launch scripts, training harness, logging, checkpointing.
  • Megatron-Core (megatron.core) — a library of parallelism-aware transformer building blocks that other frameworks (NVIDIA NeMo, Databricks MosaicML, Aleph Alpha) can import.

The 3-D Parallelism Layout

Given a cluster with \(N\) GPUs, Megatron partitions them into a 3-D grid:

\[ N = \text{DP} \times \text{TP} \times \text{PP} \]

Every GPU belongs to exactly one data-parallel replica, one tensor-parallel group, and one pipeline stage. The layout is typically described as:

TP group (intra-node, NVLink all-reduce) — TP = 4 DP replica 1 GPU 0 stage 0, rank 0 GPU 1 stage 0, rank 1 GPU 2 stage 0, rank 2 GPU 3 stage 0, rank 3 one PP stage pipeline send: activations / bubbles (point-to-point) GPU 4 stage 1, rank 0 GPU 5 stage 1, rank 1 GPU 6 stage 1, rank 2 GPU 7 stage 1, rank 3 next PP stage DP all-reduce / ZeRO reduce-scatter (gradient sync across replicas) DP replica 2 GPU 8 copy of stage 0 GPU 9 copy of stage 0 GPU 10 copy of stage 0 GPU 11 copy of stage 0 DP replica 2 (copy of replica 1) Dimension semantics: horizontal = TP: weights split across GPUs; NVLink all-reduce on critical path vertical within replica = PP: consecutive layer stages; point-to-point sends (InfiniBand) between replicas = DP: identical model copies; gradient sync via all-reduce / ZeRO
3-D parallelism maps every GPU to exactly one TP group, one PP stage, and one DP replica. Within a node, four GPUs share a tensor-parallel group connected by NVLink; two rows stacked vertically form two pipeline stages exchanging activations point-to-point; the dashed block below is the data-parallel replica that syncs gradients via all-reduce or ZeRO reduce-scatter after each step.

The rule of thumb that emerges from practice: place TP within a node (so the all-reduce inside a TP group can use NVLink rather than crossing InfiniBand) and use PP to span nodes (pipeline sends are point-to-point and latency-tolerant). DP can be at any granularity but is often the outermost dimension.

Megatron-Core Parallel State

Megatron-Core tracks the parallelism topology in a global parallel_state module. Understanding this is essential for debugging.

# megatron/core/parallel_state.py  (simplified)
import torch
import torch.distributed as dist

_TP_GROUP = None  # tensor-parallel process group
_PP_GROUP = None  # pipeline-parallel process group
_DP_GROUP = None  # data-parallel process group

def initialize_model_parallel(
    tensor_model_parallel_size: int = 1,
    pipeline_model_parallel_size: int = 1,
):
    """
    Build the 3-D process group topology.
    Assumes dist.init_process_group() has already been called.
    world_size = TP * PP * DP is enforced implicitly.
    """
    global _TP_GROUP, _PP_GROUP, _DP_GROUP

    world_size = dist.get_world_size()
    rank = dist.get_rank()

    dp_size = world_size // (tensor_model_parallel_size * pipeline_model_parallel_size)

    # --- Tensor-parallel groups ---
    # Each group of TP consecutive ranks forms one TP group.
    for i in range(pipeline_model_parallel_size * dp_size):
        ranks = list(range(i * tensor_model_parallel_size,
                           (i + 1) * tensor_model_parallel_size))
        group = dist.new_group(ranks)
        if rank in ranks:
            _TP_GROUP = group

    # --- Pipeline-parallel groups ---
    # Stride by TP; each group of PP elements (spaced TP apart) is a PP group.
    for i in range(tensor_model_parallel_size * dp_size):
        ranks = list(range(i, world_size, tensor_model_parallel_size))[:pipeline_model_parallel_size]
        group = dist.new_group(ranks)
        if rank in ranks:
            _PP_GROUP = group

    # --- Data-parallel groups ---
    for i in range(tensor_model_parallel_size * pipeline_model_parallel_size):
        ranks = list(range(i, world_size, tensor_model_parallel_size * pipeline_model_parallel_size))
        group = dist.new_group(ranks)
        if rank in ranks:
            _DP_GROUP = group

def get_tensor_model_parallel_group(): return _TP_GROUP
def get_pipeline_model_parallel_group(): return _PP_GROUP
def get_data_parallel_group(): return _DP_GROUP

Every Megatron-Core layer that performs a collective operation (e.g., the column/row linear layers in the transformer) calls get_tensor_model_parallel_group() to target the right process group. This design keeps the distributed logic out of user code.

Sequence Parallelism in Megatron

The original Megatron TP implementation broadcasts the input activations to all TP ranks before computing. This means the activations (LayerNorm inputs, dropout outputs) are replicated across TP ranks and waste memory proportional to TP.

Megatron-LM’s sequence parallelism (introduced in “Reducing Activation Recomputation in Large Transformer Models,” Korthikanti et al. 2022) avoids this. Outside the TP-sharded GEMM blocks, the sequence dimension is sharded across TP ranks. The transition in/out of sequence-parallel regions uses all-gather before the column parallel GEMM and reduce-scatter after the row parallel GEMM — replacing an all-reduce with two smaller collectives.

x: [B, S, H] each rank holds [B, S/TP, H] — sequence-parallel activation mem = B·S·H / TP all-gather (TP dim) PEAK ACTIVATION MEMORY — TP copies replicated [B, S, H] on every rank fully replicated — TP copies of full [B,S,H] in flight activation mem = B·S·H (no TP saving here) Column-parallel Linear (weight sharded on output dim) [B, S, H/TP] per rank column-parallel output — different columns per rank (tensor-parallel) Row-parallel Linear (weight sharded on input dim) partial [B, S, H] per rank row-parallel output = partial sums awaiting reduction (each rank has a different partial contribution) reduce-scatter (sum + scatter along S) [B, S/TP, H] per rank back to sequence-parallel activation mem = B·S·H / TP (restored) Net: steady-state activation mem = B·S·H / TP (vs B·S·H without seq. parallelism) For TP=8: 8x activation memory reduction — significant at long sequences Box 2 (highlighted) is the only point holding full [B,S,H]; all others are 1/TP size all-gather + reduce-scatter replaces one all-reduce (same total comm volume)
Sequence parallelism keeps activations sharded along the sequence dimension everywhere except inside the TP-parallel GEMM pair. An all-gather reconstructs the full [B,S,H] tensor before the column-parallel linear (the highlighted replicated state — peak activation memory); a reduce-scatter immediately after the row-parallel linear shards it back, restoring the B·S·H/TP memory footprint and cutting activation memory by a factor of TP compared to naive tensor parallelism.

Net effect: activation memory at steady state is H * B * S / TP rather than H * B * S. For TP=8 this is an 8× activation reduction — significant at long sequences.

DeepSpeed: ZeRO and Beyond

DeepSpeed (Microsoft) complements Megatron by providing the optimizer-side solution. The core abstraction is ZeRO (Zero Redundancy Optimizer), which eliminates the redundant copies of optimizer states, gradients, and parameters that vanilla DDP maintains.

ZeRO Stages Recap

The three ZeRO stages correspond to increasingly aggressive sharding across DP ranks. We cover the theory in Distributed Training I; here we focus on the implementation details that matter for a real run.

Parameters 2P Gradients 2P Optimizer states 12P not stored on this GPU 4 GPUs in one data-parallel group sharded band = 1 filled slice (this GPU's shard) + 3 hollow (other GPUs') GPU 0GPU 1 GPU 2GPU 3 ZeRO-1 optimizer states sharded params + grads full copy ~4x saving (Adam) O0O1 O2O3 ZeRO-2 + gradients sharded params still full copy ~8x saving O0O1 O2O3 ZeRO-3 + parameters sharded ~DPx saving (here 4x) + all-gather per pass O0O1 O2O3 colored footprint shrinks Vanilla DDP: every GPU holds a full copy of all 16P bytes -- the redundancy ZeRO removes. Only ZeRO-3's parameters band is also sharded, so a forward pass needs a fresh all-gather to reassemble weights.
ZeRO shards model state across the data-parallel group instead of replicating it, and the sharding is progressive: each colored quantity is either a full copy on every GPU (a solid bar, unchanged row to row) or split so each GPU stores only its own 1/DP slice (one filled sliver plus three hollow slivers marking shards held elsewhere). Stage 1 shards only the heavy optimizer states; stage 2 additionally shards gradients; stage 3 additionally shards parameters — so the colored area on any single GPU visibly shrinks top to bottom, while the trade-off (an all-gather of parameters on every forward and backward pass) only appears at stage 3.
Stage What is sharded Peak memory saving (DP=64) Communication volume per step
ZeRO-1 Optimizer states (momentum, variance) ~4× for Adam \(2P\) — reduce-scatter grads + all-gather updated params; identical to DDP’s all-reduce
ZeRO-2 + Gradients ~8× \(2P\) — same as ZeRO-1 (only memory changes)
ZeRO-3 + Parameters ~64× \(3P\) — adds a param all-gather in both forward and backward; ~1.5× DDP

That last row is the whole trade: ZeRO-3 buys a DP-fold reduction in model-state memory at 1.5× the communication of DDP, and unlike ZeRO-½ the extra all-gather sits on the critical path of the forward pass, so it must be prefetched (stage3_prefetch_bucket_size) to be hidden.

For a model with \(P\) parameters stored in fp16 (2 bytes) and Adam optimizer states in fp32:

\[ \text{Memory per GPU (ZeRO-3)} = \frac{2P + 2P + 12P}{\text{DP}} = \frac{16P}{\text{DP}} \]

The first \(2P\) is the fp16 parameters, the second \(2P\) the fp16 gradients, and the \(12P\) comes from the fp32 master weights (4), Adam first moment (4), and second moment (4) — the same 16 bytes per parameter tallied in Step 1 below. ZeRO-3 shards all 16 bytes; each DP rank holds \(16P / \text{DP}\) bytes of “owned” state plus the activations for its pipeline stage.

ZeRO-Offload and ZeRO-Infinity

For clusters with more CPU memory or NVMe than GPU memory, DeepSpeed provides offload variants:

  • ZeRO-Offload: moves optimizer states (and optionally gradients) to CPU RAM. The optimizer step runs on CPU, which is fine because optimizer steps are memory-bandwidth-bound (read param + states, write updated param) and do not require GPU arithmetic throughput.
  • ZeRO-Infinity: extends offload to NVMe storage using heterogeneous memory management. A bandwidth-aware scheduler overlaps NVMe reads with GPU compute.
# DeepSpeed config JSON for ZeRO-3 with CPU offload
import json

zero3_config = {
    "zero_optimization": {
        "stage": 3,
        "offload_optimizer": {
            "device": "cpu",       # optimizer states live on CPU RAM
            "pin_memory": True     # page-locked for fast DMA transfer
        },
        "offload_param": {
            "device": "cpu",       # fp16 params also offloaded
            "pin_memory": True
        },
        "overlap_comm": True,      # overlap reduce-scatter with backward pass
        "contiguous_gradients": True,
        "sub_group_size": 1e9,     # process params in 1B-element chunks
        "reduce_bucket_size": "auto",
        "stage3_prefetch_bucket_size": "auto",
        "stage3_param_persistence_threshold": "auto",
        "stage3_max_live_parameters": 1e9,
        "stage3_max_reuse_distance": 1e9,
    },
    "fp16": {
        "enabled": True,
        "loss_scale": 0,           # dynamic loss scaling
        "loss_scale_window": 1000
    },
    "gradient_clipping": 1.0,
    "train_micro_batch_size_per_gpu": 2,
    "gradient_accumulation_steps": 8,
}

with open("ds_config_zero3.json", "w") as f:
    json.dump(zero3_config, f, indent=2)

Initializing a DeepSpeed Engine

import deepspeed
import torch
import torch.nn as nn

class TinyTransformerBlock(nn.Module):
    """A minimal transformer block for demonstration."""
    def __init__(self, d_model: int, n_heads: int, ffn_mult: int = 4):
        super().__init__()
        self.attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, ffn_mult * d_model),
            nn.GELU(),
            nn.Linear(ffn_mult * d_model, d_model),
        )

    def forward(self, x):
        # Pre-norm residual style (GPT-style)
        x = x + self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0]
        x = x + self.ffn(self.norm2(x))
        return x

model = nn.Sequential(*[TinyTransformerBlock(1024, 16) for _ in range(24)])

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)

# deepspeed.initialize wraps the model, optimizer, and dataloader
# into a DeepSpeedEngine that handles ZeRO sharding transparently.
model_engine, optimizer, _, _ = deepspeed.initialize(
    model=model,
    optimizer=optimizer,
    config="ds_config_zero3.json",
)

# Training step — identical to vanilla PyTorch apart from backward()/step()
# being called on the engine rather than on the loss and the optimizer.
import torch.nn.functional as F

for batch in dataloader:                       # yields (inputs, labels) on the right device
    inputs, labels = batch
    hidden = model_engine(inputs)              # (B, S, d_model)
    logits = lm_head(hidden)                   # your untied/tied output projection
    loss = F.cross_entropy(
        logits.view(-1, logits.size(-1)).float(),
        labels.view(-1),
    )
    model_engine.backward(loss)    # gradient reduce-scatter, accumulation-aware
    model_engine.step()            # triggers all-gather, optimizer step, re-shard

Note that model_engine.backward() and .step() are gradient-accumulation aware: DeepSpeed reads gradient_accumulation_steps from the config and only fires the reduce-scatter and the optimizer update on boundary micro-steps, so your loop never calls zero_grad() or counts micro-batches itself.

The 4-D Parallelism Space: Adding Expert Parallelism

Modern MoE (Mixture-of-Experts) models add a fourth dimension. See Mixture-of-Experts (MoE) Architectures for the architecture. The full parallelism space becomes:

\[ N = \text{DP} \times \text{TP} \times \text{PP} \times \text{EP} \]

Expert parallelism (EP) shards the experts across EP ranks. Within a single MoE layer, tokens are dispatched to experts on different GPUs via all-to-all collectives. EP communicates token activations rather than parameters, so the all-to-all volume is proportional to sequence length and hidden size, not parameter count.

Megatron-Core’s MoELayer handles the EP dimension natively (--expert-model-parallel-size, with --moe-token-dispatcher-type alltoall). The key constraint: each EP group must see enough tokens to keep all experts loaded. An expert that processes very few tokens is wasted capacity — the load imbalance problem that auxiliary loss terms (introduced by Switch Transformer, Fedus et al.) address.

N = DP x TP x PP x EP = 4 x 4 x 4 x 2 = 128 GPUs (16 nodes x 8 GPUs/node) EP group A EP group B Node 0 · TP grp 0 · PP stage 0 NVLink domain Node 1 · TP grp 1 · PP stage 0 NVLink domain ... nodes 2–7 ... Node 8 · TP grp 0 · PP stage 0 NVLink domain Node 9 · TP grp 1 · PP stage 0 NVLink domain ... nodes 9–15 ... all-to-all MoE dispatch (EP) Full 128-GPU cluster EP-A: nodes 0–7 | EP-B: nodes 8–15 In node: NVLink (TP) · Across nodes: IB (PP/EP/DP) DP all-reduce spans all 128 GPUs / step Communication domains All-reduce (TP) intra-node NVLink — ~600 GB/s agg. stays within one node (8 GPUs) on fwd-pass critical path — must be fast Pipeline send (PP) inter-node InfiniBand — point-to-point ~50 GB/s per NDR port; latency-tolerant crosses node boundary (PP stages) All-to-all (MoE dispatch, EP) crosses EP group boundary (2 nodes/call) volume = S x H x bytes — seq len, not params key MoE property: cost independent of model size DP all-reduce / ZeRO reduce-scatter all 128 GPUs — widest scope overlap backward pass (ZeRO-1/2) ZeRO-3: all-gather per fwd pass EP moves token activations (volume ~ S x H), not parameters — all-to-all cost scales with sequence length, not model size. Rule: match each collective to its network tier TP within node · PP/EP span nodes · DP is outermost — Config: DP=4, TP=4, PP=4, EP=2 on 128 GPUs = 16 nodes x 8 GPUs/node
4-D parallelism on a 128-GPU MoE cluster assigns each of its four communication collectives to a different network tier. TP all-reduce is the only collective on the forward-pass critical path and must stay on intra-node NVLink; PP pipeline sends, EP all-to-all token dispatch, and DP gradient sync all cross InfiniBand but are latency-tolerant or overlappable with compute. The EP all-to-all volume scales with sequence length (S × H), not model size.

The Fifth Axis: Context Parallelism

For long-context runs there is one more independent axis. Context parallelism (CP) shards the sequence across CP ranks and computes attention with a ring exchange of K/V blocks (Ring Attention), so activation memory and attention FLOPs per GPU both fall by CP× while the model weights stay replicated across the CP group. Megatron-Core exposes it as --context-parallel-size, and the full grid becomes \(N = \text{DP} \times \text{TP} \times \text{PP} \times \text{CP} \times \text{EP}\).

Do not confuse CP with the sequence parallelism described above: Megatron’s SP is a memory optimization strictly inside a TP group (it shards the norm/dropout regions along sequence and is free, so it is always on whenever TP > 1), whereas CP is a genuine extra dimension of the GPU grid that you spend GPUs on. The rule of thumb is to leave CP=1 until sequence length pushes activation memory past what recomputation can absorb — typically 32K tokens and beyond — then raise CP before raising TP, because the ring exchange is point-to-point and overlappable while the TP all-reduce is not. The mechanism is developed in Long-Context Pretraining & Context Extension and Distributed Training II.

Choosing Parallelism Degrees: A Systematic Approach

The parallelism config is the single highest-leverage decision when launching a large training run. Getting it wrong can cost 30-50% of hardware efficiency. Here is a principled workflow.

Step 1 — Fit the Model on One Node

Start with the model’s parameter count \(P\). For mixed-precision training with bf16 parameters and fp32 optimizer states:

\[ \text{Bytes per parameter} = 2 \text{ (bf16 param)} + 2 \text{ (bf16 grad)} + 4 \text{ (fp32 master)} + 8 \text{ (Adam)} = 16 \]

For a 70B model: \(70 \times 10^9 \times 16 = 1{,}120 \text{ GB}\) of pure model state, before activations or intermediate buffers.

With 8× H100 80 GB per node (640 GB HBM), you need at least \(\lceil 1120 / 80 \rceil = 14\) GPUs just for model state. In practice, activation memory can double this, so TP=8 × PP=4 = 32 GPUs minimum before any DP replication.

Step 2 — Pick TP

TP is constrained by intra-node bandwidth (NVLink). The all-reduce inside a TP column-parallel GEMM must finish before the next GEMM begins; it sits on the critical path.

  • TP=1: no communication, maximum arithmetic intensity.
  • TP=2: doubles memory for attention and FFN weight distribution; all-reduce is 2× 25 GB/s NVLink streams.
  • TP=4 or TP=8: recommended for nodes with 4 or 8 GPUs respectively and NVLink.
  • TP > 8: crosses PCIe/InfiniBand; avoid unless forced.

Rule: TP = number of GPUs per node (or a divisor of it) and never span node boundaries.

Step 3 — Pick PP

Pipeline parallelism introduces a bubble overhead. The bubble fraction for the 1F1B (one-forward-one-backward) schedule is approximately:

\[ \text{bubble fraction} \approx \frac{PP - 1}{PP - 1 + m} \]

where \(m\) is the number of micro-batches in flight. To keep bubble under 5%:

\[ m \geq 19(PP - 1) \]

For PP=4 this means \(m \geq 57\) micro-batches, which is achievable with large global batch sizes.

Interleaved pipeline schedules (Megatron’s virtual pipeline parallelism) split each stage into \(v\) chunks, reducing the bubble to:

\[ \text{bubble fraction (interleaved)} \approx \frac{1}{v} \cdot \frac{PP - 1}{PP - 1 + m} \approx \frac{PP - 1}{v \cdot m} \]

at the cost of additional pipeline communication per micro-batch.

Step 4 — Set DP from what remains

Parallelism & memory planner (ZeRO-3 / FSDP)
DP is inferred: DP = floor(GPUs / (TP × PP))
0%50% headroom line100%+
Activation memory for one pipeline stage can roughly double the model-state figure at steady state (chapter Worked Example: 11 GB state + 3.2 GB activations + 2 GB buffers). Treat green (<50%) as the safe target; amber means budget activations carefully. This models full ZeRO-3 / FSDP sharding across DP × TP × PP; ZeRO-1 (chapter Worked Example) shards only across TP × PP and gives a larger per-GPU figure.
\[ \text{DP} = \frac{N_{\text{total GPUs}}}{\text{TP} \times \text{PP}} \]

DP is “free” communication if you use ZeRO-1 or ZeRO-2 (the reduce-scatter / all-gather can be overlapped with the backward pass). ZeRO-3 adds synchronous all-gather per forward pass but eliminates parameter redundancy.

Step 5 — Tune global batch size and gradient accumulation

Global batch size (GBS) drives convergence. There is a critical batch size \(B_{\text{crit}}\) — well approximated by the gradient noise scale of McCandlish et al. (An Empirical Model of Large-Batch Training, 2018) — below which increasing the batch size buys a near-linear reduction in the number of optimizer steps, and above which the returns diminish sharply. \(B_{\text{crit}}\) is not a function of vocabulary size and has no simple closed form; it grows over the course of training as the loss falls. Rather than a formula, practitioners target empirical global batch sizes on the order of 1M–4M tokens per step for runs above 10B parameters, then tune the learning rate against batch size as described in Learning Rate Schedules, Warmup, Batch Size & Hyperparameters.

Given a fixed GBS and a per-GPU micro-batch size (MBS), the number of gradient accumulation steps (GAS) is:

\[ \text{GAS} = \frac{\text{GBS}}{\text{MBS} \times \text{DP} \times S} \]
N total GPUs (illustrative: 32) 1 FIT THE MODEL model state per parameter, bf16 + fp32 Adam: 2 (bf16 param) + 2 (bf16 grad) + 4 (fp32 master) + 8 (Adam m,v) = 16 bytes/param need >= ceil(model state / HBM per GPU) GPUs for state alone; activations roughly double it -> e.g. 70B needs ~32 GPUs (TP8 x PP4) minimum WHY HBM capacity is the hard floor 2 PICK TP TP all-reduce sits on the critical path -> keep on NVLink Rule: TP = GPUs per node (or a divisor); never span nodes one node 0123 NVLink mesh TP=1: no comm, max intensity TP=4/8: matches node size + NVLink TP>8: crosses PCIe/IB -- avoid WHY NVLink tier ~900 GB/s intra-node 3 PICK PP WHY InfiniBand tier inter-node, latency-tolerant PP sends are point-to-point, latency-tolerant -> span nodes bubble ~ (PP-1) / (PP-1+m) keep <5% -> m >= 19(PP-1) interleaved (virtual PP) splits each stage into v chunks, dividing the bubble by v -- at the cost of v x more comm 4 SET DP DP = N / (TP x PP) the leftover after TP, PP are carved out ZeRO-1/2 overlaps DP comm -> nearly free 5 TUNE BATCH GBS ~ 1M-4M tokens/step GAS = GBS / (MBS x DP x S) S = sequence length Example landing point: TP=8, PP=8, DP=8 -> 512 GPUs
Choosing a parallelism config is a five-step funnel that narrows from raw GPU count to a concrete TP/PP/DP split. Each step is governed by a different constraint — HBM capacity fixes the minimum GPU count, NVLink bandwidth caps TP, pipeline-bubble math bounds PP, ZeRO overlap makes DP nearly free, and target token throughput sets the batch size — reading top to bottom in the order you should actually decide them.

Running the Funnel at 100M: the Answer Is Usually DP-Only

Apply the same five steps to the capstone’s ~100M-parameter model and every model-parallel degree collapses to 1. Step 1: \(16P = 16 \times 10^8 \approx 1.6\) GB of model state — it fits on a 16 GB T4 with room to spare, so nothing must be sharded. Step 2: TP exists only to make a model fit or to cut per-GPU activation memory; with 1.6 GB of state there is nothing to split, and TP=2 would add an all-reduce per layer to a model whose GEMMs (\(d_{\text{model}}=512\)) are too small to amortize it. Step 3: PP has the same answer, plus a bubble you cannot pay for. Steps 4–5: DP = number of GPUs, and you spend the whole global-batch budget on gradient accumulation.

The practical consequence is worth stating plainly, because it saves readers weeks: you do not need Megatron-LM or DeepSpeed to train a 100M model. Plain DistributedDataParallel — or FSDP2 if you want the optimizer-state saving for free — over torchrun --nproc_per_node=8 is the correct tool, and The Pretraining Run: A Complete Single-GPU Training Loop shows that even one GPU suffices for the whole capstone. What does transfer directly from this chapter at 100M scale is the measurement discipline: the \(6P\) FLOPs/token rule, the MFU calculation, and the Nsight-before-you-tune habit. Small models typically land at lower MFU than 70B ones (they are launch-latency- and memory-bandwidth-bound rather than GEMM-bound), so calibrate expectations against similar-sized runs, not against the 40–55% frontier-scale band. The techniques here become mandatory somewhere around 7B–13B, the transition described in Retrospective: Cost Accounting, Reproducibility, and the Path to 1B.

MFU and HFU: Measuring Real Hardware Utilization

You have launched the run. Now you want to know if you are getting good value from your hardware. Two metrics matter.

Model FLOP Utilization (MFU)

MFU measures what fraction of the GPU’s peak throughput is being used for forward + backward arithmetic of the model itself.

The number of FLOPs per token for a standard decoder-only Transformer is approximately:

\[ \text{FLOPs/token} \approx 6P + 12 \cdot n_\text{layers} \cdot d_\text{model} \cdot S \]

where the \(6P\) term comes from \(\sim 2P\) for the forward pass (each parameter participates in roughly 2 multiply-adds) times 3 for the full backward pass, and the second term is the attention quadratic cost (often secondary for moderate sequence lengths). Following the PaLM/Megatron convention, the attention term counts the full \(S \times S\) score matrix even though causal masking means a FlashAttention kernel actually computes only half of it — MFU is deliberately a model-FLOP metric, so everyone must count the same nominal FLOPs for the numbers to be comparable across papers.

For practical purposes, practitioners often use the simplified rule:

\[ \text{FLOPs/token} \approx 6P \]

MFU is then:

\[ \text{MFU} = \frac{\text{FLOPs/token} \times \text{tokens/second}}{\text{peak FLOP/s of cluster}} \]

Target MFU for dense models on A100/H100 clusters is 35–55%. Values below 25% suggest a misconfiguration (bubble too high, TP spans nodes, small micro-batch sizes hitting latency bottlenecks, checkpointing overhead).

Hardware FLOP Utilization (HFU)

HFU counts all FLOPs actually issued to the GPU, including those in recomputed activations (gradient checkpointing). If you recompute one third of layers:

\[ \text{HFU} = \text{MFU} \times \frac{\text{total FLOPs issued}}{\text{model FLOPs}} \]

With full activation recomputation, you pay for an extra forward pass just before each backward, giving forward \(2P\) + recomputed forward \(2P\) + backward \(4P\) = \(8P\) per token, so:

\[ \text{FLOPs/token (with full recompute)} \approx \frac{4}{3} \times 6P = 8P \]

HFU ≥ MFU. A good cluster should see HFU of 55–70% on H100s with modern frameworks.

FLOPs per token (P = model parameters, symbolic) MFU -- useful model work forward 2P backward 4P (~2x forward: grads wrt inputs AND weights) = 6P FLOPs/token attention adds 12 x n_layers x d_model x S per token, secondary at moderate S MFU = (6P x tok/s) peak cluster FLOP/s HFU -- all FLOPs issued to the GPU forward 2P recomputed forward +2P backward 4P hatched = paid for, not new (checkpointing) 6P mark (Bar A's end) = 8P FLOPs/token = (4/3) x 6P HFU = (issued x tok/s) peak = MFU x (issued/model) HFU >= MFU always -- recompute trades memory for extra issued FLOPs that raise HFU but not useful throughput Targets on H100: dense MFU 35-55%, HFU 55-70%. MFU below 25% signals a misconfiguration (bubble too high, TP spanning nodes, micro-batch too small, checkpointing overhead).
MFU counts only the 6P FLOPs/token that do useful forward and backward work; HFU also counts the extra 2P from recomputing activations under gradient checkpointing, giving 8P issued. Because HFU's numerator always includes everything MFU's does plus more, HFU never falls below MFU — the gap between them is the price paid in wasted-but-necessary FLOPs to keep activation memory in budget.
def compute_mfu(
    model_params: int,        # number of parameters
    tokens_per_second: float, # observed training throughput
    peak_flops_per_sec: float, # e.g., 5.06e17 for dense bf16 on 512 H100 SXM5 GPUs
    n_layers: int = None,
    d_model: int = None,
    seq_len: int = None,
) -> float:
    """
    Compute Model FLOP Utilization.

    Uses the simplified 6P rule for forward+backward FLOPs per token.
    If n_layers, d_model, seq_len are provided, also adds attention cost.
    """
    flops_per_token = 6 * model_params

    # Attention cost (forward + backward): 12 * n_layers * d_model * seq_len per token.
    # Forward is 4 * d * S per layer (QK^T and AV, each 2 * d * S); x3 for the
    # backward pass, matching the fwd+bwd convention of the 6P base term.
    if all(v is not None for v in [n_layers, d_model, seq_len]):
        attn_flops = 12 * n_layers * d_model * seq_len
        flops_per_token += attn_flops

    achieved_flops = flops_per_token * tokens_per_second
    mfu = achieved_flops / peak_flops_per_sec
    return mfu


# Example: 70B model, 512 H100 SXM5 GPUs
# H100 SXM5 dense bf16 peak: ~989 TFLOP/s = 9.89e14 FLOP/s per GPU.
# (The 1979 TFLOP/s figure NVIDIA quotes is the 2:4-sparse rate; dense
#  training runs against half of it.)
H100_BF16_TFLOPS = 9.89e14
n_gpus = 512
peak_cluster_flops = H100_BF16_TFLOPS * n_gpus  # ~5.06e17 FLOP/s

# Observed: 1200 tokens/second per GPU = 614.4K tokens/second total
tokens_per_sec = 1200 * n_gpus

mfu = compute_mfu(
    model_params=70e9,
    tokens_per_second=tokens_per_sec,
    peak_flops_per_sec=peak_cluster_flops,
    n_layers=80,
    d_model=8192,
    seq_len=4096,
)
print(f"MFU: {mfu:.2%}")  # prints ~54.9% (~55%) for a well-configured run, incl. attention term

Worked Example: Memory Budget for a 70B Run

Setup: 70B parameter model, bf16, 512 H100-80GB GPUs, TP=8, PP=8, DP=8, GBS=4M tokens, seq_len=4096.

Model state (per DP rank, ZeRO-1): - bf16 parameters: \(70 \times 10^9 \times 2 = 140\) GB total, 140/1 per DP rank (ZeRO-1 does not shard params) - With TP=8, PP=8: each GPU holds \(\frac{1}{8 \times 8} = \frac{1}{64}\) of the model = \(140/64 \approx 2.2\) GB bf16 params - fp32 master weight copy: \(140 \times 2 = 280\) GB total / 64 = 4.4 GB per GPU - Adam states: same as master copy = 4.4 GB per GPU - Subtotal model state per GPU: \(2.2 + 4.4 + 4.4 = 11\) GB

Activation memory (one pipeline stage, without recomputation): - Layers per pipeline stage: \(80 / 8 = 10\) layers - Activations per layer ≈ \(2 \times B \times S \times H\) bytes (input and output of attention block) - With MBS=2, \(S=4096\), \(H=8192\): \(2 \times 2 \times 4096 \times 8192 \times 2 \approx 537\) MB per layer - 10 layers: \(\approx 5.4\) GB activations per stage (before recompute) - With selective recompute (e.g., recompute attention blocks only): reduce by \(\sim\)40% → 3.2 GB

Total per GPU (approximate): \(11 + 3.2 + 2\) (buffers/gradients) \(= 16.2\) GB — well within 80 GB.

MFU check: - FLOPs per token: \(6 \times 70 \times 10^9 = 4.2 \times 10^{11}\) - Peak cluster (dense bf16): \(9.89 \times 10^{14} \times 512 \approx 5.06 \times 10^{17}\) FLOP/s - Need \(\geq 6.0 \times 10^5\) tokens/s cluster-wide for 50% MFU: \(6.0 \times 10^5 / 512 \approx 1{,}180\) tokens/s per GPU - A well-tuned 70B run on H100s achieves roughly 850–1,300 tokens/s per GPU, corresponding to MFU of 36–55% against the dense bf16 peak.

A Complete Worked Configuration: 70B Pretraining Run

This section presents a concrete, production-style launch for a 70B dense model using Megatron-LM + DeepSpeed ZeRO-1.

Cluster topology

Cluster: 64 nodes × 8 H100-SXM5-80GB = 512 GPUs
Network: 400 Gb/s InfiniBand NDR (inter-node), 900 GB/s NVLink (intra-node)
Parallelism: TP=8, PP=8, DP=8  →  512 = 8 × 8 × 8

Model config

# model_config.py — Llama-style 70B architecture
MODEL_CONFIG = {
    "num_layers": 80,
    "hidden_size": 8192,
    "ffn_hidden_size": 28672,   # ~3.5x hidden_size for SwiGLU
    "num_attention_heads": 64,
    "num_key_value_heads": 8,   # GQA with 8 KV heads
    "max_position_embeddings": 8192,
    "vocab_size": 128256,       # Llama-3 vocabulary
    "activation_function": "swiglu",
    "normalization": "rmsnorm",
    "tie_embeddings": False,
}

Megatron launch script

#!/bin/bash
# launch_70b.sh — SLURM-based Megatron-LM 70B launch

#SBATCH --nodes=64
#SBATCH --ntasks-per-node=8
#SBATCH --gpus-per-node=8
#SBATCH --cpus-per-task=12
#SBATCH --mem=960G           # enough for ZeRO-Offload CPU tensors if needed

# ---- Parallelism degrees ----
TP=8
PP=8
DP=8  # implicit: 512 / (8*8)

# ---- Batch configuration ----
# Global batch size: ~4M tokens per step
# Seq len 4096, MBS=1 per GPU, GAS=128 → GBS = 1 * 512 * 128 * 4096 = 268M tokens... too large
# More typically: MBS=2, GAS=32 → GBS = 2 * 512 * 32 * 4096 = ~134M tokens/step — still large
# In practice: GBS set to 1M tokens = 244 sequences of 4096 tokens
# With DP=8, MBS=1, GAS=ceil(244/8/GAS_factor): tune per run
SEQ_LEN=4096
GLOBAL_BATCH_SIZE=2048      # sequences per step = 2048 × 4096 = 8.4M tokens
MICRO_BATCH_SIZE=2
GAS=$((GLOBAL_BATCH_SIZE / (DP * MICRO_BATCH_SIZE)))  # = 128

# ---- Training config ----
TRAIN_ITERS=500000
LR=3e-4
MIN_LR=3e-5
LR_WARMUP_ITERS=2000
LR_DECAY_STYLE=cosine
CLIP_GRAD=1.0
WEIGHT_DECAY=0.1

# ---- Paths ----
DATA_PATH=/mnt/storage/tokenized/llama3_merged
CHECKPOINT_PATH=/mnt/checkpoints/70b-run

torchrun \
  --nnodes=$SLURM_NNODES \
  --nproc_per_node=8 \
  --rdzv_backend=c10d \
  --rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT \
  pretrain_gpt.py \
  --tensor-model-parallel-size $TP \
  --pipeline-model-parallel-size $PP \
  --num-layers 80 \
  --hidden-size 8192 \
  --ffn-hidden-size 28672 \
  --num-attention-heads 64 \
  --group-query-attention \
  --num-query-groups 8 \
  --seq-length $SEQ_LEN \
  --max-position-embeddings $SEQ_LEN \
  --micro-batch-size $MICRO_BATCH_SIZE \
  --global-batch-size $GLOBAL_BATCH_SIZE \
  --train-iters $TRAIN_ITERS \
  --lr $LR \
  --min-lr $MIN_LR \
  --lr-warmup-iters $LR_WARMUP_ITERS \
  --lr-decay-style $LR_DECAY_STYLE \
  --weight-decay $WEIGHT_DECAY \
  --clip-grad $CLIP_GRAD \
  --bf16 \
  --use-flash-attn \
  --recompute-activations \
  --recompute-granularity selective \
  --use-distributed-optimizer \
  --overlap-grad-reduce \
  --overlap-param-gather \
  --use-rope-scaling \
  --normalization RMSNorm \
  --swiglu \
  --tokenizer-type TikTokenizer \
  --data-path $DATA_PATH \
  --save $CHECKPOINT_PATH \
  --load $CHECKPOINT_PATH \
  --save-interval 1000 \
  --eval-interval 500 \
  --log-interval 10 \
  --tensorboard-dir $CHECKPOINT_PATH/tb \
  --wandb-project llm-stack-70b

The four performance flags are the ones worth memorizing. --recompute-activations selects the selective recomputation policy of Korthikanti et al. (attention softmax/dropout only); --use-distributed-optimizer turns on Megatron’s own optimizer-state sharding across the DP group — that is ZeRO-1, implemented natively inside Megatron-Core; --overlap-grad-reduce hides the DP reduce-scatter behind the backward pass; and --overlap-param-gather hides the distributed optimizer’s parameter all-gather behind the forward pass. Add --tp-comm-overlap (which requires Transformer Engine’s userbuffers) to additionally overlap the TP all-gather/reduce-scatter with the GEMMs they bracket.

Do you still need DeepSpeed?

Increasingly, no. The historical Megatron-DeepSpeed fork existed because Megatron had no optimizer sharding of its own, so DeepSpeed supplied ZeRO. Modern Megatron-Core ships --use-distributed-optimizer (ZeRO-1) plus --overlap-param-gather, and can shard further with FSDP-style options, so a pure Megatron-Core run needs no DeepSpeed at all. DeepSpeed remains the right choice when you want ZeRO-⅔ semantics, CPU/NVMe offload, ZeRO++ quantized collectives, or DeepSpeed-MoE — or when you are driving training from HuggingFace Trainer/accelerate, which speak DeepSpeed configs natively. The JSON below is shown for the DeepSpeed-driven variant of the same run; you would use it instead of --use-distributed-optimizer, not alongside it.

DeepSpeed config for this run

{
  "zero_optimization": {
    "stage": 1,
    "overlap_comm": true,
    "allgather_partitions": true,
    "reduce_scatter": true,
    "allgather_bucket_size": 500000000,
    "reduce_bucket_size": 500000000
  },
  "bf16": {
    "enabled": true
  },
  "gradient_clipping": 1.0,
  "train_micro_batch_size_per_gpu": 2,
  "gradient_accumulation_steps": 128,
  "steps_per_print": 10,
  "wall_clock_breakdown": false
}

Monitoring the run

# parse_megatron_logs.py — extract MFU from Megatron-LM stdout
import re
import sys

LOG_LINE_RE = re.compile(
    r"iteration\s+(\d+)/\s*\d+.*?elapsed time per iteration \(ms\): ([\d.]+).*?"
    r"tokens-per-second-per-gpu: ([\d.]+)",
    re.DOTALL,
)

H100_BF16_PEAK_TFLOPS = 989.0  # dense bf16 Tensor Core, per GPU (1979 is the 2:4-sparse rate)
MODEL_PARAMS = 70e9

def toks_per_sec_to_mfu(tps_per_gpu: float) -> float:
    flops_per_tok = 6 * MODEL_PARAMS
    achieved_tflops = flops_per_tok * tps_per_gpu / 1e12
    return achieved_tflops / H100_BF16_PEAK_TFLOPS

for line in sys.stdin:
    m = LOG_LINE_RE.search(line)
    if m:
        iteration = int(m.group(1))
        ms_per_iter = float(m.group(2))
        tps = float(m.group(3))
        mfu = toks_per_sec_to_mfu(tps)
        print(f"iter {iteration:6d} | {ms_per_iter:6.0f} ms/it | {tps:5.0f} tok/s/gpu | MFU {mfu:.1%}")

Practical Pitfalls and Tuning Knobs

The TP Communication Bottleneck

Tensor parallelism sits on the critical path of the forward pass. If TP all-reduces are slow (e.g., because TP spans InfiniBand instead of NVLink), you can lose 20-40% of throughput. Always profile with nsys profile and check that ncclAllReduce calls within a TP group run at NVLink speed (≈ 600 GB/s aggregate bidirectional).

Pipeline Bubble vs. Memory Tradeoff

Increasing PP reduces per-GPU memory but increases the bubble fraction. For PP=8 and \(m=32\) micro-batches, the bubble is \((8-1)/(8-1+32) \approx 18\%\). Doubling micro-batches (increasing global batch or reducing MBS) drops this to 9%. Interleaved schedules (virtual PP) halve it again but increase inter-stage communication by a factor of \(v\).

Activation Recomputation Granularity

Megatron-LM offers three granularity levels:

Mode What is recomputed Memory Extra FLOPs
full Entire layer Minimum +33%
selective Attention softmax + dropout Medium +5-15%
none Nothing Maximum +0%

For large models, selective is the sweet spot — it eliminates the expensive-to-store softmax activations (which grow as \(O(S^2)\) in sequence length) while retaining the cheaper MLP activations.

Gradient accumulation and ZeRO-3 interaction

When combining ZeRO-3 with gradient accumulation, each micro-batch forward pass triggers a parameter all-gather. With GAS=128, you do 128 all-gathers per optimizer step. Use --overlap-param-gather to pipeline these with compute, and set stage3_max_live_parameters large enough to buffer at least one full transformer block’s parameters, otherwise you stall.

Choosing Between Megatron and FSDP

PyTorch’s Fully Sharded Data Parallel (Distributed Training I) covers a similar use case to ZeRO-3. Rule of thumb:

  • Megatron-Core (+ optionally DeepSpeed) for runs on dedicated clusters with real TP/PP requirements (>30B parameters), and wherever Transformer Engine’s FP8 and TP-comm-overlap kernels matter.
  • FSDP2 for runs up to ~30B parameters where single-framework PyTorch is preferred and TP is unnecessary.
  • FSDP2 + TP/PP/CP via DTensor is the PyTorch-native path for very large models without a Megatron dependency.

You rarely wire that last option by hand. The reference implementation is pytorch/torchtitan, PyTorch’s own pretraining platform, which composes FSDP2, DTensor tensor parallelism, pipeline parallelism (torch.distributed.pipelining), context parallelism, torch.compile, Float8 training via torchao, and Distributed Checkpoint behind a single TOML config — the PyTorch-native answer to Megatron-LM. Its ND-parallel mesh is built with init_device_mesh, the same primitive you would use yourself:

from torch.distributed.device_mesh import init_device_mesh

# 512 GPUs as DP=8 x PP=8 x TP=8 — the direct analogue of Megatron's
# parallel_state.initialize_model_parallel(), but as a first-class PyTorch object.
mesh = init_device_mesh("cuda", (8, 8, 8), mesh_dim_names=("dp", "pp", "tp"))
tp_group = mesh["tp"].get_group()      # what get_tensor_model_parallel_group() returns
dp_mesh = mesh["dp"]                   # pass straight to fully_shard(module, mesh=dp_mesh)

The other stacks you will meet in the wild: NVIDIA NeMo (a full framework built on Megatron-Core, adding data/recipe management and multimodal), HuggingFace nanotron (a compact, readable 3D-parallel pretrainer), allenai/OLMo-core (the fully open OLMo training stack), and HuggingFace accelerate, which is not a parallelism engine itself but a launcher that plugs a Trainer-style loop into DeepSpeed or FSDP configs.

For mixed-precision choices and the role of bf16 vs fp8 in these runs, see Mixed Precision, bf16 & FP8 Training.

Profiling before tuning

Before changing any parallelism config, run NVIDIA Nsight Systems for one iteration:

nsys profile \
    --trace cuda,nvtx \
    --output profile_iter \
    --capture-range cudaProfilerApi \
    python pretrain_gpt.py --profile-step 5 ...

Look for: (1) long NCCL gaps (communication bottleneck), (2) back-to-back small kernels (micro-batch too small, memory-bound), (3) idle GPU time between pipeline stages (bubble). Each symptom has a distinct fix.

Hyperparameter Sensitivity and Scaling the Config

Not all hyperparameters are scale-invariant. When you double the cluster and increase GBS, you typically need to:

  1. Scale the learning rate with the square root of GBS (linear scaling works empirically up to a point, but for very large batches \(\sqrt{\text{GBS}}\) scaling is safer). See Learning Rate Schedules, Warmup, Batch Size & Hyperparameters.
  2. Extend warmup proportionally to GBS — a common heuristic is to warm up over 1–2B tokens regardless of batch size.
  3. Reduce gradient clipping threshold as model depth grows to avoid spurious gradient explosions (see Training Stability, Loss Spikes & Debugging Large Runs).

Virtual Pipeline Parallelism

Megatron’s interleaved pipeline (enabled with --num-layers-per-virtual-pipeline-stage) assigns each GPU multiple non-contiguous chunks of layers:

Panel A — Without interleaving (v=1) GPU k owns layers 2k and 2k+1 | one contiguous block per GPU GPU 0 GPU 1 GPU 2 GPU 3 L0 L1 L2 L3 L4 L5 L6 L7 each GPU owns one contiguous block — layers flow left-to-right in one pass bubble fraction ~ (PP-1) / (PP-1 + m) = 3 / (3 + m) Panel B — With interleaving (v=2 chunks per GPU) GPU k owns layers k and k+4 | two non-contiguous virtual stages GPU 0 GPU 1 GPU 2 GPU 3 L0 L1 L2 L3 L4 L5 L6 L7 Round 1 (virtual stage 0): L0, L1, L2, L3 — first pass across all GPUs Round 2 (virtual stage 1): L4, L5, L6, L7 — second pass across all GPUs each GPU owns 2 non-contiguous chunks — pipeline makes TWO passes per micro-batch bubble fraction ~ (PP-1) / (PP-1 + v*m) = 3 / (3 + 2m) — approx half of Panel A trade-off: 2x pipeline messages per micro-batch (v=2 sends per stage boundary)
Interleaved (virtual) pipeline parallelism halves the bubble by giving each GPU two non-contiguous layer chunks instead of one contiguous block. In Panel A, reading order L0→L7 sweeps left-to-right once; in Panel B the pipeline makes two full sweeps (round 1: L0–L3, round 2: L4–L7 in accent), reducing the bubble fraction from approximately (PP-1)/(PP-1+m) to (PP-1)/(PP-1+v·m), at the cost of twice as many inter-stage messages per micro-batch.

This halves the bubble at the cost of sending twice as many pipeline messages per micro-batch. In practice, for TP=8 where the pipeline messages are relatively small (one micro-batch of activations), interleaving is almost always worth it above PP=4.

Interview Corner

Q: You are given a 256-GPU cluster (8 GPUs/node, NVLink intra-node, InfiniBand inter-node) and asked to train a 70B dense model. Walk through how you would choose TP, PP, and DP, and justify each choice.

A: Start with TP=8 — one full node — because all tensor-parallel all-reduces then stay on NVLink (fast) and never touch InfiniBand (slow). With TP=8 and 8 nodes remaining in the config, we choose PP=4 which gives 8/8=1 set of 4-stage pipelines per TP group and leaves DP=256/(8×4)=8. Verify memory: 70B params × 16 bytes / (8×4 TP×PP sharding) ≈ 35 GB model state per GPU, plus ~5-10 GB activations with selective recompute → comfortably fits 80 GB. For MFU, PP=4 with interleaved schedule (v=2) and 32+ micro-batches gives a bubble below 10%. If we needed more DP, we would scale the cluster rather than reducing TP/PP. If DP gradient communication shows up as exposed time in the profile, note that moving ZeRO-1 → ZeRO-2 would not help — both move the same reduce-scatter + all-gather volume, ZeRO-2 only saves gradient memory. The real levers are overlap (--overlap-grad-reduce, --overlap-param-gather), larger reduce buckets, and, if the DP group spans many nodes, ZeRO++-style hierarchical or quantized collectives.

Combining Megatron-Core with External Libraries

Megatron-Core is designed to be embedded. The typical NeMo or Databricks Mosaic setup looks like:

# Pattern: Megatron-Core layer inside a custom training loop
from megatron.core import parallel_state
from megatron.core.transformer.transformer_config import TransformerConfig
from megatron.core.models.gpt.gpt_model import GPTModel
# Layer "specs" say which implementation backs each sublayer. The Transformer
# Engine spec uses TE's fused kernels (and is required for FP8); there is also a
# pure-PyTorch `get_gpt_layer_local_spec()` for debugging without TE installed.
from megatron.core.models.gpt.gpt_layer_specs import (
    get_gpt_layer_with_transformer_engine_spec,
)

# 1. Initialize distributed environment
import torch.distributed as dist
dist.init_process_group(backend="nccl")

# 2. Configure the model parallel topology
parallel_state.initialize_model_parallel(
    tensor_model_parallel_size=8,
    pipeline_model_parallel_size=4,
)

# 3. Build the model using TransformerConfig
config = TransformerConfig(
    num_layers=80,
    hidden_size=8192,
    num_attention_heads=64,
    num_query_groups=8,         # GQA
    ffn_hidden_size=28672,
    use_cpu_initialization=False,
    bf16=True,
    tensor_model_parallel_size=8,
    pipeline_model_parallel_size=4,
)

model = GPTModel(
    config=config,
    transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec(),
    vocab_size=128256,
    max_sequence_length=8192,
    position_embedding_type="rope",
)

# 4. Optimizer-state sharding. Either Megatron's own distributed optimizer
#    (config.use_distributed_optimizer = True, ZeRO-1) or DeepSpeed — not both.
import deepspeed
model_engine, _, _, _ = deepspeed.initialize(model=model, config="ds_config_zero1.json")

This pattern — Megatron-Core for the TP/PP topology, DeepSpeed for the optimizer-side ZeRO sharding — is sometimes called 3D + ZeRO and remains the dominant approach for frontier model training runs in 2026, now increasingly running on NVIDIA Blackwell (B200 / GB200 NVL72) systems alongside the large H100/H200 fleets of the previous generation.

For inference serving after training, the parallelism story shifts toward pure TP (no PP, since autoregressive decode cannot pipeline) and often requires weight resharding from the training checkpoint format. See Multi-GPU & Multi-Node Inference for the inference-side parallelism story.

State of the Art & Resources (2026)

As of 2026, the 3D + ZeRO pattern (Megatron-Core for tensor/pipeline parallelism plus optimizer-state sharding, whether from Megatron’s own distributed optimizer or from DeepSpeed) remains the dominant approach for frontier pretraining runs, with context parallelism (CP) and expert parallelism (EP) expanding the space to 4D or 5D for long-sequence and MoE models. The credible PyTorch-native alternative is torchtitan’s FSDP2 + DTensor composition, which now covers the same axes without a Megatron dependency. Production clusters regularly achieve 40–55% MFU on H100/H200 hardware, and increasingly on NVIDIA Blackwell (B200 / GB200 NVL72) systems — now the frontier training platform — using these frameworks.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • NVIDIA/Megatron-LM — the reference implementation of Megatron-Core; includes TP, PP, CP, EP, and the distributed optimizer; actively maintained with H100/Blackwell support.
  • deepspeedai/DeepSpeed — ZeRO stages 1–3, ZeRO-Infinity, ZeRO++, and DeepSpeed-MoE; integrates directly with Megatron-Core or HuggingFace Trainer.
  • pytorch/torchtitan — PyTorch’s own reference pretraining platform: FSDP2 + DTensor TP + PP + CP composed over a DeviceMesh, with torch.compile, torchao Float8, and Distributed Checkpoint, all config-driven. The PyTorch-native alternative to the Megatron/DeepSpeed pairing.
  • huggingface/nanotron and allenai/OLMo-core — compact, readable 3D-parallel pretrainers; good sources to read end to end when Megatron-Core’s abstraction layers are more than you need.

Go deeper

Further Reading

  • Shoeybi, Patwary, et al. “Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism” (2019). The original Megatron paper introducing TP for transformers.
  • Narayanan, Shoeybi, et al. “Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM” (NeurIPS 2021). Introduces the 3-D parallelism framework and the 1F1B pipeline schedule.
  • Korthikanti, Casper, et al. “Reducing Activation Recomputation in Large Transformer Models” (MLSys 2023). Introduces sequence parallelism and selective activation recomputation.
  • Rajbhandari, Rasley, Ruwase, He. “ZeRO: Memory Optimizations Toward Training Trillion Parameter Models” (SC 2020). The ZeRO paper.
  • Rajbhandari, et al. “ZeRO-Infinity: Breaking the GPU Memory Wall for Extreme Scale Deep Learning” (SC 2021). ZeRO-Offload and NVMe offloading.
  • Ren, et al. “ZeRO-Offload: Democratizing Billion-Scale Model Training” (USENIX ATC 2021).
  • NVIDIA Megatron-Core GitHub: NVIDIA/Megatron-LM — the canonical reference implementation.
  • DeepSpeed GitHub: deepspeedai/DeepSpeed (formerly microsoft/DeepSpeed) — ZeRO implementation and tutorials.
  • PyTorch torchtitan GitHub: pytorch/torchtitan — the PyTorch-native ND-parallel pretraining reference stack.
  • Chowdhery, et al. “PaLM: Scaling Language Modeling with Pathways” (2022). Describes the 4D parallelism and MFU analysis methodology used at Google.

Key Takeaways

  • Megatron-LM organizes GPUs into a 3-D grid: TP (intra-node, NVLink), PP (inter-node, point-to-point), DP (outermost). The formula is \(N = \text{DP} \times \text{TP} \times \text{PP}\).
  • Sequence parallelism in Megatron-Core shards activations along the sequence dimension outside TP-parallel regions, replacing one all-reduce with all-gather + reduce-scatter and cutting activation memory by a factor of TP.
  • DeepSpeed ZeRO has three stages: ZeRO-1 shards optimizer states, ZeRO-2 adds gradients, ZeRO-3 adds parameters. Combined memory reduction with DP=64 and ZeRO-3 can exceed 60×. ZeRO-Offload and ZeRO-Infinity extend sharding to CPU/NVMe.
  • For a 70B run, a representative production config is TP=8, PP=8, DP=8 on 512 H100-80GB GPUs with ZeRO-1 optimizer sharding and selective activation recomputation.
  • MFU measures what fraction of peak cluster FLOP/s is consumed by model arithmetic. Use \(\text{FLOPs/token} \approx 6P\) for a quick estimate. Target 40–55% MFU for dense models; values below 25% indicate a misconfiguration.
  • Pipeline bubble fraction \(\approx (PP-1)/(PP-1+m)\). Keep it below 5–10% by increasing micro-batch count \(m\) or using interleaved (virtual PP) schedules.
  • Always profile before tuning. Nsight Systems traces quickly reveal whether the bottleneck is NCCL communication, pipeline bubbles, or kernel-launch overhead.
  • The 3D + ZeRO pattern is the dominant approach for frontier pretraining runs, though modern Megatron-Core’s own --use-distributed-optimizer is ZeRO-1 and often removes the need for DeepSpeed; pytorch/torchtitan (FSDP2 + DTensor TP/PP/CP over a DeviceMesh) is the PyTorch-native alternative.
  • Expert parallelism (EP) adds a fourth dimension for MoE models, communicating token activations (not parameters) via all-to-all; context parallelism (CP) adds a fifth for long sequences, sharding the sequence itself with a ring K/V exchange.
  • Every degree above 1 must be forced by memory or bandwidth. At ~100M parameters the funnel returns TP=PP=CP=EP=1 and DP-only — plain DDP or FSDP2, no Megatron or DeepSpeed required.

Exercises

1. The chapter’s rule of thumb is to place TP within a node and use PP to span nodes. Explain the hardware reason for each half of this rule. What specifically goes wrong if you set TP=16 on a cluster of 8-GPU nodes connected by InfiniBand?

Solution

The rule follows from where each collective sits relative to the critical path and how much bandwidth it needs.

  • TP within a node. A tensor-parallel column/row GEMM pair requires an all-reduce (or, with sequence parallelism, an all-gather + reduce-scatter) that must complete before the next GEMM can start. It sits directly on the forward/backward critical path, so its latency is not hideable and its bandwidth demand is high (activations, every layer, every micro-batch). Intra-node NVLink offers ~900 GB/s, roughly an order of magnitude more than inter-node InfiniBand (400 Gb/s ~= 50 GB/s per the chapter’s cluster). Keeping TP on NVLink keeps this critical-path collective fast.

  • PP spans nodes. Pipeline parallelism only sends point-to-point activation tensors between adjacent stages (one micro-batch’s activations at a stage boundary). These sends are latency-tolerant — they can be overlapped with compute of other micro-batches in the 1F1B schedule — and they are far less frequent than TP collectives. So PP tolerates the slower inter-node InfiniBand link.

  • TP=16 on 8-GPU nodes. A TP group of 16 must include GPUs on two different nodes, so the TP all-reduce now crosses InfiniBand instead of staying on NVLink. Because that all-reduce is on the critical path and runs every layer, the ~10x slower inter-node link stalls every GEMM. The chapter notes this can cost 20-40% of throughput. The fix is to keep TP <= GPUs-per-node (here TP=8) and push the extra parallelism into PP or DP.

2. A model has \(P = 13 \times 10^9\) parameters and is trained with Adam in mixed precision (bf16 params + bf16 grads + fp32 master + fp32 Adam moments), i.e. the 16-bytes-per-parameter accounting from Step 1. Using the ZeRO-3 formula \(\text{Memory per GPU} = 16P/\text{DP}\) (model state only, ignoring activations), what is the minimum DP degree needed to fit the model state within 40 GB per GPU? What is the per-GPU model state at that DP degree?

Solution

Total model state is

\[ 16P = 16 \times 13 \times 10^9 = 208 \times 10^9 \text{ bytes} = 208 \text{ GB}. \]

We need \(16P/\text{DP} \le 40\) GB:

\[ \text{DP} \ge \frac{208}{40} = 5.2. \]

DP must be an integer (and in practice a power of two for clean group layout), so the minimum integer is \(\text{DP} = 6\), and the smallest power-of-two that works is \(\text{DP} = 8\).

  • At \(\text{DP} = 6\): \(208 / 6 \approx 34.7\) GB per GPU.
  • At \(\text{DP} = 8\): \(208 / 8 = 26\) GB per GPU.

Either fits the 40 GB budget; DP=6 is the strict minimum, DP=8 is the practical choice. (This is model state only — real deployments still need headroom for activations and buffers.)

3. You launch a run with pipeline degree \(PP = 8\) and \(m = 24\) micro-batches per optimizer step, using the plain 1F1B schedule. (a) Compute the bubble fraction. (b) Your target is a bubble under 5%. Using the chapter’s rule \(m \ge 19(PP-1)\), how many micro-batches would the plain schedule need? © Instead you enable an interleaved (virtual pipeline) schedule with \(v = 4\) chunks per stage, keeping \(m = 24\). Estimate the new bubble fraction.

Solution

(a) Plain 1F1B bubble fraction:

\[ \frac{PP - 1}{PP - 1 + m} = \frac{8 - 1}{8 - 1 + 24} = \frac{7}{31} \approx 0.226 = 22.6\%. \]

(b) The rule \(m \ge 19(PP - 1)\) gives

\[ m \ge 19 \times 7 = 133 \text{ micro-batches}, \]

which is a very large batch requirement — this is exactly why interleaving is attractive at high PP.

© Interleaved schedule with \(v = 4\):

\[ \frac{1}{v} \cdot \frac{PP - 1}{PP - 1 + m} = \frac{1}{4} \times \frac{7}{31} = \frac{7}{124} \approx 0.056 = 5.6\%. \]

So with only \(m = 24\) micro-batches, virtual PP with \(v = 4\) brings the bubble from ~22.6% down to ~5.6% — close to the 5% target — at the cost of ~4x more inter-stage pipeline messages per micro-batch.

4. A 30B dense model is trained on 256 H100 GPUs. The observed throughput is 900 tokens/second/GPU. Using the simplified \(6P\) rule and the chapter’s dense bf16 H100 peak of \(9.89 \times 10^{14}\) FLOP/s per GPU, compute the MFU. Is this in the healthy range the chapter cites? Then, if the run uses full activation recomputation, estimate the corresponding HFU.

Solution

MFU. FLOPs per token (simplified rule):

\[ 6P = 6 \times 30 \times 10^9 = 1.8 \times 10^{11} \text{ FLOP/token}. \]

Achieved FLOP/s per GPU:

\[ 1.8 \times 10^{11} \times 900 = 1.62 \times 10^{14} \text{ FLOP/s}. \]

MFU is achieved over peak (per-GPU peak works because both numerator and denominator scale by the same GPU count):

\[ \text{MFU} = \frac{1.62 \times 10^{14}}{9.89 \times 10^{14}} \approx 0.164 = 16.4\%. \]

This is below the healthy 35-55% range and even below the 25% “likely misconfiguration” threshold the chapter flags — a signal to profile for TP crossing node boundaries, too-small micro-batches, excessive bubble, or checkpointing overhead.

HFU with full recompute. Full recomputation issues an extra forward pass, so the FLOPs actually issued to the GPU are \(8P\) instead of \(6P\) per token — a factor of \(8/6 = 4/3\) more work:

\[ \text{HFU} = \text{MFU} \times \frac{\text{total FLOPs issued}}{\text{model FLOPs}} = 0.164 \times \frac{4}{3} \approx 0.219 = 21.9\%. \]

HFU >= MFU always, as expected. The gap here reflects the wasted arithmetic of recomputation; even so, both numbers are low, confirming a configuration problem rather than a recompute-accounting artifact.

5. Extend the chapter’s compute_mfu function into a compute_hfu helper that accepts a recompute_mode argument ("none", "selective", or "full") and returns the HFU. Use the chapter’s overhead figures: "none" adds 0%, "selective" adds ~10% (a representative value from the chapter’s 5-15% range), and "full" multiplies model FLOPs by \(4/3\) (the \(8P\) vs \(6P\) ratio). Keep the style consistent with the chapter’s code.

Solution

We reuse compute_mfu to get the model-FLOP MFU, then scale by the ratio of issued FLOPs to model FLOPs implied by the recompute mode.

def compute_hfu(
    model_params: int,
    tokens_per_second: float,
    peak_flops_per_sec: float,
    recompute_mode: str = "none",   # "none" | "selective" | "full"
    n_layers: int = None,
    d_model: int = None,
    seq_len: int = None,
) -> float:
    """
    Compute Hardware FLOP Utilization.

    HFU = MFU * (total FLOPs issued / model FLOPs), where the multiplier
    depends on how much activation recomputation is enabled:
      - "none":      +0%   -> factor 1.00 (HFU == MFU)
      - "selective": ~+10% -> factor 1.10 (recompute attention softmax/dropout)
      - "full":      +33%  -> factor 4/3  (an extra full forward pass, 8P vs 6P)
    """
    mfu = compute_mfu(
        model_params=model_params,
        tokens_per_second=tokens_per_second,
        peak_flops_per_sec=peak_flops_per_sec,
        n_layers=n_layers,
        d_model=d_model,
        seq_len=seq_len,
    )

    recompute_factor = {
        "none": 1.0,
        "selective": 1.10,
        "full": 4.0 / 3.0,
    }
    if recompute_mode not in recompute_factor:
        raise ValueError(f"unknown recompute_mode: {recompute_mode!r}")

    return mfu * recompute_factor[recompute_mode]


# Example: 30B model, 256 H100s, 900 tok/s/gpu, full recompute
H100_BF16_TFLOPS = 9.89e14
hfu = compute_hfu(
    model_params=30e9,
    tokens_per_second=900,          # per-GPU rate vs per-GPU peak
    peak_flops_per_sec=H100_BF16_TFLOPS,
    recompute_mode="full",
)
print(f"HFU: {hfu:.1%}")  # ~21.8% (Exercise 4's 21.9% rounds MFU to 0.164 first)

The helper is intentionally a thin wrapper: MFU already captures the useful model arithmetic, and HFU only differs by the fixed recompute overhead of the chosen mode. Note that MFU is invariant to the recompute mode (the model does the same useful work either way); only HFU rises because more FLOPs are physically issued.

6. In initialize_model_parallel (the parallel_state code), consider a world of 8 GPUs configured with tensor_model_parallel_size=2 and pipeline_model_parallel_size=2. (a) Work out dp_size. (b) List the exact rank membership of every TP group, PP group, and DP group produced by the three loops. © A colleague proposes calling get_data_parallel_group() to target the collective for a tensor-parallel all-reduce inside a column-parallel GEMM. Explain why that is wrong and which accessor is correct.

Solution

(a) dp_size = world_size // (TP * PP) = 8 // (2 * 2) = 2.

(b) Trace each loop with world_size = 8, TP = 2, PP = 2, dp_size = 2.

TP groups — loop i in range(PP * dp_size) = range(4), each group is [i*TP, (i+1)*TP):

i=0 -> ranks [0, 1]
i=1 -> ranks [2, 3]
i=2 -> ranks [4, 5]
i=3 -> ranks [6, 7]

PP groups — loop i in range(TP * dp_size) = range(4), ranks range(i, 8, TP)[:PP] = stride 2, take 2:

i=0 -> range(0,8,2)[:2] = [0, 2]
i=1 -> range(1,8,2)[:2] = [1, 3]
i=2 -> range(2,8,2)[:2] = [2, 4]
i=3 -> range(3,8,2)[:2] = [3, 5]

(Note: with this simplified stride-by-TP construction the PP groups are not disjoint — e.g. rank 2 appears in the i=0 and i=2 groups. The real Megatron code partitions more carefully; the chapter’s version is labeled “simplified” and is meant to convey the striding idea, not exact production group assignment.)

DP groups — loop i in range(TP * PP) = range(4), ranks range(i, 8, TP*PP) = stride 4:

i=0 -> [0, 4]
i=1 -> [1, 5]
i=2 -> [2, 6]
i=3 -> [3, 7]

© A tensor-parallel column/row GEMM all-reduce must sum partial results across the GPUs that hold shards of the same layer’s weights — that is, the members of a TP group (e.g. [0, 1]). The data-parallel group instead links GPUs that hold complete, independent replicas processing different data; reducing across it would incorrectly mix distinct micro-batch replicas and would not combine the weight shards at all. The correct accessor is get_tensor_model_parallel_group(), which every parallelism-aware layer calls precisely so this routing never leaks into user code.