Give every process group one collective timeout that survives rank-0-only work - #444
Conversation
…only work
EK-FAC runs abort at larger dataset sizes with
Watchdog caught collective operation timeout
checkTimeout at ProcessGroupNCCL.cpp:733
always immediately after "Collecting gradients: 100%", and never during the
gradient loop itself. The loop is not the problem -- allocate_batches pads the
batch count to a multiple of the world size and asserts every rank gets an equal
number, so ranks leave the loop together.
What follows the loop is not symmetric:
run_with_collector_hooks() collector/collector.py
... gradient loop (symmetric) ...
self.collector.teardown() hessians/autocorrelation.py
process_autocorrelation_matrices(...) `if rank == 0:` ...
if self.rank == 0: processor.save(...)
dist.all_reduce(total_processed) <- other ranks block HERE
Rank 0 reduces and eigendecomposes the Grams and writes them to storage while
every other rank is already waiting in the all_reduce. That is not a deadlock --
rank 0 does arrive -- but the rank-0 work grows with the dataset and the write
lands on shared storage, so past the timeout the watchdog kills a run that was
making normal progress. We see it at 64k documents where 32k with the same
config and the same hardware completes.
The timeouts in place were inconsistent and, in the build path, too short:
build.py 30 minutes <- the index/EK-FAC path, where this fires
distributed.py 1 hour (init_dist) / none (parent_barrier)
score/score.py 1 hour
magic/cli.py none -> NCCL default, 10 minutes
magic/metasmoothness.py none -> 10 minutes
This replaces all five with a single DIST_TIMEOUT in bergson/distributed.py,
defaulting to 1 hour and overridable per-run with BERGSON_DIST_TIMEOUT_MIN.
Worth stating explicitly: TORCH_NCCL_HEARTBEAT_TIMEOUT_SEC does NOT help here.
It governs the watchdog monitor thread, not the collective timeout, and the
collective timeout has no environment override at all -- it is an
init_process_group argument. That is why this has to be a code change.
This raises the ceiling rather than removing the asymmetry. Doing the all_reduce
before teardown, or sharding the Hessian save across ranks, would shrink the
window itself; both are larger changes and neither is needed to stop healthy
runs being killed.
for more information, see https://pre-commit.ci
Lucia ruling 2026-08-26. Three process decisions, an error-analysis log that treats each failure as an open task, and scripts/hung_check.py. The checker earns its place immediately: it found two hung muon 128k tuning runs nobody knew about, quiet for 644 and 323 minutes, holding four GPUs. Neither had a claim, so no claim-based check could have seen them. Both show wchan pipe_write, PPid 1, and fd 1/2 pointing at a DELETED train.log -- orphaned children blocked writing into a pipe their dead launcher was supposed to drain. A closed read end would have delivered EPIPE and killed them; a half-open undrained one hangs them instead. That is a different failure from the london 32k/64k hangs (wait_woken, 39 threads in futex_wait, intact logs), and the log keeps them separate. Also backports EleutherAI/bergson#444 into the pinned -429 checkout: one DIST_TIMEOUT across all five init_process_group sites. The 64k EK-FAC abort is rank 0 processing and saving the Hessians past the collective timeout while the other rank waits in all_reduce -- build.py allowed 30 minutes and rank 0 needs more at 64k.
plan_adam_eps1e17_64k_bs32 EK-FAC aborted with "Watchdog" on iris-0. It launched before the DIST_TIMEOUT backport, so it still had build.py's 30 minutes. The useful number came from its muon twin, still ALIVE in the same rank-0 section at 114 minutes, GPU 3 at 100% and GPU 2 at 0%. Rank-0 Hessian processing and save on a 64k row therefore takes over 110 minutes. So the 1-hour default I proposed in EleutherAI/bergson#444 would abort exactly these runs. I picked that ceiling before measuring what it had to clear. The measurement now says 2 h minimum at 64k and more above that. Relaunched with BERGSON_DIST_TIMEOUT_MIN=360; both GPUs at ~100%. A timeout raises a ceiling, it does not remove the asymmetry. Doing the all_reduce before teardown, or sharding the Hessian save across ranks, is the actual fix. Also: the muon twin at 114 minutes reads STALE to check_runs.py and would read hung to hung_check.py. One rank at 100% with the rest at 0% is healthy rank-0 work -- check per-GPU utilisation before acting on either script. Fleet: eight london tuning runs now training across 32k and 64k, adamw and muon, after the stale-cache fix. shared-ord-0 fully saturated.
|
Measurement update: raised the default from 1 h to 3 h. The original hour was a guess made before I had a number for the rank-0 section. On a 64k-document row I have now observed it still healthy at 114 minutes — rank 0 pinned at 100% GPU running process_autocorrelation_matrices and processor.save(), rank 1 at 0% blocked in dist.all_reduce(total_processed). An hour would abort exactly the runs this PR is meant to protect. The two-GPU utilisation split is also the cleanest way to tell this state from a real hang: one rank saturated and the rest idle is healthy rank-0 work. Still worth saying that this raises a ceiling rather than removing the asymmetry — doing the all_reduce before teardown, or sharding the Hessian save across ranks, would shrink the window itself. |
The 1 h default was a guess made before measuring what it had to clear. A 64k row was observed still healthy in the rank-0 section at 114 minutes -- rank 0 at 100% GPU, rank 1 at 0% blocked in all_reduce -- so an hour would have aborted exactly the runs this is meant to protect. Raised to 3 h and recorded the measurement in the comment, so the next person changing it knows what the number is based on.
…ult is far short
Both 4000-step rows aborted again, and this time the log says exactly why:
Timeout(ms)=21600000
That is 360 minutes -- the BERGSON_DIST_TIMEOUT_MIN=360 I set actually applied.
So the run had six hours and still exceeded it. The patch works; the number was
wrong.
That revises the measurement twice over. I first proposed 1 hour in
EleutherAI/bergson#444, raised it to 3 after seeing a 64k bs256 row still healthy
in that section at 114 minutes, and now a 64k bs32 row at 4000 steps has blown
past six. The rank-0 section scales with the trajectory, and 4000 steps is 8x the
500-step row that took under two hours.
Ruled out I/O, which was my first suspicion since these write to ssd-1 and that
volume hit its quota twice today. It has recovered -- 2.7T free, and it now
writes at 3.0 GB/s against ssd-2 at 538 MB/s. So the six hours is compute in
process_autocorrelation_matrices plus the save, not a stalled filesystem.
Relaunched both with BERGSON_DIST_TIMEOUT_MIN=1440. Twenty-four hours is a guess
in the same family as the last two, and I want to be plain that raising the
ceiling again is not a fix. The asymmetry is the fix: rank 0 works while every
other rank sits in dist.all_reduce(total_processed), and no timeout makes that
efficient -- it just stops it being fatal. Doing the all_reduce before teardown,
or sharding the Hessian save across ranks, is what removes it.
Practical consequence for the correlation Lucia keeps asking about: the 4000-step
rows are not a reliable route to a high-step LDS point. The 500-step row is --
plan_adam_eps1e17_64k_bs256 is at 83/100 and I have just sharded its last unowned
gap (22-24) onto a free A100 pair. That is the next new (LDS, delta) pair and it
is close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give every process group one collective timeout that survives rank-0-only work
EK-FAC runs abort at larger dataset sizes with
always immediately after "Collecting gradients: 100%", and never during the
gradient loop itself. The loop is not the problem -- allocate_batches pads the
batch count to a multiple of the world size and asserts every rank gets an equal
number, so ranks leave the loop together.
What follows the loop is not symmetric:
Rank 0 reduces and eigendecomposes the Grams and writes them to storage while
every other rank is already waiting in the all_reduce. That is not a deadlock --
rank 0 does arrive -- but the rank-0 work grows with the dataset and the write
lands on shared storage, so past the timeout the watchdog kills a run that was
making normal progress. We see it at 64k documents where 32k with the same
config and the same hardware completes.
The timeouts in place were inconsistent and, in the build path, too short:
This replaces all five with a single DIST_TIMEOUT in bergson/distributed.py,
defaulting to 1 hour and overridable per-run with BERGSON_DIST_TIMEOUT_MIN.
Worth stating explicitly: TORCH_NCCL_HEARTBEAT_TIMEOUT_SEC does NOT help here.
It governs the watchdog monitor thread, not the collective timeout, and the
collective timeout has no environment override at all -- it is an
init_process_group argument. That is why this has to be a code change.
This raises the ceiling rather than removing the asymmetry. Doing the all_reduce
before teardown, or sharding the Hessian save across ranks, would shrink the
window itself; both are larger changes and neither is needed to stop healthy
runs being killed.