Skip to content

Commit eba5d27

Browse files
authored
Avoid redundant copies in MPS P2P staging (#8314)
## Summary - make staged MPS `isend` input-only: take one CPU snapshot and retain it through completion without copying it back - make staged MPS `irecv` output-only: allocate an empty CPU target without reading the destination, then publish it only after the underlying bare `wait()` succeeds - preserve the backend's native `None` return for non-member ranks and never publish an unconfirmed or failed receive buffer - keep `StagedWork.wait()` deliberately narrow instead of broadening the Work contract Follow-up to #8303. The directional-staging scope was approved by @delock in [this review comment](#8303 (comment)). ## Why #8303 made `isend` and `irecv` genuinely asynchronous and deferred MPS publication until completion. The generic staging path still treats every tensor as both input and output, which adds two transfers that cannot affect a point-to-point result: - `isend` copies the staged CPU payload back to an unchanged MPS source after `wait()`; - `irecv` copies the old MPS destination to CPU before the receive overwrites it. P2P direction is known at the call site, so those copies can be removed without changing the default in-out staging contract used by other collectives. ## Correctness contract - `isend` keeps its CPU payload reachable through the returned work handle. - `irecv` copies CPU to MPS only after the backend `wait()` returns successfully. - a failed receive leaves the caller's destination unchanged. - a backend `None` return remains `None`, including non-member group ranks, and never publishes the empty receive target. - `is_completed()`, timeout-aware `wait()`, `get_future()`, and `result()` remain outside this change, matching the maintainer-approved scope. ## Verification Exact local head: `5caf2b6f9aef9ee52a2c3ba58a1a24059cf35add`. - all repository pre-commit hooks for both changed files - four single-process real-MPS direction, failure, and `None`-return tests: `4 passed` - real two-rank MPS/Gloo member transfer plus non-member subgroup behavior: `2 passed` - real two-rank CPU/Gloo member transfer plus non-member subgroup behavior: `2 passed` - complete `tests/unit/comm/test_dist.py` on MPS: `16 passed, 19 skipped` - `git diff --check` The new four-test direction/failure matrix was first run against the unchanged #8303 source: `3 failed, 1 passed`. It is `4 passed` with this patch. ## Local staging evidence An instrumented no-network microbenchmark on one Apple M5 Pro host (Python 3.12.13, PyTorch 2.13.0) confirmed the expected Python/ATen-visible cross-device copy matrix at 1, 16, and 64 MiB: | Operation | Creation | Wait | |---|---|---| | `isend` | one MPS-to-CPU snapshot | no CPU-to-MPS copy-back | | `irecv` | no MPS-to-CPU pre-copy | one CPU-to-MPS publication after success | For the eliminated local phases, median p50 reductions were 99.84-99.96% for `isend` wait and 96.41-98.11% for `irecv` creation. These measurements isolate wrapper staging overhead with a completed fake Work; they are not end-to-end Gloo, pipeline, training, or multi-node speedup claims. Signed-off-by: Fu Xiaonan <ht3fudatou@163.com>
1 parent 6e3bd08 commit eba5d27

2 files changed

Lines changed: 133 additions & 20 deletions

File tree

deepspeed/comm/torch.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ def wait(self):
9696

9797

9898
class StagedWork:
99-
"""Completes a staged collective by copying the CPU results back to the original device tensors."""
99+
"""Completes a staged collective after the underlying work's bare ``wait()`` succeeds.
100+
101+
The narrow signature is deliberate: unsupported timeout and polling APIs must fail loudly.
102+
"""
100103

101104
def __init__(self, work, copy_back):
102105
self.work = work
@@ -115,21 +118,29 @@ def _needs_cpu_staging(tensor):
115118
return isinstance(tensor, torch.Tensor) and tensor.device.type == 'mps'
116119

117120

118-
def stage_on_cpu(func=None, *, always_async=False):
121+
def stage_on_cpu(func=None, *, always_async=False, copy_to_cpu=True, copy_from_cpu=True):
119122
"""Runs a collective on CPU copies of any MPS tensor arguments, then copies the results back.
120123
121124
This is what lets DeepSpeed use the gloo backend on Apple Silicon, where device tensors are
122125
not supported by any torch.distributed backend. Unified memory keeps the copies cheap.
123126
124127
always_async is for ops like isend/irecv that are asynchronous by contract but have no
125128
async_op parameter: the copy back must wait until the returned work handle completes.
129+
130+
copy_to_cpu=False allocates an output-only CPU target without reading the device tensor.
131+
copy_from_cpu=False retains an input-only CPU snapshot without copying it back.
126132
"""
127133
if func is None:
128-
return lambda wrapped_func: stage_on_cpu(wrapped_func, always_async=always_async)
134+
return lambda wrapped_func: stage_on_cpu(
135+
wrapped_func,
136+
always_async=always_async,
137+
copy_to_cpu=copy_to_cpu,
138+
copy_from_cpu=copy_from_cpu,
139+
)
129140

130141
def _stage(arg, pairs):
131142
if _needs_cpu_staging(arg):
132-
cpu_tensor = arg.to('cpu')
143+
cpu_tensor = arg.to('cpu') if copy_to_cpu else torch.empty_like(arg, device='cpu')
133144
pairs.append((arg, cpu_tensor))
134145
return cpu_tensor
135146
if isinstance(arg, list) and any(_needs_cpu_staging(t) for t in arg):
@@ -148,12 +159,17 @@ def wrapper(self, *args, **kwargs):
148159
work = func(self, *args, **kwargs)
149160

150161
def copy_back():
151-
for device_tensor, cpu_tensor in pairs:
152-
device_tensor.copy_(cpu_tensor)
162+
if copy_from_cpu:
163+
for device_tensor, cpu_tensor in pairs:
164+
device_tensor.copy_(cpu_tensor)
153165

154166
# async_op is usually forwarded positionally, so resolve it against the real signature.
155167
bound_args = signature.bind(self, *args, **kwargs)
156168
if always_async or bound_args.arguments.get('async_op', False):
169+
# torch.distributed returns None for ranks outside the requested group. Without a
170+
# completion handle, an output-only staging buffer must never be published.
171+
if work is None:
172+
return None
157173
return StagedWork(work, copy_back)
158174
copy_back()
159175
return work
@@ -433,12 +449,12 @@ def recv(self, tensor, src=None, group=None, tag=0):
433449
return torch.distributed.recv(tensor=tensor, src=src, group=group, tag=tag)
434450

435451
@disable_compiler_collective
436-
@stage_on_cpu(always_async=True)
452+
@stage_on_cpu(always_async=True, copy_from_cpu=False)
437453
def isend(self, tensor, dst, group=None, tag=0):
438454
return torch.distributed.isend(tensor=tensor, dst=dst, group=group, tag=tag)
439455

440456
@disable_compiler_collective
441-
@stage_on_cpu(always_async=True)
457+
@stage_on_cpu(always_async=True, copy_to_cpu=False)
442458
def irecv(self, tensor, src=None, group=None, tag=0):
443459
return torch.distributed.irecv(tensor=tensor, src=src, group=group, tag=tag)
444460

tests/unit/comm/test_dist.py

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,13 @@ def test(self, num_elements):
131131
class FakeP2PWork:
132132
"""Stands in for a torch.distributed Work so the staging wrapper can be tested single-process."""
133133

134-
def __init__(self, fill=None):
134+
def __init__(self, fill=None, error=None):
135135
self.fill = fill
136+
self.error = error
136137

137138
def wait(self):
139+
if self.error is not None:
140+
raise self.error
138141
if self.fill is not None:
139142
self.fill()
140143
return True
@@ -145,55 +148,136 @@ class TestMpsStagedP2P:
145148

146149
def test_irecv_defers_copy_back_to_wait(self, monkeypatch):
147150
from deepspeed.comm.torch import TorchBackend, StagedWork
148-
captured = {}
151+
captured = {'device_reads': 0, 'publishes': 0}
152+
backend = TorchBackend.__new__(TorchBackend)
153+
received = torch.zeros(16, dtype=torch.float32, device='mps')
154+
real_to = torch.Tensor.to
155+
real_cpu = torch.Tensor.cpu
156+
real_copy = torch.Tensor.copy_
157+
158+
def spy_to(tensor, *args, **kwargs):
159+
result = real_to(tensor, *args, **kwargs)
160+
if tensor is received and result.device.type == 'cpu':
161+
captured['device_reads'] += 1
162+
return result
163+
164+
def spy_cpu(tensor, *args, **kwargs):
165+
result = real_cpu(tensor, *args, **kwargs)
166+
if tensor is received:
167+
captured['device_reads'] += 1
168+
return result
169+
170+
def spy_copy(tensor, source, *args, **kwargs):
171+
result = real_copy(tensor, source, *args, **kwargs)
172+
if source is received and tensor.device.type == 'cpu':
173+
captured['device_reads'] += 1
174+
if tensor is received and source.device.type == 'cpu':
175+
captured['publishes'] += 1
176+
return result
149177

150178
def fake_irecv(tensor, src=None, group=None, tag=0):
151179
captured['staged'] = tensor
152180
return FakeP2PWork(fill=lambda: tensor.copy_(torch.arange(16, dtype=torch.float32)))
153181

182+
monkeypatch.setattr(torch.Tensor, 'to', spy_to)
183+
monkeypatch.setattr(torch.Tensor, 'cpu', spy_cpu)
184+
monkeypatch.setattr(torch.Tensor, 'copy_', spy_copy)
154185
monkeypatch.setattr(torch.distributed, 'irecv', fake_irecv)
155-
backend = TorchBackend.__new__(TorchBackend)
156-
received = torch.zeros(16, dtype=torch.float32, device='mps')
157186

158187
handle = backend.irecv(received, src=0)
159188

160-
# gloo must see a CPU tensor, and the MPS tensor must stay untouched until wait().
161189
assert isinstance(handle, StagedWork)
162190
assert captured['staged'].device.type == 'cpu'
191+
assert captured['device_reads'] == 0
192+
assert captured['publishes'] == 0
163193
assert received.abs().sum().item() == 0
164194
assert handle.wait() is True
165-
assert torch.equal(received.cpu(), torch.arange(16, dtype=torch.float32))
195+
assert captured['publishes'] == 1
196+
assert torch.equal(real_cpu(received), torch.arange(16, dtype=torch.float32))
166197

167198
def test_isend_stages_payload_on_cpu(self, monkeypatch):
168199
from deepspeed.comm.torch import TorchBackend, StagedWork
169-
captured = {}
200+
captured = {'payload_reads': 0, 'payload_copy_backs': 0}
201+
backend = TorchBackend.__new__(TorchBackend)
202+
payload = torch.arange(16, dtype=torch.float32, device='mps')
203+
real_to = torch.Tensor.to
204+
real_copy = torch.Tensor.copy_
205+
206+
def spy_to(tensor, *args, **kwargs):
207+
result = real_to(tensor, *args, **kwargs)
208+
if tensor is payload and result.device.type == 'cpu':
209+
captured['payload_reads'] += 1
210+
return result
211+
212+
def spy_copy(tensor, source, *args, **kwargs):
213+
result = real_copy(tensor, source, *args, **kwargs)
214+
if tensor is payload:
215+
captured['payload_copy_backs'] += 1
216+
return result
170217

171218
def fake_isend(tensor, dst=None, group=None, tag=0):
172219
captured['staged'] = tensor
173220
return FakeP2PWork()
174221

222+
monkeypatch.setattr(torch.Tensor, 'to', spy_to)
223+
monkeypatch.setattr(torch.Tensor, 'copy_', spy_copy)
175224
monkeypatch.setattr(torch.distributed, 'isend', fake_isend)
176-
backend = TorchBackend.__new__(TorchBackend)
177-
payload = torch.arange(16, dtype=torch.float32, device='mps')
178225

179226
handle = backend.isend(payload, dst=0)
180227

181228
assert isinstance(handle, StagedWork)
182229
assert captured['staged'].device.type == 'cpu'
183230
assert torch.equal(captured['staged'], torch.arange(16, dtype=torch.float32))
231+
assert captured['payload_reads'] == 1
184232
assert handle.wait() is True
233+
assert captured['payload_reads'] == 1
234+
assert captured['payload_copy_backs'] == 0
235+
236+
def test_irecv_failure_does_not_publish_uninitialized_staging(self, monkeypatch):
237+
from deepspeed.comm.torch import TorchBackend
238+
backend = TorchBackend.__new__(TorchBackend)
239+
received = torch.full((16, ), 7.0, dtype=torch.float32, device='mps')
240+
captured = {}
241+
242+
def fake_irecv(tensor, src=None, group=None, tag=0):
243+
captured['staged'] = tensor
244+
return FakeP2PWork(error=RuntimeError("receive failed"))
245+
246+
monkeypatch.setattr(torch.distributed, 'irecv', fake_irecv)
247+
handle = backend.irecv(received, src=0)
248+
249+
assert captured['staged'].device.type == 'cpu'
250+
with pytest.raises(RuntimeError, match="receive failed"):
251+
handle.wait()
252+
assert torch.equal(received, torch.full_like(received, 7.0))
253+
254+
def test_irecv_without_work_preserves_native_return_and_destination(self, monkeypatch):
255+
from deepspeed.comm.torch import TorchBackend
256+
backend = TorchBackend.__new__(TorchBackend)
257+
received = torch.full((16, ), 7.0, dtype=torch.float32, device='mps')
258+
259+
monkeypatch.setattr(torch.distributed, 'irecv', lambda *args, **kwargs: None)
260+
handle = backend.irecv(received, src=0)
261+
262+
assert handle is None
263+
assert torch.equal(received, torch.full_like(received, 7.0))
185264

186265

187266
class TestDistIsendIrecv(DistributedTest):
188267
world_size = 2
189268

190269
def _launch_procs(self, num_procs, init_method):
191270
# 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':
271+
# device_count(), which reports sockets on CPU and one physical device on MPS. Bypass
272+
# the gate for both; MPS still needs spawned processes so each rank gets a Metal context.
273+
device = get_accelerator().device_name()
274+
if device not in ['cpu', 'mps']:
195275
return super()._launch_procs(num_procs, init_method)
196276
self.backend = 'gloo'
277+
if device == 'mps':
278+
self.non_daemonic_procs = True
279+
self.reuse_dist_env = False
280+
return self._launch_non_daemonic_procs(num_procs, init_method)
197281
torch.multiprocessing.set_start_method('forkserver', force=True)
198282
self._launch_daemonic_procs(num_procs, init_method)
199283

@@ -215,6 +299,19 @@ def test(self):
215299
expected = torch.arange(length, dtype=torch.float32).to(device)
216300
assert torch.equal(received, expected)
217301

302+
def test_non_member_irecv_preserves_native_return(self):
303+
rank = dist.get_rank()
304+
device = get_accelerator().device_name()
305+
subgroup = dist.new_group(ranks=[0])
306+
307+
if rank == 1:
308+
received = torch.full((4096, ), 7.0, dtype=torch.float32, device=device)
309+
handle = dist.irecv(received, src=0, group=subgroup)
310+
311+
assert handle is None
312+
assert torch.equal(received, torch.full_like(received, 7.0))
313+
dist.barrier()
314+
218315

219316
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16])
220317
@pytest.mark.parametrize("num_elements", [128, 3])

0 commit comments

Comments
 (0)