Propagate CPU errors to events - #3742
Conversation
|
This PR adds errors to If a CPU load fails, the event may have both A concrete fast-failure interleaving is: the event is inserted, I think either For context, these issues would prevent me from reliably landing ml-explore/mlx-swift#427, which is why I opened my original MLX PR. That Swift PR depends on CPU lazy-load read failures propagating deterministically to |
84a9b7a to
b71f0ec
Compare
|
Thanks a lot for reviewing this! I updated the PR with a different strategy: the error happened in On the race condition of On |
aleroot
left a comment
There was a problem hiding this comment.
Thank you for this work, once released I will definitely make use of it in my apps.
|
@zcbenz Sorry to disturb, but can this be merged so that we can unlock the mlx-swift development as well ? Thanks. |
Killing one rank of a ring group leaves every surviving rank hung. Two separate defects combine to produce it. 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 earlier call with no data available set it, so the failure is skipped, the error count never rises, and the worker spins on a dead socket at 100% CPU without logging anything. sendAll() and recvAll() in the nccl backend already treat <= 0 as failure. Reaching the error threshold does not release waiters either. The worker returned, leaving every queued task's promise unsatisfied. Because the SocketThread outlives its worker those promises are not destroyed, so no broken_promise is delivered, the futures never become ready, and every wait blocks forever. Treat r == 0 as an error on both the send and recv paths, and reject the pending promises rather than returning, so the failure reaches whoever is waiting instead of the collective completing without this peer's contribution. The internal waits become get() so the exception is observed rather than discarded. Until the error can be carried from the stream thread to the main thread the exception terminates the process rather than surfacing to the caller. That is still a diagnosable stop rather than a silent hang or a wrong result, and ml-explore#3742 makes it a catchable error.
Killing one rank of a ring group leaves every surviving rank hung. Two separate defects combine to produce it. 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 earlier call with no data available set it, so the failure is skipped, the error count never rises, and the worker spins on a dead socket at 100% CPU without logging anything. sendAll() and recvAll() in the nccl backend already treat <= 0 as failure. Reaching the error threshold does not release waiters either. The worker returned, leaving every queued task's promise unsatisfied. Because the SocketThread outlives its worker those promises are not destroyed, so no broken_promise is delivered, the futures never become ready, and every wait blocks forever. Treat r == 0 as an error on both the send and recv paths, and reject the pending promises rather than returning, so the failure reaches whoever is waiting instead of the collective completing without this peer's contribution. The internal waits become get() so the exception is observed rather than discarded. Until the error can be carried from the stream thread to the main thread the exception terminates the process rather than surfacing to the caller. That is still a diagnosable stop rather than a silent hang or a wrong result, and ml-explore#3742 makes it a catchable error.
Killing one rank of a ring group leaves every surviving rank hung. Two separate defects combine to produce it. 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 earlier call with no data available set it, so the failure is skipped, the error count never rises, and the worker spins on a dead socket at 100% CPU without logging anything. sendAll() and recvAll() in the nccl backend already treat <= 0 as failure. Reaching the error threshold does not release waiters either. The worker returned, leaving every queued task's promise unsatisfied. Because the SocketThread outlives its worker those promises are not destroyed, so no broken_promise is delivered, the futures never become ready, and every wait blocks forever. Treat r == 0 as an error on both the send and recv paths, and reject the pending promises rather than returning, so the failure reaches whoever is waiting instead of the collective completing without this peer's contribution. The internal waits become get() so the exception is observed rather than discarded. Until the error can be carried from the stream thread to the main thread the exception terminates the process rather than surfacing to the caller. That is still a diagnosable stop rather than a silent hang or a wrong result, and ml-explore#3742 makes it a catchable error.
|
Ran into a case this doesn't cover yet, in the ring backend after #4060. When a peer disconnects, SocketThread fails the pending promises and the exception is Wiring the ring dispatch sites into this PR's mechanism closes it: the dispatched closures What I checked: a CPU-stream One note: even with the fix, a process that exits while the ring is still up can hit repro (2-rank localhost ring, kill one rank mid-run)# repro.py, one process per rank:
# MLX_RANK=0 MLX_HOSTFILE=hosts.json python repro.py
# MLX_RANK=1 MLX_HOSTFILE=hosts.json python repro.py
# hosts.json: [["127.0.0.1:15500"], ["127.0.0.1:15501"]]
# kill -9 the rank-1 process mid-run; rank 0 dies with SIGABRT on main.
import time
import mlx.core as mx
group = mx.distributed.init(backend="ring")
cpu = mx.default_stream(mx.Device(mx.cpu))
a = mx.ones((256, 256))
expected = float(group.size()) * a.size
for it in range(2000):
z = mx.distributed.all_sum(a, group=group, stream=cpu)
y = (z @ mx.eye(256)).sum() # gpu consumer, fence-linked to z
try:
mx.eval(y)
except RuntimeError as e:
print(f"it {it}: caught {e}")
break
assert float(y) == expected
time.sleep(0.01) |
|
Confirmed this myself, since #4060 is what introduced the gap. Reproduced on an M5 Max, macOS 26.6, 2-rank localhost ring on current main, using your repro. Rank 0 dies The Your read of the mechanism matches what I'm seeing: the |
3f37869 to
1add2cf
Compare
|
Thanks for testing this PR. I have rebased it on #4174 and change the code to simply catch and transfer all exceptions in CPU streams. |
|
Tested the rebase against the peer-loss case. It closes it, with no change to Built
The currently released version hangs rather than aborts, since #4060 has not shipped in a Why no ring change is needed
st.enqueue([&st, task = std::move(task)]() mutable {
try {
task();
} catch (const std::exception& error) {
if (!st.error.valid()) { ... }
}
});and One thing I looked for and did not findIn auto task_wrap = [s = stream_, task = std::move(task)]() mutable {
task();
scheduler::notify_task_completion(s);
};The new catch sits outside that lambda, in Not touching @sashko-zakharchuk's ring work: the two pieces beyond the abort, draining segment |
Thanks for noticing that, I think it is a valid concern that totally could happen. I added a fix for that. |
This PR implements exception handling for errors happened in
eval_cpu. Similar to #3523, the cpu scheduler would poison all pending events in the stream whenever an error happened, and an exception would throw when the poisoned event is synchronized.Most of this PR is doing refactoring:
metal::EventImplto the publicEventclass.Schedulerto make it capable of setting errors in events.Schedulermethods to signal/wait events.Note that most of the errors happened in
eval_cpuwould be fatal and not recoverable, so this PR does not catch all errors, instead we have to catch the expected errors and pass to the scheduler explicitly, this PR handles the IO error inLoad::eval_cpuas example.