Skip to content

Commit 9311fd5

Browse files
authored
Route isend/irecv to nonblocking backend methods and stage them as async on MPS (#8303)
## Summary Resolves @delock's review note on #8293 (#8293 (comment)): `irecv` is asynchronous by contract and has no `async_op` parameter, so the MPS CPU-staging wrapper must handle it explicitly. Two fixes: 1. **`deepspeed/comm/comm.py`** — `isend`/`irecv` dispatched to the *blocking* `cdb.send`/`cdb.recv` (since the original comm backend, #1985). Callers got a blocking call and `recv`'s return value (the source rank `int`) instead of a waitable handle, so `dist.irecv(...).wait()` raised `AttributeError`. This affects every backend, not just MPS — e.g. the 1-bit comm helpers (`runtime/comm/{compressed,hccl,nccl}.py`) call `dist.isend/irecv(...).wait()`. They now route to `cdb.isend`/`cdb.irecv`. 2. **`deepspeed/comm/torch.py`** — with the routing fixed, the MPS staging wrapper's copy-back decision (keyed on an `async_op` argument) ran immediately for `irecv`, before the transfer completed. A new `always_async` flag on `stage_on_cpu` defers the copy-back to the handle's `wait()` for `isend`/`irecv`. `StagedWork.wait()` now also returns the underlying work's wait result. ### Verified (M5 Max, macOS 26.3, torch 2.13) - Real two-process gloo run with MPS tensors: on master, `dist.irecv` returns an `int` and `.wait()` crashes; with this PR it returns a handle and the buffer holds the correct payload after `wait()`. - `DS_ACCELERATOR=mps pytest unit/comm/test_dist.py`: 10 passed (multi-rank cases skip on 1 device). - ZeRO-2/3 smoke training unaffected. ### Test Adds `TestDistIsendIrecv` (world size 2) to the existing `tests/unit/comm/test_dist.py`: rank 0 `isend`s, rank 1 `irecv`s, both assert a waitable handle and verify the payload after `wait()`. Backend-agnostic, so it exercises the routing fix on CUDA/CPU CI as well. ### Relation to #8301 #8301 addresses the same note with a more extensive `StagedWork` (futures, result identity restoration, weakref buffer tracking). This PR makes the fix more concise and accurate: no current DeepSpeed users calls `Work.result()`/`get_future()` on staged P2P ops, and the staged CPU buffer for `isend` is kept alive by the deferred copy-back closure until `wait()`. Huge Credit to @FU-max-boop for the thorough analysis of the Work semantics and fixes. --------- Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
1 parent da3ca68 commit 9311fd5

4 files changed

Lines changed: 106 additions & 10 deletions

File tree

deepspeed/comm/comm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,13 +384,13 @@ def recv(tensor, src=None, group=None, tag=0, prof=False, log_name='recv', debug
384384
@timed_op
385385
def isend(tensor, dst, group=None, tag=0, prof=False, log_name='isend', debug=get_caller_func()):
386386
global cdb
387-
return cdb.send(tensor=tensor, dst=dst, group=group, tag=tag)
387+
return cdb.isend(tensor=tensor, dst=dst, group=group, tag=tag)
388388

389389

390390
@timed_op
391391
def irecv(tensor, src=None, group=None, tag=0, prof=False, log_name='irecv', debug=get_caller_func()):
392392
global cdb
393-
return cdb.recv(tensor=tensor, src=src, group=group, tag=tag)
393+
return cdb.irecv(tensor=tensor, src=src, group=group, tag=tag)
394394

395395

396396
@timed_op

deepspeed/comm/torch.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,23 +103,29 @@ def __init__(self, work, copy_back):
103103
self.copy_back = copy_back
104104

105105
def wait(self):
106+
result = None
106107
if self.work is not None:
107-
self.work.wait()
108+
result = self.work.wait()
108109
self.copy_back()
109-
return None
110+
return result
110111

111112

112113
def _needs_cpu_staging(tensor):
113114
# gloo (the only torch backend on macOS) cannot operate on MPS tensors.
114115
return isinstance(tensor, torch.Tensor) and tensor.device.type == 'mps'
115116

116117

117-
def stage_on_cpu(func):
118+
def stage_on_cpu(func=None, *, always_async=False):
118119
"""Runs a collective on CPU copies of any MPS tensor arguments, then copies the results back.
119120
120121
This is what lets DeepSpeed use the gloo backend on Apple Silicon, where device tensors are
121122
not supported by any torch.distributed backend. Unified memory keeps the copies cheap.
123+
124+
always_async is for ops like isend/irecv that are asynchronous by contract but have no
125+
async_op parameter: the copy back must wait until the returned work handle completes.
122126
"""
127+
if func is None:
128+
return lambda wrapped_func: stage_on_cpu(wrapped_func, always_async=always_async)
123129

124130
def _stage(arg, pairs):
125131
if _needs_cpu_staging(arg):
@@ -147,7 +153,7 @@ def copy_back():
147153

148154
# async_op is usually forwarded positionally, so resolve it against the real signature.
149155
bound_args = signature.bind(self, *args, **kwargs)
150-
if bound_args.arguments.get('async_op', False):
156+
if always_async or bound_args.arguments.get('async_op', False):
151157
return StagedWork(work, copy_back)
152158
copy_back()
153159
return work
@@ -427,12 +433,12 @@ def recv(self, tensor, src=None, group=None, tag=0):
427433
return torch.distributed.recv(tensor=tensor, src=src, group=group, tag=tag)
428434

429435
@disable_compiler_collective
430-
@stage_on_cpu
436+
@stage_on_cpu(always_async=True)
431437
def isend(self, tensor, dst, group=None, tag=0):
432438
return torch.distributed.isend(tensor=tensor, dst=dst, group=group, tag=tag)
433439

434440
@disable_compiler_collective
435-
@stage_on_cpu
441+
@stage_on_cpu(always_async=True)
436442
def irecv(self, tensor, src=None, group=None, tag=0):
437443
return torch.distributed.irecv(tensor=tensor, src=src, group=group, tag=tag)
438444

op_builder/cpu/comm.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# DeepSpeed Team
55

66
import os
7+
import sys
78
from .builder import CPUOpBuilder
89

910

@@ -65,6 +66,7 @@ def cxx_args(self):
6566
return ['-O2', '-fopenmp']
6667

6768
def is_compatible(self, verbose=False):
68-
# TODO: add soft compatibility check for private binary release.
69-
# a soft check, as in we know it can be trivially changed.
69+
# The shared-memory kernels use Linux-only APIs, so let other platforms fall back to gloo.
70+
if sys.platform != 'linux':
71+
return False
7072
return super().is_compatible(verbose)

tests/unit/comm/test_dist.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,94 @@ def test(self, num_elements):
128128
assert torch.all(x == result)
129129

130130

131+
class FakeP2PWork:
132+
"""Stands in for a torch.distributed Work so the staging wrapper can be tested single-process."""
133+
134+
def __init__(self, fill=None):
135+
self.fill = fill
136+
137+
def wait(self):
138+
if self.fill is not None:
139+
self.fill()
140+
return True
141+
142+
143+
@pytest.mark.skipif(get_accelerator().device_name() != 'mps', reason="covers the MPS CPU-staging path")
144+
class TestMpsStagedP2P:
145+
146+
def test_irecv_defers_copy_back_to_wait(self, monkeypatch):
147+
from deepspeed.comm.torch import TorchBackend, StagedWork
148+
captured = {}
149+
150+
def fake_irecv(tensor, src=None, group=None, tag=0):
151+
captured['staged'] = tensor
152+
return FakeP2PWork(fill=lambda: tensor.copy_(torch.arange(16, dtype=torch.float32)))
153+
154+
monkeypatch.setattr(torch.distributed, 'irecv', fake_irecv)
155+
backend = TorchBackend.__new__(TorchBackend)
156+
received = torch.zeros(16, dtype=torch.float32, device='mps')
157+
158+
handle = backend.irecv(received, src=0)
159+
160+
# gloo must see a CPU tensor, and the MPS tensor must stay untouched until wait().
161+
assert isinstance(handle, StagedWork)
162+
assert captured['staged'].device.type == 'cpu'
163+
assert received.abs().sum().item() == 0
164+
assert handle.wait() is True
165+
assert torch.equal(received.cpu(), torch.arange(16, dtype=torch.float32))
166+
167+
def test_isend_stages_payload_on_cpu(self, monkeypatch):
168+
from deepspeed.comm.torch import TorchBackend, StagedWork
169+
captured = {}
170+
171+
def fake_isend(tensor, dst=None, group=None, tag=0):
172+
captured['staged'] = tensor
173+
return FakeP2PWork()
174+
175+
monkeypatch.setattr(torch.distributed, 'isend', fake_isend)
176+
backend = TorchBackend.__new__(TorchBackend)
177+
payload = torch.arange(16, dtype=torch.float32, device='mps')
178+
179+
handle = backend.isend(payload, dst=0)
180+
181+
assert isinstance(handle, StagedWork)
182+
assert captured['staged'].device.type == 'cpu'
183+
assert torch.equal(captured['staged'], torch.arange(16, dtype=torch.float32))
184+
assert handle.wait() is True
185+
186+
187+
class TestDistIsendIrecv(DistributedTest):
188+
world_size = 2
189+
190+
def _launch_procs(self, num_procs, init_method):
191+
# Two gloo ranks do not need two devices, but the base class gates process count on
192+
# device_count(), which reports sockets on CPU and would skip this test on CI. Bypass
193+
# the gate there and pin gloo so oneCCL bindings are not silently picked up.
194+
if get_accelerator().device_name() != 'cpu':
195+
return super()._launch_procs(num_procs, init_method)
196+
self.backend = 'gloo'
197+
torch.multiprocessing.set_start_method('forkserver', force=True)
198+
self._launch_daemonic_procs(num_procs, init_method)
199+
200+
def test(self):
201+
rank = dist.get_rank()
202+
device = get_accelerator().device_name()
203+
length = 4096
204+
if rank == 0:
205+
payload = torch.arange(length, dtype=torch.float32).to(device)
206+
handle = dist.isend(payload, dst=1)
207+
else:
208+
received = torch.zeros(length, dtype=torch.float32).to(device)
209+
handle = dist.irecv(received, src=0)
210+
# isend/irecv are asynchronous by contract: they must hand back a waitable handle,
211+
# and the received buffer is only valid after wait() completes.
212+
assert hasattr(handle, 'wait')
213+
handle.wait()
214+
if rank == 1:
215+
expected = torch.arange(length, dtype=torch.float32).to(device)
216+
assert torch.equal(received, expected)
217+
218+
131219
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16])
132220
@pytest.mark.parametrize("num_elements", [128, 3])
133221
class TestDistInferenceAllReduce(DistributedTest):

0 commit comments

Comments
 (0)