Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

514 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc., or its affiliates.
# SPDX-License-Identifier: MIT
"""Run rocroller tests in parallel shards.
Usage: run-tests-sharded PROFILE [BUILD_DIR]
Number of shards is auto-detected as ncores/2.
"""
import argparse
import os
import pty
import random
import select
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
# Add lib directory to path for rrtest import
sys.path.insert(0, str(Path(__file__).parent / "lib"))
from rrtest import get_test_commands
@dataclass
class ShardInfo:
"""Information about a running test shard."""
process: subprocess.Popen
master_fd: int
shard_tag: str
shard_index: int
test_type: str
executable: str
output_buffer: str = ""
start_time: float = 0.0
def get_gpu_arch():
"""Detect GPU architecture (e.g., gfx1201) if possible."""
try:
result = subprocess.run(
["rocminfo"],
capture_output=True,
text=True,
check=True,
timeout=5,
)
# Look for gfx architecture in output
for line in result.stdout.splitlines():
if "Name:" in line and "gfx" in line.lower():
# Extract gfx architecture (e.g., gfx1201)
parts = line.lower().split()
for part in parts:
if part.startswith("gfx"):
return part.strip()
except:
pass
return None
def get_available_cpus():
"""Get the number of CPUs available, respecting cgroups and affinity."""
try:
result = subprocess.run(
["nproc"], capture_output=True, text=True, check=True, timeout=1
)
return int(result.stdout.strip())
except:
pass
return os.cpu_count() or 4
def get_shuffle_seed():
"""Return one random seed for all shards (gtest and catch2).
Uses TEST_SHUFFLE_SEED env var if set (for reproducibility);
otherwise picks a random seed.
Returns:
(seed, from_env): seed is an int in [1, 2**31-1]; from_env is True if
the seed came from TEST_SHUFFLE_SEED.
"""
master = os.environ.get("TEST_SHUFFLE_SEED")
if master is not None:
return int(master), True
return random.SystemRandom().randint(1, 2**31 - 1), False
def start_shard(
shard_index,
base_cmd,
test_type,
num_shards,
build_dir,
shard_seed=None,
):
"""Start a shard of tests and return process info.
Args:
shard_index: Index of this shard (0-based)
base_cmd: Base command list from get_test_commands (includes executable and filters)
test_type: Framework name (e.g., "gtest", "catch2", "ctest")
num_shards: Total number of shards
build_dir: Build directory path
shard_seed: Optional pre-computed random seed for this shard (used for gtest/catch2).
"""
# Extract executable name from command
executable = base_cmd[0]
# Generate shard tag prefix from framework name (first letter uppercase)
test_prefix = test_type[0].upper()
shard_tag = f"[{test_prefix}{shard_index:02d}]"
print(f"{shard_tag} Starting shard", flush=True)
# Set environment variables
env = os.environ.copy()
# Parallelization
env["OPENBLAS_NUM_THREADS"] = "2"
env["OMP_NUM_THREADS"] = "2"
# Debug logging for failed kernel generation
env["AMD_COMGR_REDIRECT_LOGS"] = "stdout"
# Start with base command from profile and add framework-specific sharding arguments
cmd = base_cmd.copy()
if test_type == "gtest":
if shard_seed is None:
raise ValueError("shard_seed is required for gtest")
env["GTEST_TOTAL_SHARDS"] = str(num_shards)
env["GTEST_SHARD_INDEX"] = str(shard_index)
cmd.extend(
[
"--gtest_shuffle",
f"--gtest_random_seed={shard_seed}",
f"--gtest_output=xml:test_report/gtest_shard_{shard_index}.xml",
]
)
elif test_type == "catch2":
if shard_seed is None:
raise ValueError("shard_seed is required for catch2")
cmd.extend(
[
"--order",
"rand",
"--shard-count",
str(num_shards),
"--shard-index",
str(shard_index),
"--rng-seed",
str(shard_seed),
"--reporter",
f"junit::out=test_report/catch2_shard_{shard_index}.xml",
"--reporter",
"console::out=-::colour-mode=ansi",
]
)
elif test_type == "ctest":
# CTest doesn't have built-in sharding. Let CTest use threads instead.
num_threads = 4
cmd.extend(
[
"--parallel",
str(num_threads),
"--output-on-failure",
"--output-junit",
f"test_report/ctest_shard_{shard_index}.xml",
]
)
# Log environment variables we set (added or overridden)
orig_env = os.environ
set_vars = [
(k, env[k])
for k in sorted(env.keys())
if k not in orig_env or orig_env.get(k) != env[k]
]
if set_vars:
print(f"{shard_tag} Environment:", flush=True)
for k, v in set_vars:
print(f"{shard_tag} {k}={v}", flush=True)
# Log the command being executed
cmd_str = " ".join(cmd)
print(f"{shard_tag} Running command in {build_dir}:", flush=True)
print(f"{shard_tag} {cmd_str}", flush=True)
# Use pseudo-TTY to ensure line-buffered output from subprocess
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
env=env,
cwd=build_dir,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
# Close slave fd in parent process
os.close(slave_fd)
return ShardInfo(
process=process,
master_fd=master_fd,
shard_tag=shard_tag,
shard_index=shard_index,
test_type=test_type,
executable=executable,
output_buffer="",
start_time=time.time(),
)
def run_shards(shard_infos):
"""Run multiple shards and multiplex their I/O using select.
Returns: (failed_shards, timing_info)
failed_shards: list of (shard_index, test_type, exit_code) tuples
timing_info: list of (shard_index, test_type, executable, elapsed_seconds) tuples
"""
active_shards = {info.master_fd: info for info in shard_infos}
failed_shards = []
timing_info = []
while active_shards:
fds = list(active_shards.keys())
ready, _, _ = select.select(fds, [], [], 0.1)
for fd in ready:
info = active_shards[fd]
try:
chunk = os.read(fd, 4096).decode("utf-8", errors="replace")
if chunk:
info.output_buffer += chunk
# Process complete lines
while "\n" in info.output_buffer:
line, info.output_buffer = info.output_buffer.split("\n", 1)
line = line.rstrip("\r")
if line:
print(f"{info.shard_tag} {line}", flush=True)
except OSError:
# File descriptor closed or error
pass
# Check which processes have finished
finished_fds = []
for fd, info in active_shards.items():
if info.process.poll() is not None:
finished_fds.append(fd)
# Handle finished processes
for fd in finished_fds:
info = active_shards[fd]
# Read any remaining output
try:
while True:
chunk = os.read(fd, 4096).decode("utf-8", errors="replace")
if not chunk:
break
info.output_buffer += chunk
except OSError:
pass
# Print any remaining buffered output
if info.output_buffer:
for line in info.output_buffer.split("\n"):
line = line.rstrip("\r")
if line:
print(f"{info.shard_tag} {line}", flush=True)
# Close file descriptor
os.close(fd)
# Get return code and elapsed time
return_code = info.process.wait()
elapsed_time = time.time() - info.start_time
# Record timing information
timing_info.append(
(info.shard_index, info.test_type, info.executable, elapsed_time)
)
if return_code == 0:
print(
f"{info.shard_tag} Completed successfully in {elapsed_time:.1f}s",
flush=True,
)
else:
print(
f"{info.shard_tag} Failed with exit code {return_code} after {elapsed_time:.1f}s",
flush=True,
)
failed_shards.append((info.shard_index, info.test_type, return_code))
# Remove from active shards
del active_shards[fd]
return failed_shards, timing_info
def main():
parser = argparse.ArgumentParser(
description="Run rocroller tests in parallel shards"
)
parser.add_argument(
"profile",
help="Test profile to use from test-profiles.yaml (e.g., precheckin, codecov, smoke)",
)
parser.add_argument(
"build_dir",
nargs="?",
default="build",
help="Path to build directory (default: build)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="List tests that would run with the given profile, but don't execute them",
)
args = parser.parse_args()
# Auto-detect number of available CPUs (respecting cgroups) and use half
num_cores = get_available_cpus()
num_shards = max(1, num_cores // 2)
# Cap shards to 8 on gfx1201 due to hanging issues
gpu_arch = get_gpu_arch()
if gpu_arch and "gfx1201" in gpu_arch:
if num_shards > 8:
print(f"Detected {gpu_arch}, capping shards to 8 (was {num_shards})")
num_shards = 8
# Cap shards to 48 maximum
if num_shards > 48:
print(f"Capping shards to 48 (was {num_shards})")
num_shards = 48
# Resolve build directory
build_dir = Path(args.build_dir).resolve()
if not build_dir.exists():
print(f"ERROR: Build directory does not exist: {build_dir}")
sys.exit(1)
# Get test commands from profile using rrtest library
try:
commands = get_test_commands(args.profile)
print(f"Using test profile: {args.profile}")
print(f"Frameworks: {', '.join(commands.keys())}")
except Exception as e:
print(f"ERROR: Failed to load test profile '{args.profile}': {e}")
sys.exit(1)
print("=" * 50)
print("Running sharded tests" + (" [DRY RUN]" if args.dry_run else ""))
print(f"Build directory: {build_dir}")
print(f"Test profile: {args.profile}")
print(f"Available cores: {num_cores}")
if gpu_arch:
print(f"GPU architecture: {gpu_arch}")
if not args.dry_run:
print(f"Number of shards: {num_shards} (auto-detected)")
shuffle_seed, from_env = get_shuffle_seed()
if from_env:
print(
f"Shuffle seed: {shuffle_seed} (from TEST_SHUFFLE_SEED, reproducible)"
)
else:
print(f"Shuffle seed: {shuffle_seed} (set TEST_SHUFFLE_SEED to reproduce)")
if args.dry_run:
print("Mode: DRY RUN - listing tests only")
print("=" * 50)
# Create test report directory
test_report_dir = build_dir / "test_report"
test_report_dir.mkdir(exist_ok=True)
# Handle dry-run mode: list tests instead of executing
if args.dry_run:
print("\n=== DRY RUN MODE: Listing tests that would be executed ===\n")
for framework, cmd_list in commands.items():
for cmd in cmd_list:
print(f"--- {framework.upper()} Test Suite ---")
list_cmd = cmd.copy()
# Add framework-specific list arguments
if framework == "gtest":
list_cmd.append("--gtest_list_tests")
elif framework == "catch2":
list_cmd.append("--list-tests")
elif framework == "ctest":
list_cmd.extend(["--show-only=json-v1"])
result = subprocess.run(list_cmd, cwd=build_dir)
if result.returncode != 0:
print(
f"ERROR: Failed to list {framework} tests (exit code {result.returncode})"
)
sys.exit(1)
print()
print("=== DRY RUN COMPLETE ===")
sys.exit(0)
# Run all shards for each framework
failed_shards = []
all_timing_info = []
# Process each framework in order
for framework, cmd_list in commands.items():
for cmd in cmd_list:
# Determine number of shards based on framework
# CTest runs tests in parallel internally, so only run 1 "shard"
framework_num_shards = 1 if framework == "ctest" else num_shards
print(f"\n--- Running {framework.upper()} shards ---")
shard_infos = []
for i in range(framework_num_shards):
try:
seed = shuffle_seed if framework in ("gtest", "catch2") else None
info = start_shard(
i,
cmd,
framework,
framework_num_shards,
build_dir,
shard_seed=seed,
)
shard_infos.append(info)
except Exception as e:
print(
f"ERROR: Failed to start {framework} shard {i}: {e}", flush=True
)
failed_shards.append((i, framework, 1))
# Run all shards for this framework and wait for completion
framework_failures, framework_timing = run_shards(shard_infos)
failed_shards.extend(framework_failures)
all_timing_info.extend(framework_timing)
print()
# Print timing summary
if all_timing_info:
print("=" * 50)
print("TIMING SUMMARY")
print("=" * 50)
# Group timings by executable
executable_timings = {}
for idx, test_type, executable, elapsed in all_timing_info:
if executable not in executable_timings:
executable_timings[executable] = {"framework": test_type, "timings": []}
executable_timings[executable]["timings"].append((idx, elapsed))
# Print timings for each executable
for executable in sorted(executable_timings.keys()):
data = executable_timings[executable]
timings = data["timings"]
framework = data["framework"]
prefix = framework[0].upper()
print(f"\n{executable} ({framework}):")
for idx, elapsed in sorted(timings):
print(f" [{prefix}{idx:02d}] {elapsed:6.1f}s")
total = sum(t for _, t in timings)
avg = total / len(timings)
min_time = min(t for _, t in timings)
max_time = max(t for _, t in timings)
print(
f" Total: {total:.1f}s, Avg: {avg:.1f}s, Min: {min_time:.1f}s, Max: {max_time:.1f}s"
)
print("=" * 50)
print()
# Report results
if failed_shards:
print("FAILED: One or more test shards failed:")
for shard_index, test_type, exit_code in failed_shards:
print(f" - {test_type} shard {shard_index}: exit code {exit_code}")
sys.exit(1)
else:
print("SUCCESS: All test shards completed successfully.")
print()
print(f"Test results written to: {test_report_dir}/")
# List generated XML files
xml_files = sorted(test_report_dir.glob("*.xml"))
if xml_files:
for xml_file in xml_files:
size = xml_file.stat().st_size
print(f" - {xml_file.name} ({size:,} bytes)")
sys.exit(0)
if __name__ == "__main__":
main()