Deep dive
How it works
In: layer input X (replicated or sequence-sharded) and per-rank weight shards in a group of t GPUs · Out: the layer output, identical to the unsharded layer · State: each rank's 1/t of the weights (and of the optimizer state) · Cost: FLOPs and weights ÷ t; 4 all-reduces of b·s·h elements per transformer layer per step
Megatron's two conjugate operators (t ranks in a TP group):
- f: identity in forward, all-reduce of the gradient in backward. Placed before a column-parallel layer.
- g: all-reduce in forward, identity in backward. Placed after a row-parallel layer.
MLP: X ─f─► [X·A_i] ─► GeLU ─► [·B_i] ─g─► Y A = [A_1 … A_t] by columns
(b·s·4h/t) partial Y B = [B_1; …; B_t] by rows
Attention: X ─f─► Q_i,K_i,V_i = X·W_i (heads h/t per rank) ─► attn ─► ·O_i ─g─► Y
Embedding: vocab-parallel: each rank holds V/t rows; mask out-of-range ids, all-reduce
Loss: vocab-parallel cross-entropy: all-reduce the row max and the sum-exp, never gather logits
Sequence parallelism (Korthikanti et al.): between blocks, activations are sharded along the sequence dimension. g becomes a reduce-scatter (each rank gets s/t tokens of Y), LayerNorm and dropout run on the shard, and f becomes an all-gather. Communication is unchanged; activation memory for those regions drops by t.
Code
A Megatron MLP split over t ranks on plain slices. A column-parallel first GEMM and a row-parallel second GEMM need exactly one all-reduce forward ("g") and one backward ("f"). The code checks this against the unsharded layer.
fn matmul(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> { // (m×k)(k×n)
let mut c = vec![0.0f32; m * n];
for i in 0..m { for p in 0..k { let x = a[i * k + p]; for j in 0..n { c[i * n + j] += x * b[p * n + j]; } } }
c
}
fn transpose(a: &[f32], r: usize, c: usize) -> Vec<f32> {
let mut t = vec![0.0f32; r * c];
for i in 0..r { for j in 0..c { t[j * r + i] = a[i * c + j]; } }
t
}
fn all_reduce(parts: &[Vec<f32>]) -> Vec<f32> { // NCCL all-reduce stand-in
(0..parts[0].len()).map(|i| parts.iter().map(|p| p[i]).sum()).collect()
}
fn relu(x: &[f32]) -> Vec<f32> { x.iter().map(|v| v.max(0.0)).collect() }
fn relu_bwd(g: &[f32], pre: &[f32]) -> Vec<f32> { g.iter().zip(pre).map(|(g, z)| if *z > 0.0 { *g } else { 0.0 }).collect() }
fn close(a: &[f32], b: &[f32]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3 * (1.0 + y.abs())) }
/// Columns [i·w, (i+1)·w) of a row-major (rows×cols) matrix.
fn col_block(a: &[f32], rows: usize, cols: usize, w: usize, i: usize) -> Vec<f32> {
(0..rows).flat_map(|r| a[r * cols + i * w..r * cols + (i + 1) * w].iter().copied()).collect()
}
fn main() {
let (m, d, f, t) = (8, 32, 128, 4); // tokens, hidden, ffn width, TP degree
let rnd = |n: usize, s: usize| -> Vec<f32> { (0..n).map(|i| ((i * 2654435761 + s) % 1000) as f32 / 1000.0 - 0.5).collect() };
let (x, a, b) = (rnd(m * d, 1), rnd(d * f, 2), rnd(f * d, 3)); // Y = relu(X·A)·B
let fs = f / t;
let a_sh: Vec<Vec<f32>> = (0..t).map(|i| col_block(&a, d, f, fs, i)).collect(); // A by columns
let b_sh: Vec<Vec<f32>> = (0..t).map(|i| b[i * fs * d..(i + 1) * fs * d].to_vec()).collect(); // B by rows
// Forward. X is replicated; hidden activations stay sharded (m × f/t per rank).
let pre: Vec<Vec<f32>> = a_sh.iter().map(|ai| matmul(&x, ai, m, d, fs)).collect();
let partial: Vec<Vec<f32>> = (0..t).map(|i| matmul(&relu(&pre[i]), &b_sh[i], m, fs, d)).collect();
let y = all_reduce(&partial); // "g": the only forward communication
let pre_full = matmul(&x, &a, m, d, f);
assert!(close(&y, &matmul(&relu(&pre_full), &b, m, f, d)));
// Backward for loss ½‖Y‖², so dY = Y (identical on every rank after the all-reduce).
let dy = y.clone();
let mut dx_parts = vec![];
for i in 0..t {
let dh = relu_bwd(&matmul(&dy, &transpose(&b_sh[i], fs, d), m, d, fs), &pre[i]);
let da_i = matmul(&transpose(&x, m, d), &dh, d, m, fs); // weight grads: local, no comm
let dh_full = relu_bwd(&matmul(&dy, &transpose(&b, f, d), m, d, f), &pre_full);
assert!(close(&da_i, &col_block(&matmul(&transpose(&x, m, d), &dh_full, d, m, f), d, f, fs, i)));
dx_parts.push(matmul(&dh, &transpose(&a_sh[i], d, fs), m, fs, d));
}
let dx = all_reduce(&dx_parts); // "f": the only backward communication
let dh_full = relu_bwd(&matmul(&dy, &transpose(&b, f, d), m, d, f), &pre_full);
assert!(close(&dx, &matmul(&dh_full, &transpose(&a, d, f), m, f, d)));
println!("TP={t} MLP matches the unsharded layer: 1 all-reduce forward, 1 backward");
}
In PyTorch the same plan is parallelize_module(mlp, tp_mesh, {"fc1": ColwiseParallel(), "fc2": RowwiseParallel()}) on DTensors. Megatron implements f and g as autograd functions around all_reduce.
Complexity and constants
- Forward FLOPs per layer ≈ 24·b·s·h² + 4·b·s²·h, split t-ways. Communication per layer per step: 4 all-reduces of 2·b·s·h bytes (bf16).
- Example: b = 1, s = 4096, h = 8192, t = 8. Each all-reduce is 67 MB, about 0.3 ms at about 400 GB/s busbw, so about 1.2 ms per layer per step. Compute: 3 × 24·b·s·h² / t ≈ 2.5 TFLOP per GPU, about 4 ms at about 600 TFLOPS. Exposed communication is about 25% unless overlapped, and roughly 8x worse over InfiniBand.
- Megatron-LM ran 8-way TP within DGX nodes combined with pipeline and data parallelism (PTD-P) at 502 PFLOP/s on 3072 A100s (52% of peak) for a 1T-parameter model.
Efficiency
- Cost: per layer 4 all-reduces of 2·b·s·h bytes (bf16). Example from above: b = 1, s = 4096, h = 8192, t = 8 gives 67 MB each, about 0.3 ms at about 400 GB/s, so about 1.2 ms per layer against about 4 ms of compute: about 25% exposed unless overlapped, order of 8x worse over InfiniBand. Megatron ran 8-way TP inside nodes at 52% of A100 peak on 3072 GPUs (Narayanan et al.). Bound: collective latency and bandwidth on the critical path.
- Levers:
- keep t inside one NVLink domain (8 GPUs) — avoids the roughly 8x slower inter-node all-reduce (above) — always
- sequence parallelism — norm and dropout activations ÷ t at the same communication volume (Korthikanti et al. 2022) — long sequences
- overlap collectives with GEMM chunks (async TP, collective matmul) — hides the exposed communication (Wang et al., ASPLOS 2023) — large layers
- vocab-parallel embedding and loss — never gathers b·s·V logits — large vocabularies
- TP × pipeline × data (PTD-P, 2D/3D meshes) — scale beyond one node (Narayanan et al. 2021) — more than about 8 GPUs
- Naive → standard → state of the art:
- one GPU, layer does not fit · not runnable
- column then row split, 4 blocking all-reduces per layer · about 25% exposed communication in the example
- sequence-parallel TP with overlapped reduce-scatter and all-gather · communication largely hidden
Implementation notes
- Make the hidden size, heads and FFN width divisible by t. Keep h/t a multiple of 64-128 for tensor-core tiles.
- GQA with fewer KV heads than t: replicate KV heads (each rank holds ≥1), or TP would split a head.
- Overlap TP communication with GEMMs: decompose the GEMM into chunks and pipeline reduce-scatter/all-gather chunks against compute (async TP, collective matmul in XLA, Transformer Engine userbuffers).
- Initialize shards consistently. Either slice one full matrix (as above, from a shared seed) or use a per-shard RNG offset, or TP results differ from the unsharded model.
- Use TP with ZeRO/FSDP across data-parallel replicas (a 2D mesh) and pipeline stages across nodes (3D parallelism).
Pitfalls
- Adding the row-parallel bias on every rank before the all-reduce adds it t times.
- Dropout in TP regions needs a per-rank RNG stream for sharded activations and an identical stream for replicated activations (Megatron's
model_parallel_cuda_manual_seed). - Gradient clipping must count sharded parameters once and replicated parameters once, not t times.
- Calling
.contiguous()before collectives is mandatory: NCCL needs dense buffers, and transposed views silently copy or fail. - Inference: TP across PCIe GPUs is often slower than one GPU plus quantization (weight quantization).
Variants and alternatives
In production
Megatron-LM and Megatron-Core, NVIDIA NeMo, DeepSpeed (with Megatron), PyTorch DTensor parallelize_module (TorchTitan), JAX/XLA GSPMD sharding, and the vLLM, SGLang and TensorRT-LLM --tensor-parallel-size inference paths.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Megatron-LM / Megatron Core | Python / CUDA | NVIDIA/Megatron-LM | pip install megatron-core | — | — | — | — | origin of column/row TP and sequence parallelism |
| PyTorch DTensor | Python / C++ | pytorch/pytorch | pip install torch | — | — | — | — | parallelize_module with ColwiseParallel / RowwiseParallel |
| TorchTitan | Python | pytorch/torchtitan | pip install torchtitan | — | — | — | — | TP, PP and FSDP on one device mesh |
| vLLM | Python / C++ / CUDA | vllm-project/vllm | pip install vllm | — | — | — | — | --tensor-parallel-size for inference |
| DeepSpeed | Python / CUDA | deepspeedai/DeepSpeed | pip install deepspeed | — | — | — | — | tensor parallelism for training and inference |
Sources
- Shoeybi et al., Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism, 2019. paper · code
- Narayanan et al., Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM, SC 2021. paper
- Korthikanti et al., Reducing Activation Recomputation in Large Transformer Models, 2022. paper
- Xu et al., GSPMD: General and Scalable Parallelization for ML Computation Graphs, 2021. paper
- Wang et al., Overlap Communication with Dependent Computation via Decomposition in Large Deep Learning Models, ASPLOS 2023.