ring: fail on peer disconnect instead of hanging forever - #4060
ring: fail on peer disconnect instead of hanging forever#4060erwinzhang7 wants to merge 2 commits into
Conversation
Killing one rank of a ring group leaves every surviving rank hung with no
way to recover. Two separate defects combine to produce it.
First, an orderly peer close is invisible. recv() reports it by returning 0
and leaves errno untouched, so the 'errno != EAGAIN' test reads a stale
value. On a non-blocking socket that value is almost always EAGAIN, because
every previous call with no data available set it, so the failure is skipped
entirely. error_count never increments, the abort is never reached, and the
worker spins on a dead socket forever at 100% CPU without logging anything.
sendAll() and recvAll() in the nccl backend already treat <= 0 as failure.
Second, when the abort is reached the worker just returns. Every queued
task's promise is left unsatisfied, and because the SocketThread outlives
its worker those promises are not destroyed either, so no broken_promise is
delivered, the futures never become ready, and every waiter blocks forever.
Treat r == 0 as an error on both paths, and exit rather than return once the
error threshold is hit. The log line already said 'Aborting...'; now it does.
Stopping is the honest outcome: the ring cannot continue correctly without
the peer, and completing collectives that are silently missing a rank's
contribution would be worse than failing. This matches how the nccl backend
handles unrecoverable communication errors.
Verified on a 4 rank loopback ring, killing one rank mid-run:
before: 3/3 survivors alive and frozen indefinitely, and the rank that was
spinning logged nothing at all
after: 3/3 log the error and exit
Spawns a 3 rank loopback ring directly, kills one rank, and asserts the survivors exit rather than block forever. It does not run under mlx.launch, and the file is named test_*.py rather than *_test_distributed.py on purpose. The *_test_distributed.py files are excluded from unittest discovery so they only run under mpirun or mlx.launch; this one needs the opposite, because mlx.launch terminates surviving ranks when a rank exits, which is exactly the behaviour under test. Running it as an ordinary discovered test that manages its own processes also keeps it from disturbing ring_test_distributed.py's eight rank run. Ports come from binding to port 0 and releasing, rather than a fixed range that would collide on a shared runner. Startup failures skip rather than fail, so an environment that cannot form a ring at all does not look like a regression. Against 0.32.0 it fails in 61s with the survivors still running. With the fix it passes in about 1s. Ran 8 consecutive times on the fix with no flakes.
| // backend handles unrecoverable communication errors. | ||
| log_info(true, "Too many send/recv errors. Aborting..."); | ||
| return; | ||
| exit(1); |
There was a problem hiding this comment.
I think the error can be passed to the main thread by setting exception in the promise when error_count >= 10. Aborting the process is not the best choice in this case.
There was a problem hiding this comment.
Thanks, I thought about this before opening the PR but only reasoned about why it wouldn't work. This time I built it, and I think the outcome still argues against it, though the reasoning changed.
Implemented exactly as suggested: on error_count >= 10, reject every queued task's promise
with set_exception instead of stopping, and switch the ten internal .wait() calls to
.get() so the exception is observed rather than silently ignored.
Same reproduction as the PR: 4 ranks, all_sum in a loop, SIGKILL rank 2. All three
survivors end like this:
[ring] Socket 3 was closed by the peer
... (x10)
[ring] Too many send/recv errors. Failing pending tasks...
libc++abi: terminating due to uncaught exception of type std::runtime_error: [ring] connection to a peer was lost
The exception never reaches the main thread. The ring's .wait() calls run inside a lambda
handed to encoder.dispatch(...) (ring.cpp:525), which executes on the stream thread, and
StreamThread::thread_fn (scheduler.h:48) calls task() with no try/catch. Throwing there
unwinds straight out of the thread entry point.
The try/catch in eval() (transforms.cpp:228, from #3675) does not cover it, that
runs on the calling thread and only guards the inline portion of eval_cpu, not work that
has been dispatched.
So the net change is that a controlled exit(1) with a logged reason becomes an uncatchable
SIGABRT with no Python traceback. The process stops either way; likely what the
second reporter on #3862 saw as a crash on Metal.
There is a second problem even setting terminate aside. CommandEncoder::dispatch wraps
work as:
auto task_wrap = [s = stream_, task = std::move(task)]() mutable {
task();
scheduler::notify_task_completion(s);
};If task() throws, notify_task_completion never runs, so the scheduler's active-task count
leaks and wait_for_one() can block. Failing a promise from inside dispatched work is not
currently safe regardless of what the ring does with it.
What it would take to do it properly
I do like the shape of what you are asking for, and it is achievable, just not from inside
ring.cpp. It needs the exception caught where the work is dispatched, stored as an
exception_ptr, and rethrown on the calling thread at a synchronization point. There is no
such mechanism today: grep exception_ptr across mlx/scheduler.h, mlx/backend/cpu/ and
mlx/backend/metal/ returns nothing, and CommandEncoder::dispatch has no handler.
That would be a useful addition, every backend that can fail inside dispatched
work would benefit, not just the ring, but it is a scheduler change with its own design
questions (which thread observes it, what happens to the other queued tasks on that stream,
how it interacts with notify_task_completion), and it felt wrong to bundle into a
peer-disconnect fix.
Proposed Fixes:
- Keep
exit(1)here, matching the nccl backend's handling of unrecoverable communication
errors and theAborting...the log line already claims. - Leave this PR at the
r == 0detection half only, and fix the wedge separately once
there is a way to surface errors from dispatched work. - If you would like, I will prototype the
exception_ptrplumbing in the scheduler as its
own PR, and rebase this on top.
I'd do 1 for now and 3 as a follow-up, but I can also try to tackle the propagation path.
There was a problem hiding this comment.
We have a PR propagating the error from stream thread to main thread #3742, I can rebase it after this PR properly throw exception.
Proposed changes
Fixes #3862.
Killing one rank of a ring group leaves every surviving rank hung with no way to recover.
Two separate defects combine to produce it.
An orderly peer close is invisible.
recv()reports it by returning 0 and leaveserrnountouched, so theerrno != EAGAINtest reads a stale value. On a non-blockingsocket that value is almost always
EAGAIN, because every earlier call with no dataavailable set it, so the failure is skipped entirely.
error_countnever increments, theabort is never reached, and the worker spins on a dead socket forever at 100% CPU without
logging anything.
sendAll()andrecvAll()in the nccl backend already treat<= 0asfailure.
This is the more common failure in practice. In a 4 rank reproduction, two of the three
survivors ended up here rather than on the reported abort path, which is why the issue
describes ranks hanging with no diagnostic at all.
Reaching the abort does not release waiters. The worker returns, leaving every queued
task's promise unsatisfied. Because the
SocketThreadoutlives its worker those promisesare not destroyed either, so no
broken_promiseis delivered, the futures never becomeready, and every
.wait()blocks forever.The change treats
r == 0as an error on both the send and recv paths, and exits ratherthan returning once the error threshold is hit.
On stopping the process
The log line already said
Aborting...; this makes the behaviour match it.Stopping is deliberate. I first implemented the alternative the issue suggests, resolving
the pending promises so the surviving ranks continue. It does unblock them, but the
collectives then complete without the dead rank's contribution and return wrong results
silently. With a workload whose input changes each iteration, the survivors produced
got=2552againstwant=4600and kept running. For numerical work that seemed worse thanfailing, so I did not propose it.
Rejecting the promises with
set_exceptionand switching the internal.wait()calls to.get()does not work either: the throw would surface on the stream thread, andStreamThread::thread_fncallstask()with no try/catch, so it unwinds out of the threadentry point into
std::terminate. I believe this is what the second reporter on the issuesaw as a crash on Metal.
That leaves failing fast, which is what the nccl backend already does for unrecoverable
communication errors. If you would prefer a different failure mode the mechanism is
confined to one line and I am happy to change it.
Verification
4 rank loopback ring,
all_sumin a loop, one rank killed withSIGKILLmid run.Ranks launched directly rather than through
mlx.launch, since the launcher tears down thesurvivors on a rank exit and hides the behaviour.
Fork CI is green across all 22 jobs (Linux x86_64 and aarch64 on cpu and CUDA 12.6/12.9/13.0,
Windows x86_64 and aarch64, macOS 14/15/26.2 on cpu, metal and jit, plus lint). That includes
the new test, which
unittest discoverpicks up and runs on the runners, andmlx.launch --verbose -n 8 python/tests/ring_test_distributed.py, which passes with no newlog output, as expected when no fault is injected.
Checklist
pre-commit run --all-filesto format my code / installed pre-commit prior to committing changesOn the test.
python/tests/test_ring_peer_loss.pyspawns a 3 rank loopback ring,kills one rank, and asserts the survivors exit instead of blocking forever. Against 0.32.0
it fails after 61s with the survivors still running; with this change it passes in about a
second. I ran it 8 consecutive times on the fix with no flakes.
It is named
test_*.pyrather than*_test_distributed.pydeliberately. The*_test_distributed.pyfiles are excluded fromunittest discoverso they only run undermpirunormlx.launch; this one needs the opposite. It has to manage its own processes,because
mlx.launchterminates the surviving ranks when one exits, which is exactly thebehaviour under test, and running it inside
ring_test_distributed.pywould disturb thatfile's eight rank run.
Two things I did for CI robustness: ports come from binding to port 0 and releasing rather
than a fixed range that could collide on a shared runner, and a failure to form the ring at
all skips rather than fails, so a broken environment does not look like a regression. Only
the survivors-still-running case fails.