Multi-GPU and Distributed Training
Debugging a distributed job that hangs
A hang is almost always one rank reaching a collective that the others never reach — set a short timeout so the silence becomes an error, then find the rank that went a different way.
- 10 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
A frozen distributed job is nearly always one worker waiting for a message that nobody is going to send.
Four people agree to clap together on the count of three. Three of them count and clap. The fourth was looking at his phone. The other three now stand with their hands raised, waiting. Nobody is broken. Nobody errors. They wait forever.
Every collective — the shared operations from the collectives lesson — is a clap. All workers must make the call, in the same order, or the ones who did make it wait.
Why it is so hard to see
A crash gives you a message and a line number. A hang gives you nothing.
The GPUs may even show 100% usage while nothing progresses, because a waiting collective spins. Your monitoring dashboard shows a busy, healthy machine that has not finished a step in forty minutes.
The single most useful habit is to make the waiting stop. Set a time limit when the job starts. Then the silence turns into an error message with a line number, and you are back to ordinary debugging.
How it works
rank 0: all_reduce -> all_reduce -> ...waiting for a partner...
rank 1: all_reduce -> (skipped it) -> finished and exited
rank 0 will wait until the clock runs out.
With a 10-second timeout it raises an error naming the operation.
Without one, the default wait is 30 minutes.The usual causes are all the same shape:
- a collective inside
if rank == 0: - one rank taking a different
ifbranch, because its data differed - one rank having fewer batches, so it leaves the loop early
- one rank crashed already, and the others are waiting for a corpse
A real example you have seen
A group video call where one person's connection dropped. Everyone else sits looking at a frozen tile, saying "can you hear me?" The call has not failed. It is waiting.
Remember this
- A hang means one rank did not reach the collective the others reached.
- Set a short timeout so the wait becomes a readable error.
- Check first for collectives inside
ifblocks and for ranks with different batch counts.
What to learn next
- all_reduce, all_gather and broadcast — the calls that must match across every rank.
- Training across several machines — the environment where these hangs get expensive.
- Memory leaks in training loops — the other slow failure that looks like nothing is wrong.
Developer — Code and libraries.
Setup
pip install torchCaptured on CPU with the gloo backend and two processes. Reproducing a real hang is the point, so both scripts below deliberately deadlock and then time out.
Reproducing the classic hang
from datetime import timedelta
import torch
import torch.distributed as dist
# a short timeout turns a silent hang into a loud error you can read
dist.init_process_group(backend="gloo", timeout=timedelta(seconds=10))
rank = dist.get_rank()
t = torch.ones(1)
dist.all_reduce(t)
print(f"rank {rank}: first all_reduce done")
if rank == 0: # THE BUG: only rank 0 makes this call
dist.all_reduce(t)
print("rank 0: second all_reduce done")
print(f"rank {rank}: reached the end")
dist.destroy_process_group()torchrun --nproc_per_node=2 hang_demo.pyrank 1: first all_reduce done
rank 1: reached the end
rank 0: first all_reduce done
Traceback (most recent call last):
File "hang_demo.py", line 14, in <module>
dist.all_reduce(t)
RuntimeError: [.../gloo/transport/uv/unbound_buffer.cc:67] Timed out waiting
10000ms for recv operation to completeThat error took ten seconds to arrive because of the timeout argument. Remove it and the default gloo wait is 30 minutes of complete silence. The path inside the brackets is a build path and differs between platforms and versions; the words after it are the part that matters.
Read the log the way an investigator would. Rank 1 printed "reached the end" and rank 0 did not. The rank that is missing from the last log line is the one still waiting, and the rank that finished early is usually the one that skipped a call.
Finding the culprit by name
from datetime import timedelta
import time
import torch.distributed as dist
dist.init_process_group(backend="gloo", timeout=timedelta(seconds=30))
rank = dist.get_rank()
if rank == 1:
time.sleep(25) # pretend rank 1 is stuck in a slow dataloader
try:
dist.monitored_barrier(timeout=timedelta(seconds=5)) # gloo only
except RuntimeError as e:
print(f"rank {rank}:", str(e).splitlines()[0])rank 0: [Rank 0]: Rank 1 failed to pass monitoredBarrier in 5000 ms
monitored_barrier is an ordinary barrier that reports which rank failed to arrive. It is gloo-only, so on a GPU job you create a small side process group for it:
gloo_group = dist.new_group(backend="gloo") # alongside your nccl group
dist.monitored_barrier(group=gloo_group, timeout=timedelta(seconds=60))Drop that call at a few suspected points in your loop and the hang stops being anonymous.
A checklist that finds most hangs
1. Set a timeout at startup, permanently.
dist.init_process_group(backend="nccl", timeout=timedelta(minutes=10))2. Turn on the NCCL watchdog so a stuck collective aborts with a rank named, rather than spinning:
export TORCH_NCCL_ASYNC_ERROR_HANDLING=1
export TORCH_NCCL_TRACE_BUFFER_SIZE=2000 # keeps the last 2000 collectives per rank
export NCCL_DEBUG=WARN # INFO when you need the topology dump3. Turn on the distributed consistency checks:
export TORCH_DISTRIBUTED_DEBUG=DETAILThis verifies that every rank passes matching shapes and dtypes to each collective, and reports mismatches instead of hanging. It is slow, so use it while diagnosing and remove it afterwards.
4. Look at where the Python code actually is. py-spy dump --pid <pid> prints a live stack trace of a running process without stopping it. Run it against one process per rank; the rank whose stack differs is the offender.
pip install py-spy
py-spy dump --pid 123455. Search your own code for these five patterns:
| pattern | why it hangs |
|---|---|
a collective inside if rank == 0: | only one rank calls it |
if loss > threshold: dist.all_reduce(...) | data-dependent, so ranks disagree |
an early break or return in the training loop | one rank leaves, the rest wait |
| ranks with different numbers of batches | short rank finishes the epoch early |
barrier() on one side of an early-stopping check | the deciding rank exits alone |
The uneven-batches case is the sneaky one. It comes free with DistributedSampler in training, and it comes straight back if you write a custom loader or filter samples inside the loop.
Hangs that are not deadlocks
Not everything that stops is a missed collective.
Dataloader workers deadlocking. A num_workers > 0 loader that forks after CUDA has been initialised can wedge. Symptom: the hang happens on the first batch, before any collective. Try num_workers=0 to confirm — see dataloader workers and speed.
A crashed rank you were not told about. torchrun should tear the group down, but an out-of-memory kill by the operating system can leave a zombie. Check dmesg for the OOM killer and your job's exit codes per rank.
A network path that never opened. If the hang is at init_process_group rather than in the loop, it is connectivity, not logic. Test the port directly with nc -zv <master_addr> 29500.
Genuine slowness mistaken for a hang. One straggler rank running at a tenth of the speed of the others makes every step slow, not stuck. Print per-rank step times before you conclude anything.
Common mistakes
Debugging at full scale. Reproduce on 2 ranks on one machine with gloo first. Almost every logic hang reproduces there, in seconds, with readable errors.
Leaving the default 30-minute timeout. You lose half an hour per attempt, and the eventual message is less useful than one from a short timeout.
Adding barrier() calls to "fix" it. A barrier in the wrong place creates a fresh deadlock. Barriers are diagnosis, not treatment.
Treating a NCCL error's line number as the fault site. NCCL calls are asynchronous, so the error surfaces at whatever line the CPU had reached. The flight recorder buffer, not the traceback, tells you which collective actually failed.
Try it yourself
Change hang_demo.py so both ranks call all_reduce twice but rank 1 passes torch.ones(2) instead of torch.ones(1). Predict what happens, then run it with and without TORCH_DISTRIBUTED_DEBUG=DETAIL and compare the messages.
What to learn next
- all_reduce, all_gather and broadcast — the calls that must match across every rank.
- Training across several machines — the environment where these hangs get expensive.
- Memory leaks in training loops — the other slow failure that looks like nothing is wrong.
Researcher — Mathematics and papers.
Why a mismatch deadlocks rather than errors
Collectives on a process group are matched positionally, not by name or tag. Rank $r$'s $k$-th collective on a group is matched against every other rank's $k$-th collective on that group. There is no protocol-level agreement on which operation is being performed, so a rank issuing all_reduce where another issues all_gather does not produce a type error — the transports exchange whatever bytes were posted. With mismatched sizes this manifests as a hang, a truncated result, or in the worst case a memory error whose stack trace points somewhere unrelated. TORCH_DISTRIBUTED_DEBUG=DETAIL inserts a metadata exchange before each collective to catch exactly this, which is why it is slow.
Under NCCL the situation is compounded by asynchrony. Collectives are enqueued on the current CUDA stream and the host returns immediately, so the Python traceback records the line the CPU had reached, not the collective that stalled. The watchdog thread polls each work item against the group timeout and, with TORCH_NCCL_ASYNC_ERROR_HANDLING=1, aborts the communicator and terminates the process rather than allowing an indefinite spin — a hard abort, because a NCCL communicator in an inconsistent state cannot be recovered in place.
The flight recorder
TORCH_NCCL_TRACE_BUFFER_SIZE=N retains a ring buffer of the last $N$ collectives per rank: sequence number, operation, sizes, stream, and the timestamps of enqueue, start and completion. On timeout the buffer is dumped, and the diagnosis reduces to comparing sequence numbers across ranks. A rank stalled at sequence $k$ while the others sit at $k+1$ identifies both the missing participant and the specific call. This is the standard tool for hangs at scale, where reproducing on two ranks is not possible because the trigger is a rare data-dependent branch.
Categories, and what each looks like
Distributed hangs fall into four classes with distinguishable signatures. Divergent control flow — ranks issue different collective sequences; signature is mismatched sequence numbers and one rank ahead of the others. Stragglers — all ranks issue the same sequence, but one arrives late; signature is a large gap between enqueue and start timestamps on the fast ranks, and step-time histograms with a heavy tail. Resource deadlock — a hang with no collective outstanding at all, typically dataloader worker or fork-safety related; signature is a Python stack inside multiprocessing on the affected rank. Silent death — one rank has exited or been killed; signature is a missing process, an OOM entry in the kernel log, and every survivor waiting on the same sequence number.
The straggler class deserves separate treatment because it is not a bug. In a synchronous data-parallel job, step time is $\max_r t_r$ over ranks, so a single 10%-slow rank costs 10% of the entire cluster. At thousands of ranks, hardware variation alone guarantees stragglers, which motivates per-rank telemetry as standing practice rather than a debugging measure.
References
- PyTorch documentation, Distributed Debugging Tools —
TORCH_DISTRIBUTED_DEBUG,monitored_barrier, and the flight recorder. - NVIDIA, NCCL Developer Guide, troubleshooting section — communicator abort semantics and the debug environment variables.
- Dean and Barroso (2013), The Tail at Scale, CACM 56(2) — why the slowest participant dominates, and the standard responses.
- Li et al. (2020), PyTorch Distributed, VLDB 13(12) — the synchronisation points DDP introduces, and where they can stall.
What to learn next
- all_reduce, all_gather and broadcast — the calls that must match across every rank.
- Training across several machines — the environment where these hangs get expensive.
- Memory leaks in training loops — the other slow failure that looks like nothing is wrong.