Skip to content

Commit 01db9f5

Browse files
Address PR review (items 2+3): reuse CP groups across pool cases
world_size and the rank set don't change for the lifetime of one pool, so recreating the world group and a2a+p2p sub-groups per case wastes ~50-100 ms of NCCL setup each. Pre-create them once in the pool worker (new helper _create_cp_comm_groups), stash on the run_attention_with_cp module via module-level _pool_cp_comm_group / _pool_cp_comm_sub_groups pointers, and reuse them from run_dpa_with_cp in pool mode. Pool teardown destroys them once at shutdown. Also move per-case dist.new_group() calls inside the try/finally in run_dpa_with_cp: a failure mid-loop in the a2a+p2p sub_group population otherwise leaks every communicator created before the failure. The finally now only destroys groups we created locally (cp_comm_group / sub_groups populated in the else-branch), leaving pool-owned groups alone for reuse. cyanguwa's review feedback on PR #2993. Signed-off-by: Sudhakar Singh <sudhakars@nvidia.com>
1 parent ade421a commit 01db9f5

2 files changed

Lines changed: 109 additions & 51 deletions

File tree

tests/pytorch/attention/run_attention_with_cp.py

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@
3030
)
3131
from utils import ModelConfig, compare_and_assert
3232

33+
# Pool mode (NVTE_CP_POOL_PG=1) only: shared CP collective groups, created once
34+
# per pool by run_attention_with_cp_pool.main() and reused across every case in
35+
# that pool. world_size and the rank set don't change per case, so re-creating
36+
# these per call would be wasted NCCL setup (~50-100 ms each). Single-shot
37+
# subprocess mode leaves these None / [] and run_dpa_with_cp creates/destroys
38+
# its own groups inline.
39+
_pool_cp_comm_group = None
40+
_pool_cp_comm_sub_groups: list = []
41+
3342
dtypes = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp8": torch.bfloat16}
3443

3544

@@ -249,28 +258,36 @@ def run_dpa_with_cp(
249258
if not _pool_managed_pg:
250259
dist.init_process_group(backend="nccl", world_size=world_size, rank=rank)
251260

252-
# set up communication group for CP
261+
# Set up communication group for CP. In pool mode, the pool worker has
262+
# already pre-created world-scoped and a2a+p2p sub-groups once and stashed
263+
# them in module-level pointers; we reuse those and the pool destroys them
264+
# at shutdown. In single-shot mode we create them per call and destroy in
265+
# the finally below.
253266
cp_comm_ranks = range(world_size)
254267
assert rank in cp_comm_ranks
255-
cp_comm_group = dist.new_group(cp_comm_ranks, backend="nccl")
256-
# Always defined so the finally cleanup below is safe even when cp_comm_type != "a2a+p2p".
257-
cp_comm_sub_groups = []
258-
if cp_comm_type == "a2a+p2p":
259-
assert world_size % 2 == 0, (
260-
"{cp_comm_type=} requires world_size % 2 = 0 as it assumes the a2a level has cp_size"
261-
" = 2."
262-
)
263-
cp_comm_sub_ranks = [range(i * 2, (i + 1) * 2) for i in range(world_size // 2)]
264-
cp_comm_sub_ranks += [range(i, world_size, 2) for i in range(2)]
265-
for sub_ranks in cp_comm_sub_ranks:
266-
sub_group = dist.new_group(sub_ranks, backend="nccl")
267-
if rank in sub_ranks:
268-
cp_comm_sub_groups.append(sub_group)
269-
270-
# Wrap test work in try/finally so the per-case NCCL communicators created above
271-
# are destroyed even when the body raises (otherwise pool mode leaks one or more
272-
# communicators per failed case, eventually exhausting NCCL resources).
268+
_reusing_pool_groups = _pool_managed_pg and _pool_cp_comm_group is not None
269+
cp_comm_group = None
270+
cp_comm_sub_groups: list = []
271+
# Wrap setup + body so any failure between new_group calls and the end of
272+
# the test still hits the cleanup below — otherwise a partial sub-group
273+
# population (e.g. NCCL refuses new_group mid-loop) leaks communicators.
273274
try:
275+
if _reusing_pool_groups:
276+
cp_comm_group = _pool_cp_comm_group
277+
cp_comm_sub_groups = _pool_cp_comm_sub_groups if cp_comm_type == "a2a+p2p" else []
278+
else:
279+
cp_comm_group = dist.new_group(cp_comm_ranks, backend="nccl")
280+
if cp_comm_type == "a2a+p2p":
281+
assert world_size % 2 == 0, (
282+
"{cp_comm_type=} requires world_size % 2 = 0 as it assumes the a2a level has"
283+
" cp_size = 2."
284+
)
285+
cp_comm_sub_ranks = [range(i * 2, (i + 1) * 2) for i in range(world_size // 2)]
286+
cp_comm_sub_ranks += [range(i, world_size, 2) for i in range(2)]
287+
for sub_ranks in cp_comm_sub_ranks:
288+
sub_group = dist.new_group(sub_ranks, backend="nccl")
289+
if rank in sub_ranks:
290+
cp_comm_sub_groups.append(sub_group)
274291
if dtype == "fp8":
275292
if scaling_mode == "delayed":
276293
fp8_recipe = DelayedScaling(fp8_dpa=fp8_dpa, fp8_mha=fp8_mha)
@@ -805,19 +822,24 @@ def run_dpa_with_cp(
805822
logging.info(f"[Rank {rank}] CP vs no-CP: {names[i]} matches")
806823

807824
finally:
808-
# destroy distribution group(s); runs even on exception
809-
if _pool_managed_pg:
810-
# Pool owns the main PG; only clean up groups created for this case.
811-
try:
812-
dist.destroy_process_group(cp_comm_group)
813-
except Exception:
814-
pass
825+
# Destroy only groups WE created. In pool mode with shared groups,
826+
# cp_comm_group / cp_comm_sub_groups are owned by the pool runner and
827+
# destroyed at pool shutdown — touching them here would tear down the
828+
# cache that subsequent cases want to reuse.
829+
if not _reusing_pool_groups:
830+
if cp_comm_group is not None:
831+
try:
832+
dist.destroy_process_group(cp_comm_group)
833+
except Exception:
834+
pass
815835
for g in cp_comm_sub_groups:
816836
try:
817837
dist.destroy_process_group(g)
818838
except Exception:
819839
pass
820-
else:
840+
# Tear down the main PG only in single-shot mode. In pool mode the
841+
# pool runner owns the main PG and destroys it at shutdown.
842+
if not _pool_managed_pg:
821843
try:
822844
dist.destroy_process_group()
823845
except Exception:

tests/pytorch/attention/run_attention_with_cp_pool.py

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
call run_dpa_with_cp(**kwargs) — the same work function the
1616
per-case subprocess design uses, with NVTE_CP_POOL_PG=1 so the
1717
function reuses our PG instead of re-initing it
18-
torch.cuda.empty_cache() + gc.collect() per case
18+
torch.cuda.empty_cache() per case
1919
all ranks gather (ok, error_msg) to rank 0
2020
rank 0:
2121
write one JSON response line to stdout
@@ -26,7 +26,6 @@
2626
response: {"ok": true}
2727
{"ok": false, "error": "first failing rank's traceback"}
2828
"""
29-
import gc
3029
import json
3130
import os
3231
import sys
@@ -43,18 +42,6 @@
4342
from transformer_engine.pytorch.quantization import FP8GlobalStateManager
4443

4544

46-
# Env vars run_dpa_with_cp re-sets at the top of every call. We pop them
47-
# defensively between cases so a future caller that *doesn't* re-set them
48-
# can't inherit a leftover value from a previous case in the same worker.
49-
_TRANSIENT_ENV_KEYS = (
50-
"NVTE_FP8_DPA_BWD",
51-
"NVTE_DPA_FP8CS_O_in_F16",
52-
"NVTE_FLASH_ATTN",
53-
"NVTE_FUSED_ATTN",
54-
"NVTE_ALLOW_NONDETERMINISTIC_ALGO",
55-
)
56-
57-
5845
def _recv_request(rank: int) -> dict:
5946
box = [None]
6047
if rank == 0:
@@ -79,17 +66,17 @@ def _send_response(rank: int, payload: dict) -> None:
7966
def _reset_between_cases() -> None:
8067
"""Drop state that would otherwise cascade across cases.
8168
82-
Matches the per-case startup of the single-shot worker (``_run_single_config``
83-
on the per-case-subprocess branch): identical RNG seed at the start of every
84-
case, FP8 state cleared, transient env vars cleared, allocator clean.
69+
Matches the per-case startup of the single-shot worker
70+
(``_run_single_config`` on the per-case-subprocess branch): identical RNG
71+
seed at the start of every case, FP8 state cleared, allocator clean.
72+
``run_dpa_with_cp`` re-sets ``NVTE_FUSED_ATTN``/``NVTE_FLASH_ATTN``
73+
unconditionally and pops the other transient env vars itself, so no
74+
explicit pop is needed here.
8575
"""
8676
torch.manual_seed(1234)
8777
torch.cuda.manual_seed(1234)
8878
FP8GlobalStateManager.reset()
89-
for env_key in _TRANSIENT_ENV_KEYS:
90-
os.environ.pop(env_key, None)
9179
torch.cuda.empty_cache()
92-
gc.collect()
9380

9481

9582
_case_counter = 0
@@ -125,13 +112,49 @@ def _run_one(req: dict, rank: int) -> tuple[bool, str]:
125112
return ok, err
126113

127114

115+
def _create_cp_comm_groups(rank: int, world_size: int) -> tuple:
116+
"""Pre-create the CP collective groups for this pool.
117+
118+
world_size and the rank set are constant for the lifetime of one pool, so
119+
the world group and the a2a+p2p sub-groups are deterministic. Creating
120+
them once here and reusing them across every case eliminates ~50-100 ms
121+
of NCCL setup per case (cyanguwa's review feedback on PR #2993).
122+
123+
Returns ``(world_group, a2a_p2p_sub_groups)``. ``a2a_p2p_sub_groups`` is
124+
empty when world_size is too small to support a2a+p2p (needs an even
125+
world_size ≥ 4); cases with cp_comm_type='a2a+p2p' wouldn't be routed to
126+
such a pool anyway.
127+
"""
128+
world_group = dist.new_group(range(world_size), backend="nccl")
129+
sub_groups: list = []
130+
if world_size >= 4 and world_size % 2 == 0:
131+
# Mirror the layout in run_attention_with_cp.py: cp_size/2 pairs along
132+
# axis 0, plus 2 stride-2 groups along axis 1.
133+
cp_comm_sub_ranks = [range(i * 2, (i + 1) * 2) for i in range(world_size // 2)]
134+
cp_comm_sub_ranks += [range(i, world_size, 2) for i in range(2)]
135+
for sub_ranks in cp_comm_sub_ranks:
136+
sub_group = dist.new_group(sub_ranks, backend="nccl")
137+
if rank in sub_ranks:
138+
sub_groups.append(sub_group)
139+
return world_group, sub_groups
140+
141+
128142
def main() -> None:
129143
rank = int(os.environ["RANK"])
130144
world_size = int(os.environ["WORLD_SIZE"])
131145
torch.cuda.set_device(rank % torch.cuda.device_count())
132146
dist.init_process_group(backend="nccl", rank=rank, world_size=world_size)
133147
os.environ["NVTE_CP_POOL_PG"] = "1"
134148

149+
# Stash pool-shared CP groups on the run_attention_with_cp module so
150+
# run_dpa_with_cp can read them per case. Imported here (after the env var
151+
# is set) to keep import-time side effects minimal.
152+
import run_attention_with_cp as _rac
153+
154+
_rac._pool_cp_comm_group, _rac._pool_cp_comm_sub_groups = _create_cp_comm_groups(
155+
rank, world_size
156+
)
157+
135158
try:
136159
while True:
137160
req = _recv_request(rank)
@@ -141,12 +164,10 @@ def main() -> None:
141164
ok, msg = _run_one(req, rank)
142165

143166
gathered: list[tuple[bool, str]] = [None] * world_size # type: ignore[list-item]
167+
# gather_object is itself a collective synchronization point — if
168+
# every rank reached it, none is ahead. No extra barrier needed.
144169
dist.gather_object((ok, msg), gathered if rank == 0 else None, dst=0)
145170

146-
# Surface a wedged communicator here, before the next case's
147-
# collectives can inherit the corruption.
148-
dist.barrier()
149-
150171
if rank == 0:
151172
all_ok = all(o for o, _ in gathered)
152173
if all_ok:
@@ -155,6 +176,21 @@ def main() -> None:
155176
first_err = next(m for o, m in gathered if not o)
156177
_send_response(rank, {"ok": False, "error": first_err})
157178
finally:
179+
# Tear down pool-shared CP groups before the main PG (NCCL requires
180+
# sub-groups to be destroyed first). Each destroy is independently
181+
# guarded so a wedged communicator on one group doesn't leak the rest.
182+
if _rac._pool_cp_comm_group is not None:
183+
try:
184+
dist.destroy_process_group(_rac._pool_cp_comm_group)
185+
except Exception:
186+
pass
187+
for g in _rac._pool_cp_comm_sub_groups:
188+
try:
189+
dist.destroy_process_group(g)
190+
except Exception:
191+
pass
192+
_rac._pool_cp_comm_group = None
193+
_rac._pool_cp_comm_sub_groups = []
158194
dist.destroy_process_group()
159195

160196

0 commit comments

Comments
 (0)