Skip to content

Faster synchronization Fence primitive - #1773

Merged
awni merged 7 commits into
mainfrom
alt_faster_synchronize
Jan 18, 2025
Merged

Faster synchronization Fence primitive#1773
awni merged 7 commits into
mainfrom
alt_faster_synchronize

Conversation

@awni

@awni awni commented Jan 17, 2025

Copy link
Copy Markdown
Member
  • Adds a Fence synchronization primitive for polling-based fast CPU / GPU synchronization

  • Conditionally builds the above for Metal 3.2 and up.

  • Adds an environment variable MLX_METAL_FAST_SYNCH to toggle using the fast primitive or not (default 0).

  • Updates distributed Metal ops to use the Fence so we have the option to make them fast.

  • Distributed LLM tested on M1 Max, M2 Ultra, M3 Max and working.

  • Benchmarks suggests 10x reduction in synchronization latency for all-reduce (15-20 microseconds depending on the machine, compared to 150-200 with shared events).

Microbenchmarks on M3 Max:

mpirun -np 2 python benchmarks/python/synchronize_bench.py

All Reduce: time per iteration 0.149781 (ms)
All gather: time per iteration 0.136384 (ms)

MLX_METAL_FAST_SYNCH=1 mpirun -np 2 python benchmarks/python/synchronize_bench.py

All Reduce: time per iteration 0.016508 (ms)
All gather: time per iteration 0.015927 (ms)

Comment thread mlx/backend/metal/device.cpp Outdated
if (stream.encoder == nullptr) {
stream.encoder = std::make_unique<CommandEncoder>(stream.buffer);
stream.fence = std::make_shared<Fence>(device_->newFence());
stream.encoder->wait_for_fence(stream.event_fence);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am kinda confused by this wait here. In the case where we don't use the Fence at all when would the event_fence be updated?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying an alternative that doesn't require this fence cause I don't like it. But basically you can always wait on a fence. The wait will wait for any preceding calls to update. So if you never update it the wait it is essentially a no-op.

This fence is used to ensure no kernels start before the GPU signal kernel is done. So we update this fence when we signal from the GPU and then any command encoder that waits after that will wait for that update to finish.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I pushed a change to get rid of this that should (and seems) to work, so you can disregard all the previous stuff with event_fence.

Basically it requires modifying the call to wait_gpu to take an array that we want to be sure is ready before any future kernels that depend on it run. It reuses our existing synchronization machinery (barriers + fences) and is nice in that it only encoders which actually depend on the output will wait for it.

I had to add a way to register_output_array since it's not actually part of a kernel.. but I think it's cleaner/ more efficient / and doesn't require this random stream_event which was very icky.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if you never update it the wait it is essentially a no-op.

What a horrible hidden API. I assumed wait without update is a deadlock hence the confusion before.

I had to add a way to register_output_array.

Yeah, so much better! Also only waiting on things that matter rather than everything on the whole stream. Plus avoid waiting on two fences which could have been the case before.

@angeloskath angeloskath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely beautiful!

@awni
awni merged commit a4667da into main Jan 18, 2025
@awni
awni deleted the alt_faster_synchronize branch January 18, 2025 02:42

#include <metal_atomic>

[[kernel]] void input_coherent(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the input_coherent do? Does it mean to ensure the data gets fully transferred from cpu to gpu?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to ensure the data is safe to be read by the CPU.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a metal specific thing?

I have a weird flaky test in cuda backend that an array loaded from cpu sometimes ends up with wrong value in gpu:

CHECK(array_equal(test, array({1.0, 2.0, 3.0, 4.0})).item<bool>());

I wonder if I need to do something similar here, though it is completely different hardware.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very metal specific. In fact.. most of the time this kerne is not used. It's only enabled for an experimental polling based synchronization when you set MLX_FAST_METAL_SYNCH=1.

If you are using standard cuda synchronization primitives you should need anything like that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cuda does not have a standard synchronization primitive for signaling a gpu wait from cpu, I was using cuda::atomic but its wait/notify APIs had some problems with CUDA 11 (#2137) so I'm currently using busy wait for fence which is almost identical to metal backend's fast fence.

@awni awni May 2, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so I'm currently using busy wait for fence which is almost identical to metal backend's fast fence.

Oh right. Well in that case.. we are in pretty uncharted territory. Do you know of examples that have used a similar concept in CUDA? That would be a good place to look and see if they have to do anything fancy to ensure the input safe to read.

Metal we need this because even if the fence value is written by the GPU kernel (which certainly implies and the input is done being computed) it can still be in cache and not necessarily "coherent" with the rest of the device (and system in unified memory).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I couldn't find any open source code synchronizes gpu with cpu work, and there is actually no framework using cuda's unified memory APIs. I also searched nvidia forum and there was probably 2 posts related to this topic in past 10 years.

ryan5rdx added a commit to ryan5rdx/ds4 that referenced this pull request Aug 7, 2026
The CPU->GPU release half of a tensor-parallel gate blocks the command
processor on an MTLSharedEvent. Resuming from that is the single largest
term in a TP decode token: DS4_TP_GATE_PROFILE on a 2x M2 Ultra pair
reports a steady-state gpu-wait of 470us and an exchange of 38us, and a
Metal System Trace puts the GPU's own compute at 284us, so the resume is
~186us of a 508us gate. Across 86 gates that is 16ms of a 43.6ms token --
37% of decode spent stopping and restarting the GPU. The peer is not the
problem: tp_rdma_gate_exchange already blocks on recv_done, and the 38us
it costs contains the entire rendezvous.

Add an opt-in release fence, after MLX (ml-explore/mlx#1773): the GPU
spins on a word the service thread writes instead of blocking, so the
command processor never stops. A harness mirroring the gate measures the
release at 6.0us against 101us for encodeWaitForEvent.

Both halves of the handshake have to change together. The existing
arrival publish, kernel_dsv4_tp_flag_set, does a relaxed store to an
ordinary device atomic, which is sufficient only because the command
processor blocks on an event immediately afterwards -- that stall is what
makes the write land. Under the fence the GPU never stalls, so arrival
needs system scope of its own, or the service thread spins on a stale
word for a full coherency window every gate. Hence
kernel_dsv4_tp_flag_set_coherent, selected only on the fast path.

Off by default; DS4_METAL_FAST_SYNC=1 enables it, and it falls back to
the shared event if the buffer or pipeline is unavailable. Row gates only
-- batch and big gates keep the event path, and both sides key the choice
off the same conditions, fixed at init.

Three properties are load-bearing and were established by A/B:

  coherent(system) is mandatory. With a plain volatile device pointer the
  GPU never observes the CPU store (71 of 120 releases missed); with
  coherent(device) it misses roughly one in a few hundred, which at 86
  gates per token is a hang every few tokens. Reaching that qualifier
  needs #pragma METAL internals, scoped here to just these two kernels.

  One threadgroup of one thread. Against a saturated GPU, one threadgroup
  costs 4%, two cost 21%, four cost 61%; threads within a threadgroup are
  free. The shape is deliberately not configurable.

  No backoff, no chunking. An fma backoff between polls takes the release
  from 6.0us to 13.1us (256 iters) and 33.0us (2048); splitting the wait
  into 400 bounded dispatches costs 1265us, because Metal cannot cancel
  dispatches already encoded.

The spin is iteration-bounded (DS4_TP_FENCE_MAX_ITERS) so a peer dying
mid-gate cannot wedge the command processor into a GPU watchdog kill; the
service thread already writes the release on the failure path, so the
bound is a backstop.
ryan5rdx added a commit to ryan5rdx/llama.cpp that referenced this pull request Aug 25, 2026
The GPU waits on a word the host stores, so a producer/consumer handoff
does not need the host to wait for a command buffer and then submit the
next one. Needs coherent(system), which is undocumented, so the kernels
live in their own runtime-compiled library and init returns NULL when it
is unavailable. After MLX ml-explore/mlx#1773.

Not used yet; exposed by name for backend-agnostic callers.

Assisted-by: Claude Opus
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants