[BUG][Metal] Deadlock in fence - #4552
Merged
Merged
Conversation
zcbenz
approved these changes
Sep 24, 2026
zcbenz
left a comment
Member
There was a problem hiding this comment.
Very good job locating the deadlock!
Co-authored-by: Cheng <zcbenz@gmail.com>
Co-authored-by: Cheng <zcbenz@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview of the bug:
There were issues reported about deadlocks while using
MLX_METAL_FAST_SYNCH=1:The hypothesis was that stall happens because memory coherence is not guaranteed.
After some debugging, I noticed that the problem happens when we have a lot of independent reductions in one tape (More about it later).
CPU-GPU synchronization:
We do communication operations on cpu, compute (usually) on gpu. Gpu should mark inputs ready for cpu to do collective communication operation. With
MLX_METAL_FAST_SYNCH=1we can sync fast between cpu and gpu using counter in shared memory.How operations were scheduled before this PR:
Main thread traverses the graph and constructs a tape, encodes operations in to gpu command buffer and cpu queue. To synchronise, we use one fence per stream, both fences have counters to track how many
fence::update()were done -- how many inputs for consumer stream are ready. While encoding main thread tracks number offence::update()scheduled by the producer stream.Important part is that consumer stream is waiting on a counter that is equal to this scheduled number of updates, independently from the number of
fence::updates()finished. To be concrete, suppose the graph of operations is:In the above case we do independent reductions.
Then command buffer will look like: [matmul, matmul, fence::update(), fence::update(), fence::wait(), fence::wait(), ..]
Number of fences is 2 -- main threads counter is 2.
CPU queue would look like: [(while C<2) {}, all_reduce(), all reduce() ]
Basically, cpu won't start the execution of all reduce before gpu is finished preparing inputs for all all_reduce that are in the same tape. And this is exactly where stall is happening.
Debugging:
I was able to find a case when this bug is reproducible:
gpu work and back to back reduction.
I was debugging it on a cluster on 4 M5 ultras, connected with a ring of 3 cables per neighbour.
Steps to identify this stall with the above example:
ps -Ao pid,etime,%cpu,command | grep <script>— show the asymmetry between cpu utilisationStalled node: CPU utilisation is 100% -- 1 stalled system thread, idle jaccl ring threads
Healthy nodes: CPU utilisation is 300% -- 3 threads: system thread + 2 jaccl thread pool threads.
sample pid 3 -mayDie -f /tmp/out.txt— ttaches to a live process and captures the call stack of every thread, shows exact stalls on cpu threadsStalled node: mlx::core::Fence::wait(mlx::core::Stream, mlx::core::array const&)::$_1 on system cpu thread, jaccl threads are idle -> we have not entered all reduce.
Healthy nodes: <ring_pass> -- healthy nodes are at all_reduce waiting for a stalled node
So based on the above, cpu system threads has not entered the all_reduce and waiting on the fence that was scheduled to wait on the input computed by gpu.
(here I asked Claude to help me and disassemble the code and identify registers where these counters are stored)
lldb -p <pid>then we need to attache to stalled thread (for this we do thread list -> identify stalled one)
What above tells us: main thread scheduled 12 fence::updates. GPU is done with 8 of the inputs. CPU has 0 -- 0 all_reduces are done.
GPU is stuck in the middle of inputs preparation for some reason. It is not clear whether GPU updates 9–12 never executed or whether the CPU cannot see their writes, and the counters alone don’t distinguish those cases.
Sum up.
In general, this is incorrect. Every fence::wait should be waiting for a specific fence::update. This pull request fixes the scheduling and correct scheduling fixes the stall.