[diffusion] feat: capture-safe pynccl all-to-all (#33775)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/pynccl.py
|
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/pynccl.py
|
||||||
|
|
||||||
# ===================== import region =====================
|
# ===================== import region =====================
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.distributed as dist
|
import torch.distributed as dist
|
||||||
from torch.distributed import ProcessGroup, ReduceOp
|
from torch.distributed import ProcessGroup, ReduceOp
|
||||||
@@ -232,6 +234,106 @@ class PyNcclCommunicator:
|
|||||||
cudaStream_t(stream.cuda_stream),
|
cudaStream_t(stream.cuda_stream),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def group_start(self):
|
||||||
|
self.nccl.ncclGroupStart()
|
||||||
|
|
||||||
|
def group_end(self):
|
||||||
|
self.nccl.ncclGroupEnd()
|
||||||
|
|
||||||
|
def all_to_all_single(
|
||||||
|
self,
|
||||||
|
output: torch.Tensor,
|
||||||
|
input_: torch.Tensor,
|
||||||
|
output_split_sizes: list[int] | None = None,
|
||||||
|
input_split_sizes: list[int] | None = None,
|
||||||
|
stream=None,
|
||||||
|
) -> None:
|
||||||
|
"""Equal-split all-to-all, the dist.all_to_all_single equivalent.
|
||||||
|
|
||||||
|
Exists because ProcessGroupNCCL's collectives cannot be recorded into a
|
||||||
|
CUDA graph: their host-side per-op bookkeeping advances once at capture,
|
||||||
|
so replays leave the ranks disagreeing about which collective is in
|
||||||
|
flight and they hang. Raw ncclSend/ncclRecv inside a group carries no
|
||||||
|
such state, so a captured region can hold the exchange.
|
||||||
|
"""
|
||||||
|
if self.disabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pynccl all_to_all_single called while the communicator is "
|
||||||
|
"disabled; wrap it in change_state(enable=True)"
|
||||||
|
)
|
||||||
|
assert output.dtype == input_.dtype, (output.dtype, input_.dtype)
|
||||||
|
assert input_.is_contiguous() and output.is_contiguous()
|
||||||
|
if input_split_sizes is None and output_split_sizes is None:
|
||||||
|
assert output.numel() == input_.numel(), (
|
||||||
|
output.numel(),
|
||||||
|
input_.numel(),
|
||||||
|
)
|
||||||
|
assert input_.numel() % self.world_size == 0, (
|
||||||
|
f"all_to_all_single without split sizes needs an equal split, "
|
||||||
|
f"got {input_.numel()} elements over {self.world_size} ranks"
|
||||||
|
)
|
||||||
|
if stream is None:
|
||||||
|
stream = current_stream()
|
||||||
|
# dist.all_to_all_single defines split sizes along dim 0; convert rows
|
||||||
|
# to element counts so n-D tensors split identically to torch
|
||||||
|
in_row = input_.numel() // input_.size(0) if input_.dim() else 1
|
||||||
|
out_row = output.numel() // output.size(0) if output.dim() else 1
|
||||||
|
chunk = input_.numel() // self.world_size
|
||||||
|
if input_split_sizes is None:
|
||||||
|
send_counts = [chunk] * self.world_size
|
||||||
|
else:
|
||||||
|
assert sum(input_split_sizes) == input_.size(0)
|
||||||
|
send_counts = [n * in_row for n in input_split_sizes]
|
||||||
|
if output_split_sizes is None:
|
||||||
|
recv_counts = [chunk] * self.world_size
|
||||||
|
else:
|
||||||
|
assert sum(output_split_sizes) == output.size(0)
|
||||||
|
recv_counts = [n * out_row for n in output_split_sizes]
|
||||||
|
assert len(send_counts) == len(recv_counts) == self.world_size
|
||||||
|
send = input_.view(-1)
|
||||||
|
recv = output.view(-1)
|
||||||
|
dtype = ncclDataTypeEnum.from_torch(input_.dtype)
|
||||||
|
itemsize = input_.element_size()
|
||||||
|
send_off = recv_off = 0
|
||||||
|
self.nccl.ncclGroupStart()
|
||||||
|
for peer in range(self.world_size):
|
||||||
|
self.nccl.ncclSend(
|
||||||
|
buffer_type(send.data_ptr() + send_off * itemsize),
|
||||||
|
send_counts[peer],
|
||||||
|
dtype,
|
||||||
|
peer,
|
||||||
|
self.comm,
|
||||||
|
cudaStream_t(stream.cuda_stream),
|
||||||
|
)
|
||||||
|
self.nccl.ncclRecv(
|
||||||
|
buffer_type(recv.data_ptr() + recv_off * itemsize),
|
||||||
|
recv_counts[peer],
|
||||||
|
dtype,
|
||||||
|
peer,
|
||||||
|
self.comm,
|
||||||
|
cudaStream_t(stream.cuda_stream),
|
||||||
|
)
|
||||||
|
send_off += send_counts[peer]
|
||||||
|
recv_off += recv_counts[peer]
|
||||||
|
self.nccl.ncclGroupEnd()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def change_state(self, enable: bool | None = None):
|
||||||
|
"""Enable the communicator for the duration of the block.
|
||||||
|
|
||||||
|
The graph-capture path is the only caller that needs it, so ordinary
|
||||||
|
traffic keeps going through the process group and this stays a scoped
|
||||||
|
override rather than a mode switch.
|
||||||
|
"""
|
||||||
|
if enable is None:
|
||||||
|
enable = self.available
|
||||||
|
old_disabled = self.disabled
|
||||||
|
self.disabled = not enable
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.disabled = old_disabled
|
||||||
|
|
||||||
def broadcast(self, tensor: torch.Tensor, src: int, stream=None):
|
def broadcast(self, tensor: torch.Tensor, src: int, stream=None):
|
||||||
if self.disabled:
|
if self.disabled:
|
||||||
return
|
return
|
||||||
|
|||||||
+12
@@ -256,6 +256,12 @@ class NCCLLibrary:
|
|||||||
# it is better not to call it at all.
|
# it is better not to call it at all.
|
||||||
# ncclResult_t ncclCommDestroy(ncclComm_t comm);
|
# ncclResult_t ncclCommDestroy(ncclComm_t comm);
|
||||||
Function("ncclCommDestroy", ncclResult_t, [ncclComm_t]),
|
Function("ncclCommDestroy", ncclResult_t, [ncclComm_t]),
|
||||||
|
# Batches the enclosed send/recv into one collective, which is what
|
||||||
|
# makes an all-to-all out of the pairwise primitives.
|
||||||
|
# ncclResult_t ncclGroupStart();
|
||||||
|
Function("ncclGroupStart", ncclResult_t, []),
|
||||||
|
# ncclResult_t ncclGroupEnd();
|
||||||
|
Function("ncclGroupEnd", ncclResult_t, []),
|
||||||
]
|
]
|
||||||
|
|
||||||
# class attribute to store the mapping from the path to the library
|
# class attribute to store the mapping from the path to the library
|
||||||
@@ -436,6 +442,12 @@ class NCCLLibrary:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def ncclGroupStart(self) -> None:
|
||||||
|
self.NCCL_CHECK(self._funcs["ncclGroupStart"]())
|
||||||
|
|
||||||
|
def ncclGroupEnd(self) -> None:
|
||||||
|
self.NCCL_CHECK(self._funcs["ncclGroupEnd"]())
|
||||||
|
|
||||||
def ncclCommDestroy(self, comm: ncclComm_t) -> None:
|
def ncclCommDestroy(self, comm: ncclComm_t) -> None:
|
||||||
self.NCCL_CHECK(self._funcs["ncclCommDestroy"](comm))
|
self.NCCL_CHECK(self._funcs["ncclCommDestroy"](comm))
|
||||||
|
|
||||||
|
|||||||
@@ -1145,6 +1145,7 @@ STANDALONE_FILES = {
|
|||||||
"../single_test_file/test_ar_models.py",
|
"../single_test_file/test_ar_models.py",
|
||||||
"../single_test_file/test_ipc_a2a_2_gpu.py",
|
"../single_test_file/test_ipc_a2a_2_gpu.py",
|
||||||
"../single_test_file/test_dp_serving_2_gpu.py",
|
"../single_test_file/test_dp_serving_2_gpu.py",
|
||||||
|
"../single_test_file/test_pynccl_a2a_capture_2_gpu.py",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,6 +1182,8 @@ STANDALONE_FILE_EST_TIMES = {
|
|||||||
"../single_test_file/test_ipc_a2a_2_gpu.py": 240.0,
|
"../single_test_file/test_ipc_a2a_2_gpu.py": 240.0,
|
||||||
# zimage server startup dominates; six short requests after warmup
|
# zimage server startup dominates; six short requests after warmup
|
||||||
"../single_test_file/test_dp_serving_2_gpu.py": 900.0,
|
"../single_test_file/test_dp_serving_2_gpu.py": 900.0,
|
||||||
|
# one capture plus three replays on a 32K-element exchange
|
||||||
|
"../single_test_file/test_pynccl_a2a_capture_2_gpu.py": 180.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""A pynccl all-to-all must be replay-safe inside a CUDA graph.
|
||||||
|
|
||||||
|
ProcessGroupNCCL's collectives are not: their host-side per-op bookkeeping
|
||||||
|
advances once at capture, so replays leave the ranks disagreeing about which
|
||||||
|
collective is in flight and both hang. That is why the full-forward DiT graph
|
||||||
|
cannot capture `dist.all_to_all_single`. Raw ncclSend/ncclRecv inside a group
|
||||||
|
carries no such state, so this exchange can live in a captured region -- this
|
||||||
|
test is what keeps that true.
|
||||||
|
|
||||||
|
pytest -v python/sglang/multimodal_gen/test/single_test_file/test_pynccl_a2a_capture_2_gpu.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||||
|
from sglang.test.test_utils import CustomTestCase
|
||||||
|
|
||||||
|
_WORLD = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _worker() -> int:
|
||||||
|
import torch.distributed as dist
|
||||||
|
|
||||||
|
from sglang.multimodal_gen.runtime.distributed.device_communicators.pynccl import (
|
||||||
|
PyNcclCommunicator,
|
||||||
|
)
|
||||||
|
|
||||||
|
rank = int(os.environ["RANK"])
|
||||||
|
world = int(os.environ["WORLD_SIZE"])
|
||||||
|
torch.cuda.set_device(rank)
|
||||||
|
dist.init_process_group("nccl", rank=rank, world_size=world)
|
||||||
|
cpu_group = dist.new_group(ranks=list(range(world)), backend="gloo")
|
||||||
|
comm = PyNcclCommunicator(group=cpu_group, device=torch.device(f"cuda:{rank}"))
|
||||||
|
if not comm.available:
|
||||||
|
print("SKIP pynccl unavailable", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def make(seed: int) -> torch.Tensor:
|
||||||
|
g = torch.Generator(device="cuda").manual_seed(seed)
|
||||||
|
return (
|
||||||
|
torch.randn(world * 4096, dtype=torch.bfloat16, device="cuda", generator=g)
|
||||||
|
+ rank
|
||||||
|
)
|
||||||
|
|
||||||
|
def reference(x: torch.Tensor) -> torch.Tensor:
|
||||||
|
out = torch.empty_like(x)
|
||||||
|
dist.all_to_all_single(out, x.contiguous())
|
||||||
|
return out
|
||||||
|
|
||||||
|
x = make(11)
|
||||||
|
got = torch.empty_like(x)
|
||||||
|
with comm.change_state(enable=True):
|
||||||
|
comm.all_to_all_single(got, x)
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
if not torch.equal(reference(x), got):
|
||||||
|
failures.append("eager result differs from dist.all_to_all_single")
|
||||||
|
|
||||||
|
# the point of the test: capture once, then replay against fresh inputs
|
||||||
|
static_in = make(22)
|
||||||
|
static_out = torch.empty_like(static_in)
|
||||||
|
with comm.change_state(enable=True):
|
||||||
|
comm.all_to_all_single(static_out, static_in) # NCCL wants a warm path
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
graph = torch.cuda.CUDAGraph()
|
||||||
|
with torch.cuda.graph(graph, capture_error_mode="thread_local"):
|
||||||
|
comm.all_to_all_single(static_out, static_in)
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
|
||||||
|
for i, seed in enumerate((33, 44, 55)):
|
||||||
|
fresh = make(seed)
|
||||||
|
static_in.copy_(fresh)
|
||||||
|
expected = reference(fresh)
|
||||||
|
graph.replay()
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
if not torch.equal(expected, static_out):
|
||||||
|
failures.append(f"replay {i} (seed {seed}) diverged")
|
||||||
|
|
||||||
|
# uneven dim-0 splits must match dist semantics (rows, not elements)
|
||||||
|
rows = (
|
||||||
|
torch.arange(4 * 3 * world, dtype=torch.bfloat16, device="cuda").reshape(
|
||||||
|
4 * world, 3
|
||||||
|
)
|
||||||
|
+ 100 * rank
|
||||||
|
)
|
||||||
|
in_splits = (
|
||||||
|
[1, 4 * world - 1] if world == 2 else [1] * (world - 1) + [3 * world + 1]
|
||||||
|
)
|
||||||
|
out_splits = [
|
||||||
|
in_splits[rank] for _ in range(world)
|
||||||
|
] # every rank sends in_splits[j] rows to rank j
|
||||||
|
ref_out = torch.empty(sum(out_splits), 3, dtype=torch.bfloat16, device="cuda")
|
||||||
|
dist.all_to_all_single(
|
||||||
|
ref_out, rows, output_split_sizes=out_splits, input_split_sizes=in_splits
|
||||||
|
)
|
||||||
|
got_out = torch.empty_like(ref_out)
|
||||||
|
with comm.change_state(enable=True):
|
||||||
|
comm.all_to_all_single(got_out, rows, out_splits, in_splits)
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
if not torch.equal(ref_out, got_out):
|
||||||
|
failures.append("uneven dim-0 split diverged from dist.all_to_all_single")
|
||||||
|
|
||||||
|
# the raise-when-disabled contract, with the state set explicitly: this
|
||||||
|
# communicator initializes enabled, so exiting change_state restores that
|
||||||
|
with comm.change_state(enable=False):
|
||||||
|
try:
|
||||||
|
comm.all_to_all_single(static_out, static_in)
|
||||||
|
failures.append("disabled communicator did not refuse the exchange")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
verdict = torch.tensor([len(failures)], device="cuda")
|
||||||
|
dist.all_reduce(verdict)
|
||||||
|
if failures:
|
||||||
|
print(f"rank{rank} FAIL {failures}", flush=True)
|
||||||
|
if rank == 0:
|
||||||
|
print(
|
||||||
|
f"PYNCCL_A2A_CAPTURE {'FAIL' if verdict.item() else 'PASS'}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
dist.barrier()
|
||||||
|
dist.destroy_process_group()
|
||||||
|
return 1 if verdict.item() else 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPyncclA2ACapture(CustomTestCase):
|
||||||
|
def test_all_to_all_survives_graph_replay(self):
|
||||||
|
if not current_platform.is_cuda():
|
||||||
|
self.skipTest("pynccl graph capture is exercised on CUDA only")
|
||||||
|
if torch.cuda.device_count() < _WORLD:
|
||||||
|
self.skipTest(f"needs {_WORLD} GPUs")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"torch.distributed.run",
|
||||||
|
f"--nproc-per-node={_WORLD}",
|
||||||
|
"--master-port=29519",
|
||||||
|
__file__,
|
||||||
|
"--worker",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=1200,
|
||||||
|
)
|
||||||
|
print(proc.stdout[-4000:])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr[-4000:], file=sys.stderr)
|
||||||
|
self.assertEqual(proc.returncode, 0, "pynccl all-to-all is not replay-safe")
|
||||||
|
self.assertIn("PYNCCL_A2A_CAPTURE PASS", proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--worker" in sys.argv:
|
||||||
|
raise SystemExit(_worker())
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user