Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions projects/rccl-tests/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -146,25 +146,26 @@ add_rccl_test(all_gather)
add_rccl_test(all_reduce)
add_rccl_test(alltoall)
if (ENABLE_ROCSHMEM_GIN AND TARGET roc::rocshmem)
target_compile_definitions(alltoall_perf PRIVATE ENABLE_ROCSHMEM_GIN)
target_compile_options(alltoall_perf PRIVATE -fgpu-rdc $<$<CONFIG:Debug>:-ggdb -O0>)
# --whole-archive: librccl.so (ENABLE_ROCSHMEM_GIN) leaves rocshmem host
# symbols unresolved (--allow-shlib-undefined). The executable must pull
# in ALL rocshmem symbols (not just those it references directly) so that
# -rdynamic exports them for librccl.so to resolve at runtime.
target_link_libraries(alltoall_perf PRIVATE
-Wl,--whole-archive roc::rocshmem -Wl,--no-whole-archive)
target_link_options(alltoall_perf PRIVATE -fgpu-rdc --hip-link -rdynamic)
function(_rccl_tests_link_rocshmem_gin_rdc target)
target_compile_definitions(${target} PRIVATE ENABLE_ROCSHMEM_GIN)
target_compile_options(${target} PRIVATE -fgpu-rdc $<$<CONFIG:Debug>:-ggdb -O0>)
# --whole-archive: librccl.so (ENABLE_ROCSHMEM_GIN) leaves rocshmem host
# symbols unresolved (--allow-shlib-undefined). The executable must pull
# in ALL rocshmem symbols (not just those it references directly) so that
# -rdynamic exports them for librccl.so to resolve at runtime.
target_link_libraries(${target} PRIVATE
-Wl,--whole-archive roc::rocshmem -Wl,--no-whole-archive)
target_link_options(${target} PRIVATE -fgpu-rdc --hip-link -rdynamic)
endfunction()

_rccl_tests_link_rocshmem_gin_rdc(alltoall_perf)

# GIN-SDMA hybrid AllGather (all_gather_perf -D 3): same device-RDC link as
# alltoall_perf so the GinHybridAllGatherKernel resolves the GIN device backend.
if (ENABLE_DEVICE_API)
target_compile_definitions(all_gather_perf PRIVATE ENABLE_DEVICE_API)
endif()
target_compile_options(all_gather_perf PRIVATE -fgpu-rdc $<$<CONFIG:Debug>:-ggdb -O0>)
target_link_libraries(all_gather_perf PRIVATE
-Wl,--whole-archive roc::rocshmem -Wl,--no-whole-archive)
target_link_options(all_gather_perf PRIVATE -fgpu-rdc --hip-link -rdynamic)
_rccl_tests_link_rocshmem_gin_rdc(all_gather_perf)
endif()
if (ENABLE_ROCSHMEM_GIN AND TARGET roc::rocshmem AND RCCL_SOURCE_DIR)
add_executable(gin_rocshmem_gda_factory_test ${CMAKE_CURRENT_SOURCE_DIR}/gin_rocshmem_gda_factory_test.cu)
Expand Down
356 changes: 356 additions & 0 deletions projects/rccl-tests/test/gin_sdma_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
#################################################################################
# Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell cop-
# ies of the Software, and to permit persons to whom the Software is furnished
# to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IM-
# PLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNE-
# CTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
################################################################################

"""Shared helpers for GIN-SDMA rccl-tests pytest harnesses (AllGather, AllToAll,
Broadcast). MPI launcher assembly, connectivity-gate retry, and (for Broadcast)
NCCL debug-file capture live here so the per-collective test modules stay thin."""

import glob
import os
import re
import shlex
import shutil
import signal
import subprocess
import tempfile
import time

import pytest

MiB = 1024 * 1024
GiB = 1024 * MiB

# Intermittent gfx950 cuMem-VMM connectivity-gate abort (not a data error).
CONN_GATE_RE = re.compile(
r"LSA signal connectivity gate failed|unhandled system error", re.I
)
DATA_FAIL_RE = re.compile(
r"#wrong\s*=\s*[1-9]|mismatch|check.*fail|Out of bounds values\s*:\s*[1-9]",
re.I,
)


def detect_ngpus():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_AllToAllDevtime.py is a fourth copy of this harness that did not move: _detect_ngpus at :65, and :112-127 and :151-164 re-implement gin_env_xflags, mpi_launch_prefix and launch_mpi_shell. It is also the only one of the four that device-api CI runs (projects/rccl/tools/ci/run-device-api-ci.sh:215). That file is outside this diff, so this is a follow-up note rather than something to change here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and thanks for scoping it as a follow-up rather than a change here.

test_AllToAllDevtime.py being the one of the four that device-api CI actually runs (run-device-api-ci.sh:215) is the reason I would rather move it on its own: porting it onto gin_sdma_harness changes the only pytest this job executes, and that deserves a diff where the device-api run is the thing under review, not a rider on an already-large change.

Recorded on AICOMRCCL-2221 so it does not live only in this thread.

if os.environ.get("ROCR_VISIBLE_DEVICES") is not None:
return len(os.environ["ROCR_VISIBLE_DEVICES"].split(","))
if os.environ.get("HIP_VISIBLE_DEVICES") is not None:
return len(os.environ["HIP_VISIBLE_DEVICES"].split(","))
try:
out = subprocess.check_output(
'rocminfo | grep "Device Type:.\\s*.GPU" | wc -l', shell=True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detect_ngpus cannot report a failed probe: the pipeline ends in wc -l, so a missing rocminfo still exits 0 with "0" and this returns 0, which the new max(1, ...) at test_AllGather.py:42 and test_Broadcast.py:44 turns into one GPU. A host that has GPUs but no rocminfo on PATH then silently collects a quarter of the matrix and skips every multi-rank case instead of erroring at import. CI sets the rank count explicitly (projects/rccl/tools/ci/run-gin-ci.sh:247), so this only bites manual runs. Would a shutil.which check keep the clamp's purpose without hiding a broken host?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f822f0261aa. detect_ngpus() resolves rocminfo with shutil.which and raises RuntimeError when it is absent, instead of returning 0 from a pipeline that exits 0 regardless. The count now comes from parsing rocminfo output directly rather than grep ... | wc -l.

To keep the clamp's original purpose without hiding a broken host: the module-level max(1, ...) callers in test_AllGather.py and test_Broadcast.py catch that RuntimeError at import, so the GPU-free guards still collect on a machine with no ROCm at all, while the opt-in GIN paths (AG_NP, BCAST_NP, NP) call detect_ngpus() for real and surface the error instead of silently running a one-GPU matrix. test_BroadcastGinSdmaDetectNgpusRequiresRocminfo pins the raise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by f822f0261a.

)
return int(out)
except Exception:
return 0


def env_int(name, default):
"""Parse an integer env var; bad values fall back so collection stays usable."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
return int(raw)
except ValueError:
return default


def mpi_launch_prefix(request, launcher, np, mpi_opts):
"""Assemble [launcher, -np, np, ...mpi_opts, (-host hostfile)?]."""
hostfile = request.config.getoption("--hostfile")
launch = [launcher, "-np", str(np)] + list(mpi_opts)
if hostfile:
launch += ["-host", hostfile]
return launch


def gin_env_xflags(kv_list):
"""Turn ["K=V", ...] into mpirun -x flags."""
flags = []
for kv in kv_list:
flags += ["-x", kv]
return flags


def launch_mpi_shell(cmd, timeout_s, hang_msg):
"""Run a shell MPI command in a new session; kill the process group on timeout."""
print(cmd)
proc = subprocess.Popen(
cmd,
shell=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
out, _ = proc.communicate(timeout=timeout_s)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
out, _ = proc.communicate()
pytest.fail(hang_msg.format(out=(out or "")[-2000:]))
Comment thread
dlamd1dai marked this conversation as resolved.
Resolved
print(out)
return proc.returncode, out


def run_with_conn_gate_retry(
Comment thread
dlamd1dai marked this conversation as resolved.
Resolved
launch_fn, conn_retries, settle_s=3, is_data_failure=None
):
"""Retry launch_fn on connectivity-gate aborts; never retry genuine data failures.

launch_fn must return (returncode, stdout)."""
if is_data_failure is None:
is_data_failure = lambda out: bool(DATA_FAIL_RE.search(out or ""))

rc, out = 1, ""
for attempt in range(1, max(1, conn_retries) + 1):
rc, out = launch_fn()
if rc == 0:
return rc, out
if is_data_failure(out):
return rc, out
if CONN_GATE_RE.search(out or "") and attempt < conn_retries:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three new guards leave CONN_GATE_RE unpinned: an implementation that deleted this gate match and retried every non-data failure returns the same value and the same call count in all of test_Broadcast.py:289, :300 and :314. Could one more case close it, a launch_fn returning (1, "some other error") with settle_s=0 asserting exactly one call?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f822f0261aa. test_BroadcastGinSdmaConnRetryDoesNotRetryNonGateError calls with a launch_fn returning (1, "some other error") and settle_s=0, asserting exactly one call and the returned (rc, out).

That closes the gap you describe: an implementation that deleted the CONN_GATE_RE match and retried every non-data failure passed all three existing guards, but now fails this one on call count.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by f822f0261a.

print(
"=== connectivity-gate abort (attempt {}/{}); "
"re-launching after settle ===".format(attempt, conn_retries)
)
time.sleep(settle_s)
continue
return rc, out
return rc, out


def read_nccl_debug_logs(debug_dir):
"""Merged per-rank NCCL debug output written under debug_dir."""
chunks = []
for log_path in sorted(glob.glob(os.path.join(debug_dir, "nccl-debug.*"))):
try:
with open(log_path, errors="replace") as fh:
chunks.append(fh.read())
except OSError:
pass
return "\n".join(chunks)


# --- Broadcast-specific parsing / assertions ---------------------------------

PLUGIN_RE = re.compile(r"gin-anvil-sdma", re.I)

_NUM = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
_WRONG = r"(?:{}|N/A)".format(_NUM)
BCAST_ROW_RE = re.compile(
Comment thread
dlamd1dai marked this conversation as resolved.
Resolved
r"^\s*(\d+)\s+(\d+)\s+\S+\s+\S+\s+(\d+)"
r"\s+{n}\s+{n}\s+{n}\s+({w})"
r"\s+{n}\s+{n}\s+{n}\s+({w})".format(n=_NUM, w=_WRONG)
)
BCAST_OOB_RE = re.compile(r"Out of bounds values\s*:\s*(\d+)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
BCAST_OOB_RE = re.compile(r"Out of bounds values\s*:\s*(\d+)")
# Deliberately not end-anchored: rccl-tests may append an algo/proto/nchannels
# group and a timestamp, and concurrent rank output can splice onto the line.
BCAST_ROW_RE = re.compile(
r"^\s*(\d+)\s+(\d+)\s+\S+\s+\S+\s+(\d+)"
r"\s+{n}\s+{n}\s+{n}\s+({w})"
r"\s+{n}\s+{n}\s+{n}\s+({w})".format(n=_NUM, w=_WRONG)
)
BCAST_OOB_RE = re.compile(r"Out of bounds values\s*:\s*(\d+)")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in f822f0261aa. The rationale had been restored in 6c1e471a596 but landed below the regex, where it read as describing BCAST_OOB_RE; it now sits directly above BCAST_ROW_RE as in your suggestion. The BCAST_TIER_RE pointer to bcastReportTier() in src/broadcast.cu is in place just above that regex.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by f822f0261a.

BCAST_TIER_RE = re.compile(r"^#\[bcast-tier\]\s+(\S+)", re.M)


def bcast_tiers(out):
"""Set of -D 3 tier names the run reported."""
return set(BCAST_TIER_RE.findall(out or ""))


def bcast_rows(out):
"""Measured rows as (size, root, wrong_out_of_place, wrong_in_place)."""
rows = []
for line in (out or "").splitlines():
m = BCAST_ROW_RE.match(line)
if m:
rows.append((int(m.group(1)), int(m.group(3)), m.group(4), m.group(5)))
return rows


def bcast_wrong_count(field):
"""Numeric #wrong, or None when the column is N/A because checks were off."""
try:
return float(field)
except ValueError:
return None


def bcast_data_failed(out):
"""True when the run produced demonstrably wrong results.

Deliberately not a text match on "Wrong": rccl-tests prints a "#wrong" column
header on every run that reaches the results table."""
for _, _, oop, ip in bcast_rows(out):
if any(bcast_wrong_count(f) not in (None, 0.0) for f in (oop, ip)):
return True
m = BCAST_OOB_RE.search(out or "")
return bool(m and m.group(1) != "0")


def assert_bcast_ok(rc, out, debug, ctx, expect_tier=None):
"""Assert the run bound GIN-SDMA, measured something, and checked clean."""
tail = (out or "")[-2000:]
assert rc == 0, "Broadcast {} failed (exit {}). Output tail:\n{}".format(
ctx, rc, tail
)
assert PLUGIN_RE.search(debug or "") or PLUGIN_RE.search(out or ""), (
"Broadcast {} never bound the GIN Anvil-SDMA backend, so the run proves "
"nothing about the GIN path. Output tail:\n{}".format(ctx, tail)
)
rows = bcast_rows(out)
assert rows, "Broadcast {} produced no measured rows. Output tail:\n{}".format(
ctx, tail
)
unchecked = [
(s, r)
for s, r, oop, ip in rows
if bcast_wrong_count(oop) is None or bcast_wrong_count(ip) is None
]
assert not unchecked, (
"Broadcast {} reported #wrong as N/A at (size, root) {}, so the data check "
"never ran and the result is unverified.".format(ctx, unchecked)
)
bad = [
(s, r, oop, ip)
for s, r, oop, ip in rows
if bcast_wrong_count(oop) != 0.0 or bcast_wrong_count(ip) != 0.0
]
assert (
not bad
), "Broadcast {} reported nonzero #wrong (size, root, oop, ip): {}".format(
ctx, bad
)
m = BCAST_OOB_RE.search(out or "")
assert m and m.group(1) == "0", "Broadcast {} out-of-bounds count is {}".format(
ctx, m.group(1) if m else "absent"
)
if expect_tier is not None:
allowed = (
{expect_tier} if isinstance(expect_tier, str) else set(expect_tier)
)
tiers = bcast_tiers(out)
assert tiers, (
"Broadcast {} reported no #[bcast-tier] line, so which tier ran is "
"unverified. Is RCCL_TESTS_BCAST_TIER_LOG set for this launch, and is "
"the binary new enough to emit it? Output tail:\n{}".format(ctx, tail)
)
assert len(tiers) == 1 and tiers <= allowed, (
"Broadcast {} ran tier(s) {} but expected exactly one of {}. The tier "
"gate moved, or the tier under test fell through to another "
"kernel.".format(ctx, sorted(tiers), sorted(allowed))
)


def launch_bcast_gin_sdma(
request,
msg_bytes,
dtype,
*,
exe,
np,
launcher,
mpi_opts,
xenv,
ctas,
gin_type,
timeout_s,
force_flat_gin=False,
force_sag_gin=False,
):
"""Launch broadcast_perf -D 3; returns (returncode, stdout, debug_text)."""
size = str(int(msg_bytes))
debug_dir = tempfile.mkdtemp(prefix="bcast-gin-dbg-")
gin_env = [
"NCCL_GIN_ENABLE=1",
"NCCL_GIN_TYPE={}".format(gin_type),
"NCCL_GIN_ANVIL_SDMA_THRESHOLD=0",
"NCCL_GIN_ANVIL_SDMA_THRESHOLD_BROADCAST=0",
"NCCL_DEBUG=INFO",
"NCCL_DEBUG_SUBSYS=INIT,NET",
"NCCL_DEBUG_FILE={}/nccl-debug.%h.%p.log".format(debug_dir),
"RCCL_TESTS_BCAST_TIER_LOG=1",
]
if force_flat_gin:
gin_env += [
"NCCL_GIN_ANVIL_BCAST_SCATTER_AG_MIN_BYTES=0",
"NCCL_GIN_ANVIL_BCAST_RING_MIN_BYTES=0",
]
elif force_sag_gin:
gin_env += ["NCCL_GIN_ANVIL_BCAST_RING_MIN_BYTES=0"]
gin_env += list(xenv)

args = (
mpi_launch_prefix(request, launcher, np, mpi_opts)
+ gin_env_xflags(gin_env)
+ [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The perf-arg vector is still written out three times, here and at test_AllGather.py:167-193 and test_AllToAll.py:117-141, alongside three near-identical hang messages. A shared gin_perf_argv(exe, size, dtype, ctas, extra) next to gin_env_xflags would take roughly 60 lines out, and would make the -A 1 gap either deliberate or visible: AllGather and Broadcast pass it, AllToAll does not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted in f822f0261aa. gin_perf_argv(exe, size, dtype, ctas, extra) now sits next to gin_env_xflags, and extra is spliced between -D 3 and -V, which is exactly where the collectives diverge: AllGather and Broadcast pass extra=["-A", "1"], AllToAll passes nothing. gin_hang_msg(collective, timeout_s, size, dtype, size_desc) absorbs the three near-identical hang templates.

AllGather's -B 1 stays appended after the vector rather than going through extra, since it is a trailing devtime flag rather than part of the shared shape.

On making the gap "deliberate or visible": test_BroadcastGinSdmaPerfArgvMakesAverageCountGapExplicit asserts that the AllGather argv carries -A 1 between -D and -V and the AllToAll argv does not, so the difference is now pinned rather than inferred from three copies.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by f822f0261a.

exe,
"-b",
size,
"-e",
size,
"-f",
"2",
"-g",
"1",
"-R",
"2",
"-D",
"3",
"-A",
"1",
"-V",
ctas,
"-d",
dtype,
"-c",
"1",
"-w",
"1",
"-n",
"3",
]
)
cmd = " ".join(shlex.quote(a) for a in args)
hang_msg = (
"Broadcast GIN-SDMA HANG: no completion within {}s at msg={} bytes "
"({} MiB), dtype={}. Output tail:\n{{}}".format(
timeout_s, size, msg_bytes // MiB, dtype
)
)
try:
rc, out = launch_mpi_shell(cmd, timeout_s, hang_msg)
debug = read_nccl_debug_logs(debug_dir)
finally:
shutil.rmtree(debug_dir, ignore_errors=True)
return rc, out, debug


def run_bcast_with_conn_gate_retry(launch_fn, conn_retries, settle_s=3):
"""Connectivity-gate retry for Broadcast launches that return debug text too."""
state = {"debug": ""}

def attempt():
rc, out, debug = launch_fn()
state["debug"] = debug
return rc, out

rc, out = run_with_conn_gate_retry(
attempt, conn_retries, settle_s=settle_s, is_data_failure=bcast_data_failed
)
return rc, out, state["debug"]
Loading
Loading