Route isend/irecv to nonblocking backend methods and stage them as async on MPS - #8303
Conversation
deepspeed.comm.isend and irecv dispatched to the blocking cdb.send and cdb.recv since the original comm backend, so callers got a blocking call and recv's return value (the source rank) instead of a waitable handle; any .wait() on the result raised AttributeError. With the routing fixed, the MPS CPU-staging wrapper must also treat these ops as asynchronous: they have no async_op parameter, so it copied the staged buffer back before the transfer completed. A new always_async flag defers the copy back to the handle's wait(). StagedWork.wait() now returns the underlying work's wait result. Verified with a two-process gloo run on Apple Silicon: irecv on an MPS tensor returns a handle and delivers the payload after wait(). Adds a two-rank isend/irecv test alongside the existing comm tests. Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f555e9ede9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The two-rank isend/irecv test skips on MPS (one device), so the always_async staging behavior was not covered by any test. Exercise it with real MPS tensors and a fake work handle: gloo must be handed a CPU tensor, and the received buffer must stay untouched until wait(). Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
|
Thanks for the concise follow-up and for the credit. I checked the CI execution after
A minimal way to preserve both paths is to bypass only that per-device gate on CPU, following the existing def _launch_procs(self, num_procs, init_method):
if get_accelerator().device_name() != "cpu":
return super()._launch_procs(num_procs, init_method)
self.backend = "gloo"
torch.multiprocessing.set_start_method("forkserver", force=True)
return self._launch_daemonic_procs(num_procs, init_method)Setting the CPU branch explicitly to Gloo avoids silently selecting oneCCL when its bindings are present. An |
DistributedTest gates process count on device_count(), which reports sockets on CPU, so the gloo regression test was collected but skipped on CI. Bypass the per-device gate on CPU (two gloo ranks do not need two devices) and pin the backend to gloo so oneCCL bindings are not silently picked up, following TestRealCheckpointUniversalConversionTPxPP. ShareMemCommBuilder now reports incompatible off Linux: its kernels use Linux-only APIs, and building them is what any CPU-backend dist init failed on under macOS once the gate no longer skipped it. Suggested by @FU-max-boop in review. Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
|
@FU-max-boop Thanks for the comments, good catch! — confirmed from the job log that the CPU runner collected but skipped the two-rank test (one socket). Applied your suggestion in 1504ea6: One extra fix surfaced by the bypass: |
The CPU fused_adam extension created its Adam_Optimizer once with default arguments and ignored the mode parameter, so FusedAdam on the CPU backend always applied decoupled (AdamW) weight decay even when constructed with adam_w_mode=False. Keep one optimizer instance per mode instead; everything else is already passed per call. This surfaced as cpu-torch-latest failures in the fp32-adam case of test_fused_adam_matches_torch. The test's bf16 cases are dropped: torch.optim in bf16 does its math in bf16 while the fused kernels compute in fp32, so it was never a valid bf16 reference. Low-precision dtypes get an explicit fp32-math reference in the FusedAdam rework. Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
| if (!initialized) { | ||
| create_adam_optimizer(0); | ||
| initialized = true; | ||
| // ds_adam_step reads lr/betas/eps/weight_decay per call; only AdamW-vs-L2 is fixed at |
There was a problem hiding this comment.
why there is adam change? Should it be moved to a separate PR if need to fix a different issue?
There was a problem hiding this comment.
Fair question — it's here because of CI sequencing rather than scope: after master was merged into this branch, the cpu-torch-latest job re-ran and failed on test_fused_adam_matches_torch (added in #8293), which blocked this PR. Digging into that failure exposed a latent bug in this file: multi_tensor_adam created its Adam_Optimizer once with default arguments and ignored the mode parameter, so FusedAdam(adam_w_mode=False) on the CPU backend always applied decoupled (AdamW) weight decay. The commit here is the minimal fix (one optimizer instance per mode) plus trimming the test's bf16 rows, whose torch-in-bf16 reference was never valid.
Happy to split it into its own PR if you prefer — the sequence would be: merge the kernel fix first, then update this branch so cpu-torch-latest stays green here. Just say the word and I'll extract it.
There was a problem hiding this comment.
This reverts commit a746b27. Signed-off-by: PKUWZP <zhipeng.rainbowserie@gmail.com>
|
Heads-up for reviewers: the CPU Adam mode fix was split out to #8307 per review feedback. Until #8307 merges, this PR's cpu-torch-latest check fails on |
|
Thanks again for carrying the narrower patch. This is explicitly not a request to expand #8303; the current PR should stay focused, and the contract below would be handled only as a separate follow-up after this PR settles. Would this minimal contract match maintainer expectations for a CPU-staged P2P
I would deliberately exclude If this direction looks right, I can prepare that separate follow-up with behavior tests for success, error/no-copy, poll-before-wait, repeated completion, and both staging directions, plus an MPS copy-count and copy-cost baseline. |
Hi @FU-max-boop, thanks for the thorough write-up. I think directional staging (point 3) is the one worth a follow-up now. It's not hypothetical — with the current in-out staging, every isend does a redundant CPU→MPS copy-back and every irecv does a redundant MPS→CPU pre-copy. On the pipeline P2P path those are real, per-call costs. Please go ahead with that patch. One thing to keep in mind while doing it: once irecv allocates with For Agreed on excluding |
## 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 deepspeedai#8303. The directional-staging scope was approved by @delock in [this review comment](deepspeedai#8303 (comment)). ## Why deepspeedai#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 deepspeedai#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>
Summary
Resolves @delock's review note on #8293 (#8293 (comment)):
irecvis asynchronous by contract and has noasync_opparameter, so the MPS CPU-staging wrapper must handle it explicitly.Two fixes:
deepspeed/comm/comm.py—isend/irecvdispatched to the blockingcdb.send/cdb.recv(since the original comm backend, DeepSpeed Comm. Backend v1 #1985). Callers got a blocking call andrecv's return value (the source rankint) instead of a waitable handle, sodist.irecv(...).wait()raisedAttributeError. This affects every backend, not just MPS — e.g. the 1-bit comm helpers (runtime/comm/{compressed,hccl,nccl}.py) calldist.isend/irecv(...).wait(). They now route tocdb.isend/cdb.irecv.deepspeed/comm/torch.py— with the routing fixed, the MPS staging wrapper's copy-back decision (keyed on anasync_opargument) ran immediately forirecv, before the transfer completed. A newalways_asyncflag onstage_on_cpudefers the copy-back to the handle'swait()forisend/irecv.StagedWork.wait()now also returns the underlying work's wait result.Verified (M5 Max, macOS 26.3, torch 2.13)
dist.irecvreturns anintand.wait()crashes; with this PR it returns a handle and the buffer holds the correct payload afterwait().DS_ACCELERATOR=mps pytest unit/comm/test_dist.py: 10 passed (multi-rank cases skip on 1 device).Test
Adds
TestDistIsendIrecv(world size 2) to the existingtests/unit/comm/test_dist.py: rank 0isends, rank 1irecvs, both assert a waitable handle and verify the payload afterwait(). 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 callsWork.result()/get_future()on staged P2P ops, and the staged CPU buffer forisendis kept alive by the deferred copy-back closure untilwait(). Huge Credit to @FU-max-boop for the thorough analysis of the Work semantics and fixes.