ml-models / Training systems / Distributed training advanced

Distributed training

One GPU can't hold or finish a large model's training run, so the work is split across many. Data parallelism copies the model and splits the batch; sharded data parallelism (ZeRO, FSDP) also splits the weights, gradients and optimizer state; tensor and pipeline parallelism split the model itself. This page works through the ring all-reduce, the memory each scheme needs per GPU, and what each one sends over the wire.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. All-reduce, and the ring that makes it cheap
  5. DDP: buckets that overlap with backward
  6. ZeRO: shard the optimizer state, then gradients, then weights
  7. FSDP in PyTorch
  8. Tensor parallelism: split the matmuls
  9. Pipeline parallelism: split the layers
  10. Worked example
  11. A ring all-reduce over four ranks
  12. Memory per GPU: Llama 2 7B on 8 GPUs
  13. What each step sends
  14. Tensor-parallel MLP: the right split and the wrong one
  15. Pipeline bubble and activation memory
  16. Implementation
  17. Tradeoffs
  18. Connections
  19. Read first
  20. Read next
  21. References

Problem

Training Llama 2 7B (6,738,415,616 parameters) with Adam in mixed precision keeps a bf16 copy of the weights, bf16 gradients, and an fp32 master copy plus two fp32 Adam moments: 2 + 2 + 12 = 16 bytes per parameter, 107.8 GB before a single activation. An 80 GB GPU can't hold it. Even if it could, one GPU doing the trillions of token-steps a pretraining run needs would take years.

So the run is spread over N GPUs, and two questions decide how. First, which of the training state gets replicated on every GPU and which gets split: the batch, the optimizer state, the gradients, the weights, or the layers themselves. Second, what that split costs in communication, because every GPU still has to end each step with the same update the single-GPU program would have made (see the training loop).

Intuition

The gradient of a mean loss is the mean of per-example gradients, so data parallelism gives each of N GPUs a full model copy and 1/N of the batch, then averages the gradients. That's the same gradient as the big batch (the argument gradient accumulation makes for micro-batches in time, made here across space). Every GPU then applies the same update to the same weights, so they stay in sync without ever sending weights.

The waste is that every GPU stores the same optimizer state, gradients and weights. Sharded data parallelism gives each GPU 1/N of them. A GPU only needs a layer's full weights while it computes that layer, so it all-gathers them just before, then frees them. Tensor parallelism splits each weight matrix so a single layer's matmul runs on several GPUs, and pipeline parallelism gives each GPU a contiguous slice of the layers and streams micro-batches through them like an assembly line. Large runs combine all of them: tensor parallel inside a node where links are fastest, pipeline across nodes, and data parallel over the rest.

What each kind of parallelism splits across two GPUs Four panels for two GPUs. Data parallel: each GPU holds the whole model and half of the batch. Sharded data parallel: each GPU holds half of every layer's parameters, gradients and optimizer state, and half of the batch. Tensor parallel: each GPU holds half of the columns or rows of every weight matrix and the whole batch. Pipeline parallel: GPU 0 holds the first half of the layers and GPU 1 the second half, and micro-batches flow from one to the other. data parallel GPU 0 full model batch ½ GPU 1 full model batch ½ sharded (ZeRO-3 / FSDP) GPU 0 params ½ · grads ½ · optim ½ batch ½ GPU 1 params ½ · grads ½ · optim ½ batch ½ tensor parallel GPU 0 W columns ½ · rows ½ whole batch GPU 1 W columns ½ · rows ½ whole batch pipeline parallel GPU 0 layers 1–16 micro-batches → GPU 1 layers 17–32 → micro-batches solid: model state on the GPU · dashed: the data it processes this step

Mechanics

All-reduce, and the ring that makes it cheap

An all-reduce takes one tensor per rank and leaves every rank holding their elementwise sum (dist.all_reduce, default op=ReduceOp.SUM). It splits into two halves that are collectives in their own right: a reduce-scatter leaves rank r holding the sum of only its 1/p chunk (reduce_scatter_single), and an all-gather copies every rank's chunk to everyone (all_gather_single). In a ring, each of p ranks sends one chunk of N/p elements to its neighbour per step: p − 1 steps of reduce-scatter, then p − 1 of all-gather.

bytes sent per rank = 2(p − 1)/p · N · bytes per element     (→ 2N as p grows, independent of p)

The point is the last part: each rank's traffic barely grows with the number of ranks, where a naive "send everything to rank 0 and broadcast back" puts 2(p − 1)N through rank 0's link. NCCL picks rings, trees or other algorithms per message size and topology, but the bandwidth term of any all-reduce can't beat this 2(p − 1)/p factor.

DDP: buckets that overlap with backward

DistributedDataParallel broadcasts rank 0's weights and buffers at construction (_sync_module_states) so every replica starts identical, then registers a hook on each parameter's gradient. Calling all-reduce per parameter would launch thousands of tiny collectives, and waiting until backward finishes would leave the network idle while it runs. So DDP groups gradients into buckets of bucket_cap_mb (default 25 MiB). Parameters are assigned in roughly reverse order of definition, because backward produces the last layer's gradients first (_ddp_init_helper builds the Reducer from that order). When every gradient in a bucket is ready, its all-reduce starts asynchronously while backward keeps computing earlier layers. The bucket for the parameters defined first, whose gradients arrive last, can be kept small (_BucketCapacityConfig), so little communication is left once backward ends.

The reducer averages: it sums and divides by the world size. With gradient_as_bucket_view=True, param.grad is a view into the bucket, saving one gradient-sized copy. The no_sync context skips the all-reduce for gradient accumulation; the flag is read in _pre_forward.

ZeRO: shard the optimizer state, then gradients, then weights

ZeRO (Rajbhandari et al.) counts the model states for Adam in mixed precision as 2Ψ bytes of fp16 weights, 2Ψ of fp16 gradients and KΨ of optimizer state, with K = 12 (fp32 weights, momentum and variance, 4 bytes each), for Ψ parameters. Data parallelism replicates all (4 + K)Ψ on every GPU. ZeRO removes the replication in three stages:

DDP: (2 + 2 + K)Ψ    stage 1 (Pos): 4Ψ + KΨ/N    stage 2 (Pos+g): 2Ψ + (2 + K)Ψ/N    stage 3 (Pos+g+p): (2 + 2 + K)Ψ/N

Stage 1 gives each rank the Adam state for 1/N of the parameters; it updates only that slice and then all-gathers the new weights. Stage 2 also keeps only that slice's gradient: instead of an all-reduce, a reduce-scatter hands each rank the summed gradient for exactly the slice it updates. The traffic is the same as DDP, since an all-reduce is a reduce-scatter plus an all-gather. Stage 3 also shards the weights. A layer's weights are all-gathered before its forward, freed, all-gathered again before its backward, and its gradients are reduce-scattered. That's one extra all-gather, 1.5× DDP's traffic, for model states that shrink linearly with N.

FSDP in PyTorch

torch.distributed.fsdp exports two APIs. FSDP1's FullyShardedDataParallel names the ZeRO stages in ShardingStrategy: FULL_SHARD (stage 3), SHARD_GRAD_OP (stage 2: parameters are sharded outside computation but stay unsharded from forward through backward), NO_SHARD (DDP) and HYBRID_SHARD (full sharding inside a node, replication across nodes). FSDP2's fully_shard shards each parameter on dim 0 as a DTensor. Its docstring (lines 116–123) is the ZeRO-3 schedule: all-gather before forward, free after forward if reshard_after_forward, all-gather again in backward, then free and reduce-scatter the gradients.

Each fully_shard call makes one communication group, so it is applied bottom-up, once per transformer block and then once on the root. Each block's weights then come in one foreach_all_gather and leave in one foreach_reduce. Peak memory is the shards plus one or two blocks' full weights. reshard_after_forward is the stage-2/stage-3 dial: False keeps the gathered weights until backward (no second all-gather, more memory), and an integer reshards to a smaller group such as the GPUs of one node. For gradient accumulation, set_requires_gradient_sync(False) is FSDP2's equivalent of no_sync. It skips the reduce-scatter, so every rank keeps unsharded gradients until the last micro-batch. That trades the memory ZeRO saved back for communication.

Tensor parallelism: split the matmuls

Megatron-LM (Shoeybi et al.) splits a transformer's MLP Z = ReLU(XA)B so that it needs one all-reduce. Split A by columns into [A1, A2]: each GPU computes Yi = ReLU(XAi) with no communication, because the activation works elementwise on columns it owns. Split B by rows to match: each GPU computes YiBi, and Z = Σi YiBi is a single all-reduce. Splitting A by rows instead would leave partial sums before the nonlinearity, and ReLU(a + b) ≠ ReLU(a) + ReLU(b). Attention is split the same way: heads go column-wise across GPUs, and the output projection is row-wise.

PyTorch spells the two halves ColwiseParallel (input replicated, output sharded on the last dim) and RowwiseParallel (input sharded on the last dim, output replicated), applied with parallelize_module. The cost is two all-reduces of the b × s × h activations per layer in forward (attention and MLP) and two in backward, on the critical path with nothing to overlap. That is why tensor parallelism stays inside a node, on NVLink.

Pipeline parallelism: split the layers

With p stages, stage s can't start a micro-batch until stage s − 1 has sent its activations, and backward runs in the reverse direction. GPipe (ScheduleGPipe) runs every micro-batch's forward, then every backward. 1F1B (Schedule1F1B) runs min(n_microbatches, num_stages − stage_index) warm-up forwards, then alternates one backward and one forward. With equal forward and backward times, both take 2(m + p − 1) slots for 2m slots of work, so both leave the same idle bubble:

bubble fraction = (p − 1) / (m + p − 1)     (Narayanan et al. write (p − 1)/m as bubble time over ideal time)

What 1F1B changes is memory. A stage must keep each micro-batch's activations from its forward until its backward. GPipe holds all m, while 1F1B frees one per forward after warm-up, so stage 0 never holds more than p. More micro-batches shrink the bubble without growing 1F1B's activation memory, and interleaved schedules (ScheduleInterleaved1F1B) give each GPU several non-adjacent chunks of layers to shrink it further, at the cost of more sends.

ComponentShape/ParamsMeaningSource
dist.all_reduceop=ReduceOp.SUM, async_op=FalseEvery rank ends with the elementwise sum of all ranks' tensors.distributed_c10d.py 4079–4183
dist.reduce_scatter_singleoutput = input / world sizeEach rank gets the sum of its own chunk; ZeRO-2/3 gradient sync.distributed_c10d.py 6068–6173
dist.all_gather_singleoutput = input × world sizeConcatenates every rank's chunk on every rank; FSDP weight gather.distributed_c10d.py 5320–5432
DistributedDataParallelbucket_cap_mb (25 MiB)Replicates the model; all-reduces (averages) gradient buckets during backward.distributed.py 465–2776
_BucketCapacityConfigsmaller first bucketOptional smaller cap for the last-ready bucket, cutting the tail after backward.distributed.py 36–162
DDP.no_synccontext managerAccumulates gradients locally without all-reducing.distributed.py 1680–1707
ShardingStrategyFULL_SHARD, SHARD_GRAD_OP, NO_SHARD, HYBRID_SHARDFSDP1's names for ZeRO-3, ZeRO-2, DDP and node-local sharding.api.py 32–69
fully_shardmesh, reshard_after_forward, mp_policyFSDP2: dim-0 DTensor shards, all-gather before use, reduce-scatter gradients._fully_shard.py 99–308
FSDPModule.set_requires_gradient_syncboolFSDP2's no_sync: skip the reduce-scatter while accumulating._fully_shard.py 597–632
MixedPrecisionPolicyparam_dtype, reduce_dtypeDtype the gathered weights compute in and the gradients are reduced in._fsdp_api.py 14–84
ColwiseParallelnn.Linear weight split by output featuresReplicated input, output sharded on the last dim; no communication.style.py 45–183
RowwiseParallelnn.Linear weight split by input featuresSharded input, partial outputs all-reduced into a replicated output.style.py 186–336
ScheduleGPipen_microbatchesAll forwards, then all backwards; holds m activations.schedules.py 1281–1412
Schedule1F1Bwarm-up min(m, p − s)Alternates one forward and one backward after warm-up; holds at most p.schedules.py 1415–1684

Worked example

Four parts: a ring all-reduce by hand, the memory per GPU for Llama 2 7B on 8 GPUs under each scheme, a tensor-parallel MLP split two ways, and the bubble in a 4-stage pipeline. Every number below comes from tested helpers in ml_models_docs.py.

A ring all-reduce over four ranks

Each rank holds a gradient of 4 values, split into 4 chunks of 1. At reduce-scatter step t, rank r sends chunk (r − t) mod 4 to rank r + 1, which adds it to its own. At all-gather step t, rank r forwards chunk (r + 1 − t) mod 4, which the receiver overwrites. Bold marks a fully summed chunk.

AfterRank 0Rank 1Rank 2Rank 3
start(1, 2, 3, 4)(5, 6, 7, 8)(9, 10, 11, 12)(13, 14, 15, 16)
reduce-scatter 1 (1, 2, 3, 20) (6, 6, 7, 8) (9, 16, 11, 12) (13, 14, 26, 16)
reduce-scatter 2 (1, 2, 29, 20) (6, 6, 7, 28) (15, 16, 11, 12) (13, 30, 26, 16)
reduce-scatter 3 (1, 32, 29, 20) (6, 6, 36, 28) (15, 16, 11, 40) (28, 30, 26, 16)
all-gather 1 (28, 32, 29, 20) (6, 32, 36, 28) (15, 16, 36, 40) (28, 30, 26, 40)
all-gather 2 (28, 32, 29, 40) (28, 32, 36, 28) (15, 32, 36, 40) (28, 30, 36, 40)
all-gather 3 (28, 32, 36, 40) (28, 32, 36, 40) (28, 32, 36, 40) (28, 32, 36, 40)

After three reduce-scatter steps, rank r owns the full sum of chunk 1, 2, 3, 0 (for ranks 0–3). After three all-gather steps, every rank holds (28, 32, 36, 40), and dividing by 4 gives the mean gradient (7, 8, 9, 10). Each rank sent 6 values, 2(p − 1)/p × 4 = 6, whereas gathering on rank 0 and broadcasting back would push 24 through rank 0's link.

Ring all-reduce over four ranks Four ranks in a ring, each sending to the next: 0 to 1, 1 to 2, 2 to 3 and 3 back to 0. On the right, the two phases: three reduce-scatter steps after which rank r owns the sum of chunk r plus 1 mod 4, then three all-gather steps after which every rank holds the full sum 28, 32, 36, 40. Each rank sends 6 values in total. rank 0 rank 1 rank 2 rank 3 sends to r + 1 reduce-scatter · p − 1 = 3 steps step t: rank r sends chunk (r − t) mod 4, receiver adds after: rank r owns Σ of chunk (r + 1) mod 4 all-gather · p − 1 = 3 steps step t: rank r forwards chunk (r + 1 − t) mod 4, receiver copies after: every rank holds (28, 32, 36, 40) per rank: 2(p − 1) chunks of N/p = 6 values sent

Memory per GPU: Llama 2 7B on 8 GPUs

First a check against the ZeRO paper's own example, Ψ = 7.5B on N = 64 with K = 12: 120 GB, 31.4 GB, 16.6 GB and 1.9 GB for DDP and stages 1–3, the figures the paper reports. Now Ψ = 6,738,415,616 on 8 GPUs of 80 GB, model states only:

SchemeFormulaPer GPUFits 80 GB?
DDP16Ψ107.8 GBno
ZeRO-14Ψ + 12Ψ/837.1 GByes
ZeRO-22Ψ + 14Ψ/825.3 GByes
ZeRO-3 / FSDP16Ψ/813.5 GByes
Tensor parallel, 8-way16Ψ/813.5 GByes

DDP can't train this model on 80 GB GPUs at all. ZeRO-1 alone brings it to 37.1 GB, and ZeRO-3 leaves 66.5 GB per GPU for activations, which at 4,096-token sequences are the next wall (activation checkpointing, recomputing them in backward, is the usual answer). 8-way tensor parallelism splits the same states 8 ways, and also the activations inside each layer, which ZeRO doesn't touch.

Model-state memory per GPU for Llama 2 7B on 8 GPUs Horizontal bars against a dashed line at 80 GB: DDP 107.8 GB crosses the line; ZeRO-1 37.1 GB, ZeRO-2 25.3 GB and ZeRO-3 13.5 GB fit, each shorter than the last. DDP 107.8 GB ZeRO-1 37.1 GB ZeRO-2 25.3 GB ZeRO-3 13.5 GB 80 GB GPU

What each step sends

Gradients and gathered weights in bf16 (2 bytes), payload Ψ × 2 = 13.5 GB, across 8 ranks. Times assume an effective 100 GB/s per GPU, an assumed round number, not a measurement.

SchemeCollectives per stepSent per GPUAt 100 GB/s
DDP, ZeRO-1all-reduce gradients23.58 GB0.236 s
ZeRO-2reduce-scatter grads + all-gather weights23.58 GB0.236 s
ZeRO-3 / FSDP2 all-gathers + reduce-scatter35.38 GB0.354 s
Tensor parallel, 8-way128 activation all-reduces7.52 GB0.075 s

DDP's all-reduce goes out bucket by bucket, overlapped with backward. With fp32 gradients (an fp32 model under autocast) a 25 MiB bucket holds 6,553,600 of them, so the model's gradients fill about 1029 buckets and the traffic doubles to 47.17 GB. The tensor-parallel row is for one 4,096-token sequence: each all-reduce moves 1 × 4096 × 4096 × 2 bytes = 33,554,432 bytes of activations, 4 per layer over 32 layers. It grows with batch and sequence length, not with parameters, and it sits between matmuls where nothing hides it.

Tensor-parallel MLP: the right split and the wrong one

X = [(1.0, -1.0); (2.0, 0.5)], A (2 × 4) = [(1.0, 0.0, -1.0, 2.0); (0.5, 1.0, 1.0, -1.0)], B (4 × 2) = [(1.0, 0.0); (0.0, 1.0); (-1.0, 1.0); (0.5, -0.5)]. Unsplit, Z = ReLU(XA)B = [(2.00, -1.50); (4.00, -1.25)], with hidden activations [(0.50, 0.00, 0.00, 3.00); (2.25, 0.50, 0.00, 3.50)].

GPUAi (columns)Yi = ReLU(XAi)Bi (rows)YiBi
0[(1.0, 0.0); (0.5, 1.0)][(0.50, 0.00); (2.25, 0.50)][(1.0, 0.0); (0.0, 1.0)][(0.50, 0.00); (2.25, 0.50)]
1[(-1.0, 2.0); (1.0, -1.0)][(0.00, 3.00); (0.00, 3.50)][(-1.0, 1.0); (0.5, -0.5)][(1.50, -1.50); (1.75, -1.75)]
all-reduceΣi YiBi[(2.00, -1.50); (4.00, -1.25)]

The sum matches the unsplit [(2.00, -1.50); (4.00, -1.25)] exactly. Split A by rows instead, and each GPU holds a partial pre-activation; the true pre-activation is their sum, [(0.50, -1.00, -2.00, 3.00); (2.25, 0.50, -1.50, 3.50)], and its -2.0 becomes 0 after ReLU. Applying ReLU to the partial sums first and adding gives hidden [(1.00, 0.00, 0.00, 3.00); (2.25, 0.50, 0.50, 4.00)] and output [(2.50, -1.50); (3.75, -1.00)]: wrong, unless you pay for an all-reduce before the activation as well.

Pipeline bubble and activation memory

4 stages, one time slot per forward and per backward of one micro-batch, simulated with the dependencies F(s, j) after F(s − 1, j) and B(s, j) after B(s + 1, j):

Micro-batches m1481632
Bubble (p − 1)/(m + p − 1)75.0%42.9%27.3%15.8%8.6%

At m = 8 both schedules take 22 slots for 16 of work per stage, a 27.3% bubble. Peak micro-batches held in activation memory per stage: GPipe 8, 8, 8, 8; 1F1B 4, 3, 2, 1. Going to m = 32 shrinks the bubble to 8.6% and GPipe's peak to 32 micro-batches, while 1F1B's stays at 4.

GPipe and 1F1B schedules for 4 stages and 8 micro-batches Two grids of 4 rows (stages) by 22 time slots. In GPipe, each stage runs forwards F0 to F7, then backwards B0 to B7, with idle slots forming a triangle at the start and end. In 1F1B, stage s runs 4 − s warm-up forwards and then alternates backward and forward. Both finish at slot 22 with the same bubble of 27.3%, but 1F1B stage 0 holds at most 4 micro-batches of activations versus 8 for GPipe. GPipe · peak in flight 8 / 8 / 8 / 8 stage 0 stage 1 stage 2 stage 3 F0 F0 F0 F0 F1 F1 F1 F1 F2 F2 F2 F2 F3 F3 F3 F3 F4 F4 F4 F4 F5 F5 F5 F5 F6 F6 F6 F6 F7 F7 F7 F7 B0 B0 B1 B0 B1 B2 B0 B1 B2 B3 B1 B2 B3 B4 B2 B3 B4 B5 B3 B4 B5 B6 B4 B5 B6 B7 B5 B6 B7 B6 B7 B7 1F1B · peak in flight 4 / 3 / 2 / 1 stage 0 stage 1 stage 2 stage 3 F0 F0 F0 F0 F1 F1 F1 B0 F2 F2 B0 F1 F3 B0 F2 B1 B0 F3 B1 F2 F4 B1 F3 B2 B1 F4 B2 F3 F5 B2 F4 B3 B2 F5 B3 F4 F6 B3 F5 B4 B3 F6 B4 F5 F7 B4 F6 B5 B4 F7 B5 F6 B5 F7 B6 B5 B6 F7 B6 B7 B6 B7 B7 B7 dashed background: idle (bubble) · F: forward · B: backward · one slot each

Implementation

FSDP2 on one node, sharding each transformer block, with bf16 compute and fp32 gradient reduction. Launch with torchrun --nproc-per-node 8 train.py:

import os
import torch
import torch.distributed as dist
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy

dist.init_process_group("nccl")
torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))

model = build_model()                       # on the meta device for large models
mp = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32)
for block in model.layers:                  # bottom-up: one all-gather per block
    fully_shard(block, mp_policy=mp)
fully_shard(model, mp_policy=mp)            # root: embeddings, final norm, head

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)   # sees only shards

for step, micro_batches in enumerate(loader):
    for i, (x, y) in enumerate(micro_batches):
        model.set_requires_gradient_sync(i == len(micro_batches) - 1)
        loss = model(x, labels=y) / len(micro_batches)
        loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # DTensor-aware
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)

Swap the two fully_shard lines for model = DistributedDataParallel(model, device_ids=[local_rank]), and set_requires_gradient_sync for no_sync, to get plain data parallelism. Add tensor parallelism by building a 2-D DeviceMesh and calling parallelize_module with ColwiseParallel on the up projections and attention input projections and RowwiseParallel on the down and output projections before fully_shard on the data-parallel dimension. Each rank must also see a different slice of data (DistributedSampler, see data loading), or all of them compute the same gradient.

Tradeoffs

  • Data parallel is simplest and fastest when it fits. One overlapped all-reduce per step and no change to the model, but full model states on every GPU.
  • Sharding buys memory with latency. ZeRO-3/FSDP sends 1.5× DDP's bytes and puts an all-gather in front of every block, which prefetching must hide. Small blocks, slow links or tiny per-GPU batches expose it. Stage 2 or reshard_after_forward=False sits in between.
  • Tensor parallelism needs fast links. Its all-reduces are on the critical path and scale with batch × sequence × hidden, so it stays within NVLink domains (typically ≤ 8 ways) and needs the hidden size and head count to divide evenly.
  • Pipelines need many micro-batches. The bubble is (p − 1)/(m + p − 1), so deep pipelines need large global batches, and stages must be balanced or the slowest one sets the pace. The traffic, activations between neighbours only, is the lightest of all, which suits slower inter-node links.
  • The global batch grows with data parallelism. 1,024 GPUs at 4 sequences each is a 4,096-sequence batch, which may need learning-rate retuning or exceed the critical batch size (see gradient accumulation).
  • Failures scale too. More GPUs mean more frequent hardware faults; frequent sharded checkpoints and elastic restarts become part of the design.
Why is ZeRO-2's communication the same as DDP's, while ZeRO-3's is 1.5×?

An all-reduce is a reduce-scatter plus an all-gather. ZeRO-2 does the reduce-scatter on gradients and the all-gather on updated weights, so the same two halves. ZeRO-3 must all-gather weights twice, once for forward and again for backward, since it freed them in between.

In the tensor-parallel MLP, why split the first matrix by columns and the second by rows?

Column-splitting the first matrix lets each GPU apply the nonlinearity to complete hidden units with no communication. Row-splitting the second then consumes exactly those hidden units, leaving partial outputs that one all-reduce sums. The other way round needs an all-reduce before the nonlinearity too.

Doubling micro-batches from 8 to 16 with 4 stages: what happens to the bubble and to 1F1B's peak activations?

The bubble falls from 27.3% to 15.8%. 1F1B's stage 0 still holds at most 4 micro-batches; GPipe's would rise from 8 to 16.

Connections

Read first

Read next

No page builds on this one yet.

References