Repository navigation
Conversation
🤖 ds-review-bot Code Reviewv6该修改修复了所有 rank 都处于冷启动状态的空指针崩溃,但本地分支决定可能导致部分 rank 提前返回、其余 rank 卡在集合通信中。 v5该 MR 修复 #726: v4p该 MR 修复 #726:get_nccl_comm_handle 的复用分支此前无条件信任 backend._comm_ptr(),而 PyTorch 对 NCCL 通信子是惰性创建的——在冷启动(EP group 尚未执行任何 collective)时 _comm_ptr() 返回 0,导致下游 NCCLCommHandle(0) 在 C++ 侧(如 ncclTeamWorld 解引用 comm->nRanks)发生空指针崩溃。变更在复用前增加非空判断,冷启动时回退到既有的自建通信子路径,整体实现正确、改动最小且符合既有代码模式。 Files reviewed: 1 |
|
Pushed Both gaps were found by mutating the guard rather than by reading it:
The last row is the reason for this push: the escape hatch had no coverage, because the cases pinned the variable to One thing worth flagging about the comment in
One note on the For what it's worth on whether the tests earn their place: with |
get_nccl_comm_handle's reuse branch trusts backend._comm_ptr() blindly, but PyTorch creates NCCL communicators lazily: _comm_ptr() is a passive accessor that returns 0 when the ProcessGroup+device has no communicator yet. Comms materialize at the first collective on that group+device, or eagerly only when init_process_group received device_id=... . A consumer that constructs ElasticBuffer before any collective ran on the EP group (vLLM's expert-parallel serve path is a live example: TP=1, DP-sync NCCL disabled under async scheduling, barriers avoid the device group) gets NCCLCommHandle(0), and calculate_elastic_buffer_size then reaches deep_ep::nccl::get_physical_domain_size -> ncclTeamWorld(nullptr) -> deterministic segfault on every rank at serve init (reproduced 16/16 ranks, DP16/EP16). Guard the reuse: only take the borrowed-comm path when the pointer is non-null, otherwise fall through to the existing create-own-comm path. The fallback's all_gather_object is itself a collective on the group, so it lazily materializes torch's comm as a side effect and every rank takes the same branch on a cold boot. The DeepEP test suite cannot hit this because tests/utils init_dist passes device_id to init_process_group, which makes torch eager-create comms — _comm_ptr() is always valid there. Signed-off-by: Anton Alexander <dmvevents@gmail.com>
Per-rank branching on _comm_ptr() could split the group: ranks with a materialized communicator returned early while ranks without one entered the group-wide all_gather_object and blocked forever (a hang, harder to debug than the null-deref crash this PR fixes). Every rank now gathers every rank's nullness first - the collective is reached unconditionally - and reuse happens only when ALL ranks hold a live communicator, so all ranks take the same branch. Also documents that a cold-start consumer keeps its self-built comm for the lifetime of the group entry, and adds a unit test covering the uniform-reuse, uniform-null, and rank-skew cases with a mocked backend. Signed-off-by: Anton Alexander <dmvevents@gmail.com>
Two gaps in the existing cases, both found by mutating the guard: - Replacing the EP_REUSE_NCCL_COMM lookup with a literal True left every test green, so the documented escape hatch had no coverage. Added a case that sets it to 0 and asserts a managed comm is built even when all ranks report a live pointer, and that the nullness decision is skipped entirely on that path. - The cache check runs before the decision collective, so a cache hit performs no collective at all. That is correct for group-uniform callers and a hang if they diverge, so it is now pinned rather than left implied. Also switched the file to the plain test-function style used by the other tests under tests/, so it runs with python tests/utils/test_comm_reuse.py like the rest of the suite. yapf 0.40.2 and ruff 0.6.5 (requirements-lint.txt) are clean on this file. Signed-off-by: Anton Alexander <dmvevents@gmail.com>
e888ef2 to
9cad80b
Compare
`format.sh` (the Code Format Check workflow) runs yapf over every .py file that differs from main, so a touched file has to round-trip as a whole. Two of the four hunks are on lines this PR adds/moves, the other two are pre-existing on main and are folded in only because the check is file-scoped: - `have_comm = [None, ] * group.size()` (this PR): the trailing comma makes yapf explode the list across three lines; `[None] * group.size()` is the same expression and stable under yapf. Same treatment for the pre-existing `nccl_unique_ids` line two statements below it. - second blank line before the module-level `_storage = dict()` (PEP 8 E305, pre-existing). - `_storage[key] = NCCLCommHandle(...)` re-joined onto one line (fits the 120-column limit; pre-existing). No behavioural change. `bash format.sh` now passes on this branch. Signed-off-by: Anton Alexander <dmvevents@gmail.com>
Fixes #726.
get_nccl_comm_handle's reuse branch trustsbackend._comm_ptr()blindly, but PyTorch creates NCCL communicators lazily —_comm_ptr()is a passive accessor that returns 0 when the ProcessGroup+device has no communicator yet (comms materialize at the first collective on that group+device; eager creation only happens wheninit_process_groupreceiveddevice_id=...).A consumer that constructs
ElasticBufferbefore any collective has run on the EP group getsNCCLCommHandle(0), andcalculate_elastic_buffer_sizereachesget_physical_domain_size→ncclTeamWorld(nullptr)→comm->nRanksnull-deref. vLLM's expert-parallel serve path is a live reproduction: deterministic segfault on all 16 ranks (DP16/EP16) at serve init — full chain in #726.Change: only take the borrowed-comm path when the pointer is non-null; otherwise fall through to the existing create-own-comm path. On a cold group the fallback's
all_gather_objectis itself a collective on that group, so it lazily materializes torch's comm as a side effect and every rank takes the same branch — no rank divergence on the cold-boot case.Why the test suite doesn't catch it:
tests/utilsinit_distpassesdevice_id=...toinit_process_group, so torch eager-creates comms and_comm_ptr()is always valid there. The null case is only reachable from lazy-init consumers.Validated E2E on the vLLM path (Qwen3-30B-A3B-FP8, DP16/EP16, 2 nodes over EFA): with the create-own-comm path taken (behaviorally identical to this guard's fall-through on a cold group), serve comes up and sustains a c=1→64 load sweep with 0 errors.
Update: the guard is now an explicit all-rank-consistent decision — every rank gathers every rank's communicator-nullness before any rank branches, so reuse happens only when all ranks hold a live communicator and no rank can be left behind in a collective. Covered by
tests/utils/test_comm_reuse.py.