Skip to content

Commit 3eabb7b

Browse files
committed
[bugfix] gate the fused Ulysses a2a on one host, and stop re-voting per call
Two defects measured on 4x GB200, MiniMax-H3 geometry, per attention layer (NCCL baseline 2421us at sp=4, 1295us at sp=8 across two trays): as shipped sp=4 2035us (1.19x) sp=8 3102us (2.4x SLOWER than NCCL) with these sp=4 1590us (1.52x) sp=8 declines, 1300us ncclTeamLsa answers "addressable", not "fast". NCCL 2.29 extends the LSA team across a multi-node NVLink domain, so on a GB200 rack the gate passes for ranks on different trays and the kernel arms. Its fine-grained 16B remote stores are far slower there than NCCL's bulk transfers. Require a single host, which is the regime the slab decomposition was tuned for, and which matches flashinfer's own gate. torch 2.12 (the pin) bundles NCCL 2.29.7, so this is reachable today. _can_attempt now computes its local verdict without collectives and then runs one unconditional all_gather_object carrying (hostname, local_ok). A collective behind a rank-local early return hangs the group whenever ranks disagree -- exactly the case this gate exists to detect. Verified: with one rank reporting the kernel unavailable, the earlier ordering timed out at 180s while this completes with correct results on every rank. The per-call agreement cost a flat ~227us regardless of operand size -- a host-side gloo all_gather before every collective. The signature is architectural: two distinct values (scatter and gather shapes) across 50 layers x 4 steps. Cache the verdict so the collective runs twice per generation instead of ~400 times. The cache trades one property: a rank whose signature diverges mid-run now misses the cache and calls the collective alone, hanging rather than falling back. Re-voting every N calls would bound that if wanted.
1 parent a159b63 commit 3eabb7b

1 file changed

Lines changed: 39 additions & 6 deletions

File tree

fastvideo/distributed/device_communicators/ulysses_a2a.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
passes over local memory. Anything else falls back to the NCCL path.
77
"""
88

9+
import socket
10+
911
import torch
1012
import torch.distributed as dist
1113
from torch.distributed import ProcessGroup
@@ -76,6 +78,9 @@ def __init__(self, cpu_group: ProcessGroup, device_group: ProcessGroup, world_si
7678
self.pynccl_comm = pynccl_comm
7779

7880
self._handle: int | None = None
81+
# One signature per (shape, dtype, mode), so the control collective
82+
# runs twice per generation instead of once per call.
83+
self._verdicts: dict[tuple[int, ...], tuple[bool, bool, bool]] = {}
7984
self._nbytes = 0
8085
self._disabled_reason: str | None = None
8186

@@ -95,15 +100,37 @@ def _comm_ptr(self) -> int:
95100
return int(getattr(comm, "value", comm))
96101

97102
def _can_attempt(self) -> tuple[bool, str]:
98-
"""Whether this rank could use the fused path, without allocating anything."""
103+
"""Whether this rank could use the fused path, without allocating anything.
104+
105+
The exchange is unconditional: a collective behind a rank-local early
106+
return hangs the group exactly when ranks disagree.
107+
"""
108+
# LSA means addressable, not fast: NCCL 2.29 spans trays on a GB200
109+
# rack, where the kernel's 16B remote stores lose to NCCL.
110+
local_ok = True
111+
local_reason = ""
99112
try:
100113
from fastvideo_kernel import comm_ops
101114
if not comm_ops.is_available():
102-
return False, "fastvideo-kernel was built without the Ulysses a2a kernel"
103-
if not comm_ops.lsa_covers_group(self._comm_ptr(), self.world_size):
104-
return False, "the group is not a load-store-accessible (NVLink) mesh"
115+
local_ok, local_reason = False, "fastvideo-kernel was built without the Ulysses a2a kernel"
116+
elif not comm_ops.lsa_covers_group(self._comm_ptr(), self.world_size):
117+
local_ok, local_reason = False, "the group is not a load-store-accessible (NVLink) mesh"
105118
except Exception as e: # noqa: BLE001
106-
return False, f"backend unavailable ({type(e).__name__}: {e})"
119+
local_ok, local_reason = False, f"backend unavailable ({type(e).__name__}: {e})"
120+
121+
try:
122+
gathered: list[tuple[str, bool]] = [("", False)] * self.world_size
123+
dist.all_gather_object(gathered, (socket.gethostname(), local_ok), group=self.cpu_group)
124+
except Exception as e: # noqa: BLE001
125+
return False, f"topology exchange failed ({type(e).__name__}: {e})"
126+
127+
hostnames = {host for host, _ in gathered}
128+
if len(hostnames) > 1:
129+
return False, f"ranks span multiple hosts: {sorted(hostnames)}"
130+
if not local_ok:
131+
return False, local_reason
132+
if not all(ok for _, ok in gathered):
133+
return False, "a peer rank cannot use the fused path"
107134
return True, ""
108135

109136
def _agree(self, ok: bool) -> bool:
@@ -286,6 +313,7 @@ def close(self) -> bool:
286313
state is leaked until process exit and permanently disabled instead of
287314
risking a distributed deadlock.
288315
"""
316+
self._verdicts.clear()
289317
handle = self._handle
290318
all_armed = self._agree(handle is not None)
291319
all_unarmed = self._agree(handle is None)
@@ -355,7 +383,12 @@ def try_all_to_all_4D(self, x: torch.Tensor, scatter_dim: int, gather_dim: int)
355383
return None
356384

357385
signature, reason = self._call_signature(x, scatter_dim, gather_dim)
358-
use_fused, permanently_unavailable, lifecycle_consistent = self._agree_call(signature)
386+
cached = self._verdicts.get(signature)
387+
if cached is not None:
388+
use_fused, permanently_unavailable, lifecycle_consistent = cached
389+
else:
390+
use_fused, permanently_unavailable, lifecycle_consistent = self._agree_call(signature)
391+
self._verdicts[signature] = (use_fused, permanently_unavailable, lifecycle_consistent)
359392
if not use_fused:
360393
if not lifecycle_consistent:
361394
self.close()

0 commit comments

Comments
 (0)