Skip to content

Commit cc51e19

Browse files
committed
tests/ccl: graph-capture + varying-input coverage for gluon/triton all_gather & all_to_all
1 parent 0c3be44 commit cc51e19

2 files changed

Lines changed: 196 additions & 139 deletions

File tree

tests/ccl/test_all_gather_gluon.py

Lines changed: 106 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
# SPDX-License-Identifier: MIT
22
# Copyright (c) 2025 Advanced Micro Devices, Inc. All rights reserved.
33

4+
"""All-gather correctness across execution modes and input regimes.
5+
6+
`mode` controls how the collective is driven:
7+
eager_barrier : eager, async_op=False (trailing ctx.barrier())
8+
eager_nobarrier : eager, async_op=True (no trailing barrier)
9+
graph : HIP-graph capture + replay, async_op=True (the host barrier
10+
is illegal during capture, so async_op is required)
11+
`vary=False` replays identical input each step; `vary=True` feeds a fresh input,
12+
which surfaces stale cross-rank reads. impl: torch (torch.distributed reference)
13+
plus the triton and gluon iris backends (selected by config.use_gluon).
414
"""
5-
Test suite for all-gather collective operation using Gluon.
6-
"""
7-
8-
import os
915

1016
import pytest
1117
import torch
1218
import torch.distributed as dist
1319

14-
# Try to import Gluon, skip tests if not available
1520
try:
1621
import iris
1722
from iris.ccl import Config
@@ -22,84 +27,119 @@
2227
GLUON_AVAILABLE = False
2328

2429

30+
NUM_REPLAYS = 200
31+
32+
33+
def _all_gather(impl, src, stage_buf, result, shmem, config, async_op):
34+
"""Stage src into the input buffer, then all-gather. Module-level (no closure
35+
over shmem) so the test can ``del shmem`` for IPC cleanup."""
36+
stage_buf.copy_(src)
37+
if impl == "torch":
38+
dist.all_gather_into_tensor(result, stage_buf)
39+
else:
40+
shmem.ccl.all_gather(result, stage_buf, config=config, async_op=async_op)
41+
42+
43+
def _make_buffers(impl, shmem, rank, world_size, M, N, dtype, block_size_m, block_size_n):
44+
"""Resolve impl -> (stage_buf, result, config) in one place: torch uses plain
45+
device tensors and no config; the iris backends use symmetric-heap buffers and
46+
a use_gluon config. Output is (world_size * M, N) — block r holds rank r's input."""
47+
if impl == "torch":
48+
stage = torch.empty((M, N), dtype=dtype, device=f"cuda:{rank}")
49+
result = torch.empty((world_size * M, N), dtype=dtype, device=f"cuda:{rank}")
50+
return stage, result, None
51+
stage = shmem.zeros((M, N), dtype=dtype)
52+
result = shmem.zeros((world_size * M, N), dtype=dtype)
53+
config = Config(use_gluon=(impl == "gluon"), block_size_m=block_size_m, block_size_n=block_size_n)
54+
return stage, result, config
55+
56+
2557
@pytest.mark.skipif(not GLUON_AVAILABLE, reason="Gluon not available")
26-
@pytest.mark.parametrize(
27-
"dtype",
28-
[
29-
torch.float16,
30-
torch.float32,
31-
torch.bfloat16,
32-
],
33-
)
58+
@pytest.mark.parametrize("impl", ["torch", "triton", "gluon"])
59+
@pytest.mark.parametrize("mode", ["eager_barrier", "eager_nobarrier", "graph"])
60+
@pytest.mark.parametrize("vary", [False, True])
61+
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16], ids=["bf16", "fp16"])
3462
@pytest.mark.parametrize(
3563
"M, N, block_size_m, block_size_n",
36-
[
37-
# block_size_n must be a multiple of (threads_per_warp * num_warps).
38-
# With defaults (threads_per_warp=64, num_warps=4), minimum is 256.
39-
# elems_per_thread = block_size_n / 256: higher = wider vector loads.
40-
(256, 256, 32, 256), # Small: elems_per_thread=1 (scalar loads)
41-
(1024, 512, 32, 512), # Medium: elems_per_thread=2 (dword loads)
42-
(8192, 8192, 32, 1024), # Large: elems_per_thread=4 (dwordx4, optimal)
43-
],
64+
[(64, 8192, 32, 1024), (256, 8192, 32, 1024)],
4465
)
45-
def test_all_gather_gluon(dtype, M, N, block_size_m, block_size_n):
46-
"""Test all-gather functionality using Gluon by comparing against PyTorch's implementation."""
47-
# Ensure torch.distributed is initialized (should be done by test runner)
66+
def test_all_gather_gluon(impl, mode, vary, dtype, M, N, block_size_m, block_size_n):
67+
"""Rank r fills its whole input with 1 + r + replay%16 (exact integers), so
68+
output block r must equal 1 + r + replay%16 — any >=1 mismatch is a real drop.
69+
torch and eager_barrier are the references (correct when properly synced). The
70+
per-peer-slice fail tallies show which peers' slices dropped."""
4871
if not dist.is_initialized():
4972
pytest.skip("torch.distributed not initialized")
73+
if impl == "torch" and mode == "eager_nobarrier":
74+
pytest.skip("torch has no barrier knob; eager_barrier already covers eager torch")
5075

51-
# Size heap to fit input (M*N) + output (max_ranks*M*N) with headroom
52-
max_ranks = int(os.environ.get("WORLD_SIZE", 8))
53-
elem_size = torch.tensor([], dtype=dtype).element_size()
54-
needed = (1 + max_ranks) * M * N * elem_size
55-
heap_size = max(2**30, int(needed * 2)) # 2x headroom, minimum 1GB
56-
shmem = iris.iris(heap_size)
57-
rank = shmem.get_rank()
58-
world_size = shmem.get_num_ranks()
59-
60-
# Each rank has an M x N input tensor
61-
# Output is (world_size * M, N) - concatenated along dimension 0
62-
pytorch_input_tensor = torch.randn(M, N, dtype=dtype, device=f"cuda:{rank}")
63-
# Fill with deterministic values for easier debugging
64-
pytorch_input_tensor.fill_(float(rank + 1))
65-
66-
# Create output tensor for PyTorch: (world_size * M, N)
67-
pytorch_output_tensor = torch.zeros(world_size * M, N, dtype=dtype, device=f"cuda:{rank}")
68-
69-
# Run PyTorch's all_gather_into_tensor to get reference output
70-
shmem.barrier()
71-
dist.all_gather_into_tensor(pytorch_output_tensor, pytorch_input_tensor)
72-
torch.cuda.synchronize()
76+
# Resolve (impl, mode) up front; the body runs straight-line off these.
77+
async_op = mode != "eager_barrier"
78+
capture = mode == "graph"
7379

74-
# Now set up Iris Gluon all_gather
75-
iris_input_tensor = shmem.zeros((M, N), dtype=dtype)
76-
iris_input_tensor.copy_(pytorch_input_tensor)
80+
shmem = iris.iris(2**33) # 8 GB
81+
rank, world_size = shmem.get_rank(), shmem.get_num_ranks()
82+
src = torch.empty((M, N), dtype=dtype, device=f"cuda:{rank}")
83+
stage_buf, result, config = _make_buffers(impl, shmem, rank, world_size, M, N, dtype, block_size_m, block_size_n)
84+
shmem.barrier()
7785

78-
iris_output_tensor = shmem.zeros((world_size * M, N), dtype=dtype)
86+
def fill_src(replay):
87+
src.fill_(float(1 + rank + (replay % 16)))
7988

80-
# Run Iris Gluon all_gather
81-
shmem.barrier()
82-
config = Config(use_gluon=True, block_size_m=block_size_m, block_size_n=block_size_n)
83-
shmem.ccl.all_gather(iris_output_tensor, iris_input_tensor, config=config)
89+
# Warmup (runs lazy JIT/setup), then capture the step once if in graph mode.
90+
fill_src(0)
91+
_all_gather(impl, src, stage_buf, result, shmem, config, async_op)
8492
torch.cuda.synchronize()
93+
shmem.barrier()
8594

86-
# Compare results
87-
atol = 1e-3 if dtype == torch.float16 else 1e-5
88-
max_diff = torch.abs(iris_output_tensor - pytorch_output_tensor).max().item()
89-
95+
graph = None
96+
if capture:
97+
stream = torch.cuda.Stream()
98+
stream.wait_stream(torch.cuda.current_stream())
99+
with torch.cuda.stream(stream):
100+
graph = torch.cuda.CUDAGraph()
101+
graph.capture_begin()
102+
_all_gather(impl, src, stage_buf, result, shmem, config, async_op)
103+
graph.capture_end()
104+
torch.cuda.current_stream().wait_stream(stream)
105+
106+
atol = 0.5 # exact integer inputs; >=1 mismatch is a real drop
107+
failures = [] # (step, max|diff|, bad_slices)
108+
block_fail = [0] * world_size # steps each peer slice dropped
90109
try:
91-
assert torch.allclose(iris_output_tensor, pytorch_output_tensor, atol=atol), (
92-
f"Max difference: {max_diff}, expected < {atol}\n"
93-
f"Rank {rank}: Iris Gluon output doesn't match PyTorch's all_gather_into_tensor"
110+
for i in range(NUM_REPLAYS):
111+
replay = i if vary else 0
112+
fill_src(replay)
113+
if capture:
114+
graph.replay()
115+
else:
116+
_all_gather(impl, src, stage_buf, result, shmem, config, async_op)
117+
torch.cuda.synchronize()
118+
diffs = [
119+
torch.abs(result[r * M : (r + 1) * M] - float(1 + r + (replay % 16))).max().item()
120+
for r in range(world_size)
121+
]
122+
bad = [r for r in range(world_size) if diffs[r] > atol]
123+
for r in bad:
124+
block_fail[r] += 1
125+
if bad:
126+
failures.append((i, round(max(diffs[r] for r in bad), 4), bad))
127+
print(
128+
f"[rank {rank}] all_gather impl={impl} mode={mode} vary={vary} dtype={dtype} "
129+
f"{M}x{N}: {NUM_REPLAYS - len(failures)}/{NUM_REPLAYS} ok; "
130+
f"per-peer-slice fail counts={block_fail}" + (f"; first FAIL={failures[0]}" if failures else ""),
131+
flush=True,
132+
)
133+
assert not failures, (
134+
f"impl={impl} mode={mode} vary={vary} dtype={dtype} {M}x{N}: "
135+
f"{len(failures)}/{NUM_REPLAYS} steps wrong (first {failures[0]}; per-peer-slice "
136+
f"fail counts={block_fail})."
94137
)
95138
finally:
96-
# Final barrier to ensure all ranks complete before test cleanup
97-
# This helps with test isolation when running multiple tests
98-
# Note: shmem.barrier() already does cuda.synchronize()
139+
if graph is not None:
140+
del graph
99141
shmem.barrier()
100-
# Explicitly delete the shmem instance to trigger cleanup
101142
del shmem
102-
# Force garbage collection to ensure IPC handles are cleaned up
103143
import gc
104144

105145
gc.collect()

0 commit comments

Comments
 (0)