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

2860 lines
105 KiB
Python

# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""
Pytest configuration and fixtures for rocprofiler-systems tests.
This module provides shared fixtures and configuration for all test modules.
"""
from __future__ import annotations
from pathlib import Path
from functools import lru_cache
from typing import Any, Callable, Generator, Optional
import re
import os
import sys
import shutil
import signal
import traceback
try:
import yaml
except ImportError: # pragma: no cover
yaml = None # type: ignore[assignment]
# Add the pytest directory to Python path for rocprofsys package
sys.path.insert(0, str(Path(__file__).parent))
import pytest
from pytest import StashKey
from rocprofsys import environment
from rocprofsys import (
RocprofsysConfig,
discover_build_config,
GPUInfo,
get_rocminfo,
detect_gpu,
get_offload_extractor,
get_target_gpu_arch,
get_xnack_support,
TestResult,
ValidationResult,
validate_regex,
validate_file_regex,
validate_perfetto_trace,
validate_rocpd_database,
validate_timemory_json,
validate_causal_json,
validate_unified_memory_outputs,
validate_file_exists,
BaselineRunner,
SamplingRunner,
BinaryRewriteRunner,
RuntimeInstrumentRunner,
SysRunRunner,
CausalRunner,
PythonRunner,
safe_remove,
)
# Key for storing the single test result on pytest items
# Item-level stash keys
_result_key: StashKey = StashKey()
_output_printed_key: StashKey[bool] = StashKey()
_original_nodeid_key: StashKey[str] = StashKey()
# GNU convention. Used for CTests
SKIP_RETURN_CODE = 77
# As test + subtests are collapsed into a single pytest, there is only one global timeout that the
# ctest will use as reference.
# The DEFAULT_TIMEOUT is used as the "run_test" timeout and
# the CTEST_TIMEOUT_BUFFER is a fixed amount added to handle subtests + flush + teardown
# CTests set their timeout to DEFAULT_TIMEOUT + CTEST_TIMEOUT_BUFFER
DEFAULT_TIMEOUT = 300
CTEST_TIMEOUT_BUFFER = 30 # Not overridable
# Timeouts for the "rocprofiler-systems-pytest-config" prerequisite test
CONFIG_TIMEOUT = 15
CONFIG_TIMEOUT_BUFFER = 5
# Accepted runner types when using parametrized "mode" marker
ROCPROFSYS_RUNNER_CLASSES = {
"baseline": BaselineRunner,
"sampling": SamplingRunner,
"binary_rewrite": BinaryRewriteRunner,
"runtime_instrument": RuntimeInstrumentRunner,
"sys_run": SysRunRunner,
"causal": CausalRunner,
"python": PythonRunner,
}
# Accepted runner types when using parametrized "mode" marker
ROCPROFSYS_RUNNER_NAMES = list(ROCPROFSYS_RUNNER_CLASSES.keys())
# ============================================================================
#
# Pytest Hooks (Placed in the general order they are called)
#
# ============================================================================
# ----------------------------------------------------------------------------
# Initialization hooks
# ----------------------------------------------------------------------------
def pytest_addoption(parser: pytest.Parser) -> None:
"""Add custom command-line options."""
group = parser.getgroup("rocprofsys", "rocprofiler-systems test options")
group.addoption(
"--show-config-only", # Only used by "rocprofiler-systems-pytest-config" test
action="store_true",
default=False,
help="Show the test configuration and exit without running any tests",
)
group.addoption(
"--ctest-mode",
action="store",
default="off",
choices=("off", "generate", "run", "cleanup"),
help="CTest integration mode (developer flag): 'off' (default), 'generate', 'run', or 'cleanup'",
)
group.addoption(
"--ctest-output-path",
action="store",
default=None,
help="Path to write the CTest definitions file when in CTest generate mode (default: None)",
)
group.addoption(
"--python-versions",
action="store",
default=None,
help="Semicolon-separated list of Python versions (e.g. '3.8;3.9;3.10')",
)
group.addoption(
"--python-root-dirs",
action="store",
default=None,
help="Semicolon-separated list of directories to search for Python interpreters in order of preference",
)
def pytest_configure(config: pytest.Config) -> None:
"""Register custom markers and configure pytest"""
config.option.verbose = max(config.option.verbose, 1) # -v
config.option.tbstyle = "short" # --tb=short
config.option.no_header = True
config.option.reportchars += "s" # -rs
# CTest generation only collects tests to emit CTestTestfile.cmake
if config.getoption("--ctest-mode", default="off") == "generate":
from rocprofsys.cache import disable_for_process
disable_for_process()
if config.getoption("--ctest-mode", default="off") == "cleanup":
_run_cleanup()
pytest.exit("Cleanup complete", returncode=0)
if config.getoption("--show-config-only", default=False):
pytest._config_ref = config
_seed_capability_cache()
header = _generate_rocprofsys_config_header()
for line in header:
print(line)
pytest.exit("Header generated", returncode=0)
# Disable pytest-timeout plugin if detected
# It will interfere with our timeout marker
timeout_plugin = config.pluginmanager.get_plugin("timeout")
if timeout_plugin:
config.pluginmanager.unregister(timeout_plugin)
# Functional markers (do more than just label a test)
# See pytest_collection_modifyitems
config.addinivalue_line(
"markers",
"gpu: mark test as requiring a GPU",
) # required for run_test to check if the target supports the current system architectures
config.addinivalue_line(
"markers",
"mpi_optional(target): If MPI is available and the target supports MPI, uses MPI to run the test",
)
config.addinivalue_line(
"markers",
"preserve(file): prevents the file from being deleted after the test, even if ROCPROFSYS_KEEP_TEST_OUTPUT is set to OFF",
)
config.addinivalue_line(
"markers",
"run_if_gpu_category(expr): run test only if GPU category expression is true "
"(e.g., 'apu and not instinct', 'instinct or radeon')",
)
config.addinivalue_line(
"markers",
"rocm_min_version(version): mark test as requiring minimum ROCm version",
)
config.addinivalue_line(
"markers",
"oshrun_min_version(version): mark test as requiring minimum OpenSHMEM version",
)
config.addinivalue_line(
"markers",
"amdsmi_min_version(version): mark test as requiring minimum AMD-SMI version",
)
config.addinivalue_line(
"markers",
"amdgpu_min_version(version): mark test as requiring minimum amdgpu driver version",
)
config.addinivalue_line(
"markers",
"rocprofiler_sdk_min_version(version): mark test as requiring minimum rocprofiler-sdk version",
)
config.addinivalue_line(
"markers",
"rocpd(env): mark test as using ROCpd and inject ROCpd env into given env",
)
config.addinivalue_line(
"markers",
"mpi_implementation(implementation): mark test as requiring specific MPI implementation",
)
config.addinivalue_line(
"markers",
"python_versions: Test will be parametrized by Python version",
)
config.addinivalue_line(
"markers",
"timeout(seconds): mark test as having a timeout of seconds (default: 300)",
)
# Used for CTest
config.addinivalue_line(
"markers",
"depends_on(*names): declare CTest dependency on the named tests"
" (used for CTest DEPENDS)",
)
config.addinivalue_line(
"markers",
"serialize: mark test as serializable (used for CTest)",
)
config.addinivalue_line(
"markers",
"class_name(segment): hyphenated logical name for standardized / CTest test "
"names (replaces the auto-derived class segment from TestCamelCase; "
"e.g. 'rocprofiler-systems-instrument')",
)
config.addinivalue_line(
"markers",
"multi_gpu(num): mark test as using requiring atleast num amount of GPUs",
)
config.addinivalue_line(
"markers",
"build_only: prevents the test from being run in install mode", # TheRock CI runs in install mode
)
# See pytest_collection_modifyitems
generic_functional_markers = [
"ucx",
"overflow",
"attach",
"mpi",
"python",
"annotate",
"julia",
"xnack",
"no_docker",
"shmem",
"nic",
"ainic_required",
]
# Informational markers, only used for test labeling
config.addinivalue_line(
"markers", "rocprofiler: mark test as using ROCProfiler counters"
)
config.addinivalue_line("markers", "slow: mark test as slow running")
config.addinivalue_line("markers", "loops: mark test as testing loop instrumentation")
# Can be described using generic desc below
non_functional_markers = [
"avail",
"instrument",
"baseline",
"sampling",
"binary_rewrite",
"runtime_instrument",
"sys_run",
"decode",
"videodecode",
"jpegdecode",
"hipfile",
"rocprof_binary",
"rocprof_config",
"xgmi",
"sdma",
"group_by_queue",
"group_by_stream",
"openmp",
"openmp_target",
"fortran",
"sampling_duration",
"no_tmp_files",
"rccl",
"rocshmem",
"roctx",
"time_window",
"transpose",
"nic",
"ainic",
"network",
"fork",
"user_api",
"thread_limit",
"pthreads",
"rewrite_caller",
"locks",
"caller_include",
"causal",
"causal_e2e",
"papi",
"code_coverage",
"lulesh",
"unit_tests",
"hip_stream",
"presets",
"cli_help",
"hpc",
"hip",
"scratch_memory",
"rocm",
"kfd",
"unified_memory",
"validation_usm",
"selective_regions",
"minimal",
"rank_filter",
"pytest_impl",
]
for label in non_functional_markers + generic_functional_markers:
config.addinivalue_line("markers", f"{label}: label test as {label}")
# Keep a module-level ref for hooks that don't receive config directly
pytest._config_ref = config
# ----------------------------------------------------------------------------
# Collection hooks
# ----------------------------------------------------------------------------
def pytest_generate_tests(metafunc):
"""Dynamically parametrize tests based on markers."""
marker = metafunc.definition.get_closest_marker("python_versions")
if marker is not None:
rocprof_config = get_rocprof_config()
supported = set(rocprof_config.capabilities.supported_python_versions or [])
# When --python-versions is explicitly passed, always parametrize
# with those exact versions so node IDs match.
# Unsupported versions are marked as "skip"
pytest_config = getattr(pytest, "_config_ref", None)
requested_str = (
pytest_config.getoption("--python-versions", default=None)
if pytest_config
else None
)
if requested_str:
requested = [v.strip() for v in requested_str.split(";") if v.strip()]
params = []
for ver in requested:
if ver in supported:
params.append(ver)
else:
params.append(
pytest.param(
ver,
marks=pytest.mark.skip(f"Python {ver} not available"),
)
)
metafunc.parametrize("python_version", params)
elif supported:
metafunc.parametrize("python_version", sorted(supported))
else:
metafunc.parametrize(
"python_version",
[pytest.param(None, marks=pytest.mark.skip("No Python versions found"))],
)
# Used by the run_if_gpu_category marker
def gpu_category_eval_context() -> dict[str, bool]:
info = get_gpu_info()
return {
"instinct": info is not None and "instinct" in info.categories,
"radeon": info is not None and "radeon" in info.categories,
"apu": info is not None and "apu" in info.categories,
}
def pytest_collection_modifyitems(config, items) -> None:
"""Modify items based on markers."""
verbose = config.option.verbose > 0
try:
rocprof_config = get_rocprof_config()
except Exception as e:
pytest.exit(f"{e}")
# ----------------------------------------------------------------------------
def base_modifications(item: pytest.Item) -> None:
"""This function should be called for every item."""
_standardize_test_name(item, config, verbose=verbose)
# Handle optional markers
# The general form is <name>_optional(...). If the condition is met, <name> marker is added
if (
"mpi_optional" in item.keywords
and mpi_unavailable_reason(rocprof_config, config) is None
):
target = item.get_closest_marker("mpi_optional").args[0]
try:
target_path = rocprof_config.get_target_executable(target)
if rocprof_config.capabilities.target_support_mpi(target_path):
item.add_marker(pytest.mark.mpi)
except FileNotFoundError:
pass
# Marker dependencies
add_marker_if(
item,
"papi",
req_mark="annotate",
unavailable_reason=lambda: annotate_unavailable_reason(rocprof_config),
)
add_marker_if(item, "mpi", req_mark="mpi_implementation")
add_marker_if(item, "python", req_mark="python_versions")
add_marker_if(item, "gpu", req_mark="multi_gpu")
# Add corresponding runner type markers based on parametrized values ("mode")
detected_runners: set[str] = set()
if hasattr(item, "callspec") and item.callspec:
params = item.callspec.params
for param_name in ["runner", "mode", "instrumentation_mode"]:
if param_name in params:
value = str(params[param_name])
if value in ROCPROFSYS_RUNNER_NAMES:
detected_runners.add(value)
for runner in detected_runners:
marker_name = runner.replace("-", "_")
item.add_marker(getattr(pytest.mark, marker_name))
# ----------------------------------------------------------------------------
# We will not be running tests in this mode, so marker checks are redundant
if config.getoption("--ctest-mode", default="off") == "generate":
for item in items:
base_modifications(item)
return
# Marker checks
# "Skip" markers are left for runtime evaluation
for item in items:
base_modifications(item)
if "build_only" in item.keywords and rocprof_config.is_installed:
item.add_marker(
pytest.mark.skip(reason="Test only runs in build mode (build_only)")
)
if "gpu" in item.keywords:
_msg = gpu_unavailable_reason()
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "ucx" in item.keywords and not rocprof_config.capabilities.ucx_availability:
item.add_marker(pytest.mark.skip(reason="UCX not available"))
if "mpi" in item.keywords:
_msg = mpi_unavailable_reason(rocprof_config, config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "mpi_implementation" in item.keywords:
req_impl = item.get_closest_marker("mpi_implementation").args[0]
if req_impl != rocprof_config.capabilities.mpi_implementation:
item.add_marker(
pytest.mark.skip(
reason=f"Requires {req_impl}, but {rocprof_config.capabilities.mpi_implementation} found"
)
)
if "overflow" in item.keywords:
_msg = overflow_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "attach" in item.keywords:
_msg = attach_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "python" in item.keywords:
_msg = python_base_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
_msg = python_versions_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "julia" in item.keywords:
_msg = julia_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "xnack" in item.keywords:
_msg = xnack_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "no_docker" in item.keywords:
_msg = no_docker_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "shmem" in item.keywords:
_msg = shmem_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "nic" in item.keywords:
_msg = nic_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "ainic_required" in item.keywords:
_msg = ainic_unavailable_reason(rocprof_config)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "rocprofiler_sdk_min_version" in item.keywords:
req_version = item.get_closest_marker("rocprofiler_sdk_min_version").args[0]
_msg = rocprofiler_sdk_min_version_unavailable_reason(
rocprof_config, req_version
)
if _msg is not None:
item.add_marker(pytest.mark.skip(reason=_msg))
if "rocm_min_version" in item.keywords:
req_version = item.get_closest_marker("rocm_min_version").args[0]
system_version = rocprof_config.rocm_version
if system_version is None:
item.add_marker(pytest.mark.skip(reason="ROCm version not found"))
# Parse min_version and compare
min_parts = req_version.split(".")
min_tuple = tuple(int(p) for p in (min_parts + ["0", "0"])[:3])
if system_version < min_tuple:
item.add_marker(
pytest.mark.skip(
reason=f"ROCm {'.'.join(map(str, system_version))} < required {req_version}"
)
)
if "oshrun_min_version" in item.keywords:
req_version = item.get_closest_marker("oshrun_min_version").args[0]
system_version = rocprof_config.capabilities.oshrun_version
if system_version is None:
item.add_marker(pytest.mark.skip(reason="OpenSHMEM version not found"))
else:
min_parts = req_version.split(".")
min_tuple = tuple(int(p) for p in (min_parts + ["0", "0"])[:2])
if system_version < min_tuple:
item.add_marker(
pytest.mark.skip(
reason=f"oshrun version {'.'.join(map(str, system_version))} < required {req_version}"
)
)
if "amdsmi_min_version" in item.keywords:
req_version = item.get_closest_marker("amdsmi_min_version").args[0]
system_version = rocprof_config.capabilities.amdsmi_version
if system_version is None:
item.add_marker(pytest.mark.skip(reason="AMD-SMI version not found"))
else:
min_parts = req_version.split(".")
min_tuple = tuple(int(p) for p in (min_parts + ["0", "0"])[:2])
if system_version < min_tuple:
item.add_marker(
pytest.mark.skip(
reason=f"AMD-SMI {'.'.join(map(str, system_version))} < required {req_version}"
)
)
if "amdgpu_min_version" in item.keywords:
req_version = item.get_closest_marker("amdgpu_min_version").args[0]
system_version = rocprof_config.capabilities.amdgpu_version
if system_version is None:
item.add_marker(pytest.mark.skip(reason="amdgpu version not found"))
else:
min_parts = req_version.split(".")
min_tuple = tuple(int(p) for p in (min_parts + ["0", "0", "0"])[:3])
if system_version < min_tuple:
item.add_marker(
pytest.mark.skip(
reason=f"amdgpu {'.'.join(map(str, system_version))} < required {req_version}"
)
)
if "run_if_gpu_category" in item.keywords:
_gpu_msg = gpu_unavailable_reason()
if _gpu_msg is not None:
item.add_marker(pytest.mark.skip(reason=_gpu_msg))
expr = item.get_closest_marker("run_if_gpu_category").args[0]
try:
result = eval(expr, {"__builtins__": {}}, gpu_category_eval_context())
if not result:
item.add_marker(
pytest.mark.skip(
reason=f"GPU category condition '{expr}' is False"
)
)
except Exception as e:
pytest.exit(f"Invalid run_if_gpu_category expression: {e}", returncode=1)
if "multi_gpu" in item.keywords:
num_gpu = item.get_closest_marker("multi_gpu").args[0]
info = get_gpu_info()
if info.device_count < num_gpu:
item.add_marker(
pytest.mark.skip(
reason=f"Test requires atleast {num_gpu} GPUs but system has {info.device_count}"
)
)
def pytest_collection_finish(session):
if session.config.getoption("--ctest-mode", default="off") == "generate":
raw_path = session.config.getoption("--ctest-output-path", default=None)
output_path = Path(raw_path) if raw_path else None
_ctest_generate_tests(session.items, output_path)
# ----------------------------------------------------------------------------
# Test execution hooks
# ----------------------------------------------------------------------------
@pytest.hookimpl(hookwrapper=True) # Allows yield
def pytest_runtest_makereport(item, call):
"""
Attaches a "Runner Output" section to the call-phase report of the form:
=========================================
Command: <command>
Environment:
<environment>
=========================================
Test Output:
<test output>
=========================================
"""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
if rep.when != "call" or item.stash.get(_output_printed_key, False):
return
item.stash[_output_printed_key] = True
test_result = item.stash.get(_result_key, None)
if test_result is None:
return
report_output = []
cmd = " ".join(str(c) for c in getattr(test_result, "command", []))
if cmd:
report_output.append(f"{'='*70}")
report_output.append(f"Command: {cmd}")
test_env = getattr(test_result, "environment", None)
if isinstance(test_env, environment.TestEnvironment):
env_lines = test_env.format_layers()
if env_lines:
report_output.append("Environment:\n\n" + "\n".join(env_lines) + "\n")
report_output.append(f"{'='*70}")
test_output = getattr(test_result, "test_output", "")
extra_output = getattr(test_result, "extra_output", None)
if test_output or extra_output:
report_output.append("Test Output:\n")
if test_output:
report_output.append(test_output)
if extra_output:
report_output.append(extra_output)
report_output.append(f"{'='*70}")
if not report_output:
return
rep.sections.append(("Runner Output", "\n".join(report_output) + "\n\n"))
def pytest_runtest_logreport(report):
"""Print the runner output inline for passing tests in CTest run mode.
Failing tests already have their "Runner Output" section printed by pytest's
failure summary.
"""
config = getattr(pytest, "_config_ref", None)
if config is None:
return
if config.getoption("--ctest-mode", default="off") != "run":
return
if report.when != "call" or not report.passed:
return
terminal = config.pluginmanager.get_plugin("terminalreporter")
if terminal is None:
return
for section_name, section_content in report.sections:
if section_name == "Runner Output":
terminal.write_line(f"\n--- {section_name} ---")
for line in section_content.splitlines():
terminal.write_line(line)
# ----------------------------------------------------------------------------
# Session End hooks
# ----------------------------------------------------------------------------
def pytest_sessionfinish(session, exitstatus):
"""Code that runs after tests complete
In CTest mode, map "all skipped" to exit code SKIP_RETURN_CODE
so that CTest can distinguish skipped from passed (via SKIP_RETURN_CODE).
"""
if (
session.config.getoption("--ctest-mode", default="off") == "run"
and exitstatus == 0
):
reporter = session.config.pluginmanager.get_plugin("terminalreporter")
if reporter is not None:
passed = len(reporter.stats.get("passed", []))
skipped = len(reporter.stats.get("skipped", []))
if passed == 0 and skipped > 0:
session.exitstatus = SKIP_RETURN_CODE
# ============================================================================
#
# Helper functions
#
# ============================================================================
# ----------------------------------------------------------------------------
# Collection-time availability: return None if OK, else a skip reason string.
# ----------------------------------------------------------------------------
def overflow_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if rocprof_config.capabilities.perf_events_usable:
return None
return "Requires either perf_event_paranoid <= 2 or CAP_SYS_ADMIN to be available"
def gpu_unavailable_reason() -> Optional[str]:
gpu_info = get_gpu_info()
if gpu_info is not None and gpu_info.available:
return None
return "No valid GPU available"
def annotate_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
msg = overflow_unavailable_reason(rocprof_config)
if msg is not None:
return msg
if not rocprof_config.capabilities.papi_availability:
return "PAPI not available"
return None
def attach_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if rocprof_config.capabilities.ptrace_scope == 0:
return None
return (
"Requires ptrace_scope to be 0. Run 'echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope' "
"to enable attaching to process"
)
def nic_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
caps = rocprof_config.capabilities
if caps.papi_nic_events is not None and caps.perf_events_usable:
return None
return "Requires PAPI network events and perf_event_paranoid <= 2 (or CAP_SYS_ADMIN) to be available"
def ainic_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
"""Check if AI NIC tracking is available.
Requires ``amd-smi static`` to report at least one NETDEV entry.
"""
if not rocprof_config.capabilities.ai_nic_devices:
return "No AI NIC devices found (amd-smi static reports no NETDEV entries)"
return None
def rocprofiler_sdk_min_version_unavailable_reason(
rocprof_config: RocprofsysConfig, req_version: str
) -> Optional[str]:
"""Return a skip reason if the detected rocprofiler-sdk is older than req_version.
``req_version`` is a dotted version string (e.g. ``"1.2.2"``) supplied by the
test via the ``rocprofiler_sdk_min_version`` marker.
"""
system_version = rocprof_config.capabilities.rocprofiler_sdk_version
min_parts = req_version.split(".")
min_tuple = tuple(int(p) for p in (min_parts + ["0", "0", "0"])[:3])
if system_version is not None and system_version >= min_tuple:
return None
_found = (
".".join(map(str, system_version)) if system_version is not None else "not found"
)
return (
f"Requires rocprofiler-sdk minimum {req_version}, "
f"but system detected version {_found}"
)
def mpi_unavailable_reason(
rocprof_config: RocprofsysConfig, config: pytest.Config
) -> Optional[str]:
if rocprof_config.capabilities.mpiexec_exec is None:
return "MPI not available"
return None
def python_base_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if rocprof_config.rocprofsys_python is not None:
return None
return "rocprof-sys-python binary not found"
def python_versions_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if (
rocprof_config.capabilities.supported_python_versions is not None
and os.environ.get("ROCPROFSYS_USE_PYTHON", "ON").upper() == "ON"
):
return None
return (
"No supported Python versions. Each version needs a corresponding "
"libpyrocprofsys.<IMPL>-<VERSION>-<ARCH>-<OS>-<ABI>.so in site-packages/rocprofsys."
)
def julia_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if rocprof_config.capabilities.julia_exec:
return None
return "Julia not available"
def xnack_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if get_xnack_support(rocprof_config.rocm_path):
return None
return "XNACK not supported"
def no_docker_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if not rocprof_config.capabilities.is_inside_docker:
return None
return "Test cannot run inside a Docker container"
def shmem_unavailable_reason(rocprof_config: RocprofsysConfig) -> Optional[str]:
if rocprof_config.capabilities.oshrun_exec:
return None
return "SHMEM not available"
# ----------------------------------------------------------------------------
# Test-category (tier) label injection from test_categories.yaml
# ----------------------------------------------------------------------------
# Single source of truth for tier policy is tests/test_categories.yaml.
# At CTest-generate time we read the YAML and append tier labels to each
# test's emitted LABELS set, so `ctest -L <tier>` Just Works from the
# installed share/rocprofiler-systems/tests directory.
TIER_ORDER = ["quick", "standard", "comprehensive", "full"]
@lru_cache(maxsize=1)
def _load_test_categories() -> Optional[dict]:
"""Load and compile test_categories.yaml from rocprofsys_tests_dir.
Reads the YAML that CMake installs/configures into
``<build|install>/share/rocprofiler-systems/tests``
Returns ``None`` (with a single STDERR warning) when the YAML is missing or
PyYAML isn't importable
"""
if yaml is None:
print(
"[test_categories] PyYAML not available - skipping tier label injection.",
file=sys.stderr,
)
return None
try:
yaml_path = get_rocprof_config().rocprofsys_tests_dir / "test_categories.yaml"
except Exception as exc:
print(
f"[test_categories] Could not resolve tests dir - skipping tier label injection: {exc}",
file=sys.stderr,
)
return None
if not yaml_path.exists():
print(
f"[test_categories] {yaml_path} not found - skipping tier label injection.",
file=sys.stderr,
)
return None
try:
data = yaml.safe_load(yaml_path.read_text()) or {}
except yaml.YAMLError as exc:
print(
f"[test_categories] Failed to load {yaml_path}: {exc}",
file=sys.stderr,
)
return None
def _compile_list(patterns):
compiled = []
for p in patterns or []:
# Flatten one level: a YAML alias item (e.g. `- *common_excludes`)
# expands to a nested list, so callers can mix a shared anchor with
# per-tier additions.
for pattern in p if isinstance(p, list) else [p]:
try:
compiled.append(re.compile(pattern))
except re.error as exc:
print(
f"[test_categories] Skipping invalid regex {pattern!r}: {exc}",
file=sys.stderr,
)
return compiled
def _flatten_labels(values):
# One-level flatten (like _compile_list) so a YAML alias item expands
# to a nested list without raising TypeError on the unhashable inner
# list when set()-ed.
flat = []
for v in values or []:
flat.extend(v if isinstance(v, list) else [v])
return flat
tier_cfg: dict = {}
for tier in TIER_ORDER:
cfg = (data.get("test_categories", {}) or {}).get(tier) or {}
tier_cfg[tier] = {
"include": _compile_list(cfg.get("regex_includes")),
"exclude": _compile_list(cfg.get("regex_excludes")),
"label_excludes": _compile_list(cfg.get("label_excludes")),
"label_includes": _compile_list(cfg.get("label_includes")),
"labels": _flatten_labels(cfg.get("added_supplementary_labels")),
}
return {"tiers": tier_cfg}
def _resolve_tier_labels(test_name: str, existing_labels: set[str]) -> set[str]:
"""Return tier labels (subset of TIER_ORDER) for *test_name*.
Each tier is evaluated independently with the *exact* CTest filter model.
The four YAML axes map to CTest options as (labels are pytest MARKERs):
* ``regex_includes`` -> ``-R`` * ``regex_excludes`` -> ``-E``
* ``label_includes`` -> ``-L`` * ``label_excludes`` -> ``-LE``
The rocJenkins-style cascade ("matching quick also yields standard /
comprehensive / full") is achieved by having those higher tiers use
broad include patterns (typically ``regex_includes: [".*"]``). Per-tier
``regex_excludes`` punches a hole through the cascade for individual
tests: listing ``testA`` under ``standard.regex_excludes`` drops
``standard`` from its label set even if ``quick`` / ``comprehensive`` /
``full`` match.
In addition to the tier name, each matched tier contributes its
``added_supplementary_labels:`` to the test's labels.
"""
categories = _load_test_categories()
if not categories:
return set()
matched_indices: list[int] = []
extra_labels: set[str] = set()
for i, tier in enumerate(TIER_ORDER):
cfg = categories["tiers"].get(tier) or {}
include_regex = cfg.get("include", [])
include_labels = cfg.get("label_includes", [])
exclude_regex = cfg.get("exclude", [])
exclude_labels = cfg.get("label_excludes", [])
# -R: an empty include is a pass-through; otherwise the NAME must match.
if include_regex and not any(p.search(test_name) for p in include_regex):
continue
# -L: an empty include is a pass-through; otherwise a marker label must
# match. AND-combined with the -R axis above, exactly like CTest.
if include_labels and not any(
p.search(label) for p in include_labels for label in existing_labels
):
continue
# -E: NAME matching any exclude pattern vetoes the test.
if any(p.search(test_name) for p in exclude_regex):
continue
# -LE: any marker label matching an exclude pattern vetoes the test.
if any(p.search(label) for p in exclude_labels for label in existing_labels):
continue
matched_indices.append(i)
extra_labels.update(cfg.get("labels", []))
return {TIER_ORDER[i] for i in matched_indices} | extra_labels
# ----------------------------------------------------------------------------
# CTest generator functions
# ----------------------------------------------------------------------------
def _cmake_escape(s: str) -> str:
"""Escape a string for use inside CMake double-quoted arguments."""
return s.replace("\\", "\\\\").replace('"', '\\"')
def _ctest_item_ctest_identity(item: pytest.Item) -> tuple[str, str, str]:
"""Return ``(original_nodeid, item name, CTest nodeid fragment)`` for CMake generation."""
test_id = item.stash.get(_original_nodeid_key, item.nodeid)
test_name = item.name
if "::" in test_id:
file_part, _, rest = test_id.partition("::")
test_nodeid = f"{Path(file_part).name}::{rest}"
else:
test_nodeid = Path(test_id).name
return test_id, test_name, test_nodeid
def _emit_ctest_header_block() -> list[str]:
"""CMake preamble for generated CTestTestfile.cmake (env, paths, pytest/python discovery)."""
return [
"# Auto-generated CTest definitions from rocprofiler-systems pytest suite",
"# DO NOT EDIT — regenerate via: pytest <dir> --ctest-mode=generate",
"#",
"# Run with: ctest --test-dir <directory containing this file>",
"#",
"# Supported environment variables:",
"# ROCPROFSYS_TEST_DIR - Path to test package directory or .pyz file",
"# ROCPROFSYS_TEST_EXECUTABLE - Python or pytest executable to use",
"# ROCPROFSYS_PYTHON_HINTS - Additional search paths for versioned Python interpreters",
"#",
"",
"if(DEFINED ENV{ROCPROFSYS_TEST_DIR} AND NOT DEFINED ROCPROFSYS_TEST_DIR)",
' set(ROCPROFSYS_TEST_DIR "$ENV{ROCPROFSYS_TEST_DIR}")',
"endif()",
"if(DEFINED ENV{ROCPROFSYS_TEST_EXECUTABLE} AND NOT DEFINED ROCPROFSYS_TEST_EXECUTABLE)",
' set(ROCPROFSYS_TEST_EXECUTABLE "$ENV{ROCPROFSYS_TEST_EXECUTABLE}")',
"endif()",
"if(DEFINED ENV{ROCPROFSYS_PYTHON_HINTS} AND NOT DEFINED ROCPROFSYS_PYTHON_HINTS)",
' set(ROCPROFSYS_PYTHON_HINTS "$ENV{ROCPROFSYS_PYTHON_HINTS}")',
"endif()",
"",
"execute_process(COMMAND pwd OUTPUT_VARIABLE _CTEST_DIR OUTPUT_STRIP_TRAILING_WHITESPACE)",
"if(NOT DEFINED ROCPROFSYS_TEST_DIR)",
' set(ROCPROFSYS_TEST_DIR "${_CTEST_DIR}")',
"endif()",
"",
'set(_INSTALL_PATH "${ROCPROFSYS_TEST_DIR}/rocprofsys-tests.pyz")',
'set(_BUILD_PATH "${ROCPROFSYS_TEST_DIR}/../share/rocprofiler-systems/tests/pytest/")',
'set(_TEST_ARGS "-s" "--ctest-mode" "run")',
"",
'if(EXISTS "${_INSTALL_PATH}")',
" if(NOT DEFINED ROCPROFSYS_TEST_EXECUTABLE)",
" find_program(ROCPROFSYS_TEST_EXECUTABLE NAMES python3 python HINTS ${ROCPROFSYS_PYTHON_HINTS})",
" endif()",
" if(NOT ROCPROFSYS_TEST_EXECUTABLE)",
" message(FATAL_ERROR",
' "python executable not found. "',
' "Set ROCPROFSYS_TEST_EXECUTABLE to the correct path "',
' "or provide ROCPROFSYS_PYTHON_HINTS to search for the executable.")',
" endif()",
' set(_ROCPROFSYS_EXE "${ROCPROFSYS_TEST_EXECUTABLE}")',
' set(_ROCPROFSYS_EXE_ARGS "${_INSTALL_PATH}")',
' set(_ROCPROFSYS_NODEID_PFX "")',
' set(_ROCPROFSYS_EXTRA_ARGS "${_TEST_ARGS}")',
'elseif(EXISTS "${_BUILD_PATH}")',
" if(NOT DEFINED ROCPROFSYS_TEST_EXECUTABLE)",
" find_program(ROCPROFSYS_TEST_EXECUTABLE NAMES pytest pytest3 HINTS ${ROCPROFSYS_PYTHON_HINTS})",
" endif()",
" if(NOT ROCPROFSYS_TEST_EXECUTABLE)",
" message(FATAL_ERROR",
' "pytest executable not found. "',
' "Set ROCPROFSYS_TEST_EXECUTABLE to the correct path "',
' "or provide ROCPROFSYS_PYTHON_HINTS to search for the executable.")',
" endif()",
' set(_ROCPROFSYS_EXE "${ROCPROFSYS_TEST_EXECUTABLE}")',
' set(_ROCPROFSYS_EXE_ARGS "")',
' set(_ROCPROFSYS_NODEID_PFX "${_BUILD_PATH}")',
' set(_ROCPROFSYS_EXTRA_ARGS "${_TEST_ARGS}")',
"else()",
' message(FATAL_ERROR "Cannot find test package. Set ROCPROFSYS_TEST_DIR=/path/to/rocprofsys-tests.pyz")',
"endif()",
"",
"if(DEFINED ENV{ROCPROFSYS_CI_TIMEOUT})",
' set(_ROCPROFSYS_CI_TIMEOUT "$ENV{ROCPROFSYS_CI_TIMEOUT}")',
"endif()",
"",
]
def _emit_prerequisite_block() -> list[str]:
"""``rocprofiler-systems-pytest-config`` prerequisite test (global tmp fixture setup)."""
return [
'add_test("rocprofiler-systems-pytest-config" "${_ROCPROFSYS_EXE}"'
' "${_ROCPROFSYS_EXE_ARGS}"'
' "${_ROCPROFSYS_NODEID_PFX}" "--show-config-only")',
'set_tests_properties("rocprofiler-systems-pytest-config" PROPERTIES',
' FIXTURES_SETUP "rocprofsys-global-tmp-files"',
' LABELS "prerequisite;global"',
f" TIMEOUT {CONFIG_TIMEOUT + CONFIG_TIMEOUT_BUFFER}",
")",
"",
]
def _emit_cleanup_block() -> list[str]:
"""``rocprofiler-systems-test-cleanup`` (global tmp fixture cleanup)."""
return [
'add_test("rocprofiler-systems-test-cleanup" "${_ROCPROFSYS_EXE}"'
' "${_ROCPROFSYS_EXE_ARGS}"'
' "${_ROCPROFSYS_NODEID_PFX}" "--ctest-mode" "cleanup")',
'set_tests_properties("rocprofiler-systems-test-cleanup" PROPERTIES',
' FIXTURES_CLEANUP "rocprofsys-global-tmp-files"',
' LABELS "cleanup;global"',
" TIMEOUT 30",
")",
"",
]
def _emit_test_timeout_block(
item: pytest.Item, timeout_buffer: int = CTEST_TIMEOUT_BUFFER
) -> list[str]:
"""One CMake block: set ``_TEST_TIMEOUT`` from ``ROCPROFSYS_CI_TIMEOUT`` or pytest timeout (+ buffer)."""
timeout_marker = item.get_closest_marker("timeout")
timeout = (
int(timeout_marker.args[0])
if timeout_marker and timeout_marker.args
else DEFAULT_TIMEOUT
)
default_timeout = timeout + timeout_buffer
return [
"if(DEFINED _ROCPROFSYS_CI_TIMEOUT)",
f' math(EXPR _TEST_TIMEOUT "${{_ROCPROFSYS_CI_TIMEOUT}} + {timeout_buffer}")',
"else()",
f" set(_TEST_TIMEOUT {default_timeout})",
"endif()",
"",
]
def _emit_test_item_block(
item: pytest.Item,
labels: set[str],
depends_on: list[str],
run_serial: bool,
) -> list[str]:
"""``add_test`` + ``set_tests_properties`` for one item (timeout block emitted separately)."""
_, test_name, test_nodeid = _ctest_item_ctest_identity(item)
escaped_name = _cmake_escape(test_name)
escaped_nodeid = _cmake_escape(test_nodeid)
# Check if the test runs on a specific python version
extra_args = ""
if hasattr(item, "callspec") and "python_version" in item.callspec.params:
py_ver = item.callspec.params["python_version"]
if py_ver is not None:
extra_args += f' "--python-versions={py_ver}"'
lines_out: list[str] = [
f'add_test("{escaped_name}" "${{_ROCPROFSYS_EXE}}"'
f' "${{_ROCPROFSYS_EXE_ARGS}}"'
f' "${{_ROCPROFSYS_NODEID_PFX}}{escaped_nodeid}"'
f"{extra_args} ${{_ROCPROFSYS_EXTRA_ARGS}})"
]
props: list[str] = []
if labels:
props.append(f' LABELS "{";".join(sorted(labels))}"')
props.append(" TIMEOUT ${_TEST_TIMEOUT}")
props.append(f" SKIP_RETURN_CODE {SKIP_RETURN_CODE}")
props.append(' FIXTURES_REQUIRED "rocprofsys-global-tmp-files"')
if run_serial:
props.append(" RUN_SERIAL TRUE")
if depends_on:
deps_str = ";".join(_cmake_escape(d) for d in depends_on)
props.append(f' DEPENDS "{deps_str}"')
lines_out.append(f'set_tests_properties("{escaped_name}" PROPERTIES')
lines_out.extend(props)
lines_out.append(")")
lines_out.append("")
return lines_out
def _ctest_generate_tests(
items: list[pytest.Item], output_path: Optional[Path] = None
) -> None:
"""Generate a CTestTestfile.cmake file and print it to stdout."""
no_report_markers = {
"parametrize", # Ignored, except for "mode" parameter (instrumentation mode)
# Pytest built-in
"usefixtures",
"filterwarnings",
"skipif",
"skip",
"xfail",
# Internal markers
"python_versions",
"mpi_optional",
"no_docker",
"oshrun_min_version",
"rocm_min_version",
"amdsmi_min_version",
"amdgpu_min_version",
"rocprofiler_sdk_min_version",
"run_if_gpu_category",
"preserve",
# For CTests
"timeout",
"depends_on",
"serialize",
"class_name",
}
no_report_args_markers = {"rocpd"}
only_report_args_markers = {"mpi_implementation"}
lines = _emit_ctest_header_block()
lines.extend(_emit_prerequisite_block())
seen_names: dict[str, str] = {} # escaped_name -> original nodeid
for item in items:
test_id, test_name, _ = _ctest_item_ctest_identity(item)
# Handle certain markers that affect how CTest is configured
labels: set[str] = set()
depends_on: list[str] = []
run_serial = False
depends_marker = item.get_closest_marker("depends_on")
if depends_marker:
depends_on.extend(str(arg) for arg in depends_marker.args)
if item.get_closest_marker("serialize"):
run_serial = True
if hasattr(item, "callspec") and "mode" in item.callspec.params:
labels.add(str(item.callspec.params["mode"]))
# Translate pytest markers to CTest labels
for marker in item.iter_markers():
if marker.name in no_report_markers:
continue
if marker.name in only_report_args_markers:
for arg in marker.args:
labels.add(str(arg))
continue
if marker.name in no_report_args_markers or not marker.args:
labels.add(marker.name)
else:
args_str = ", ".join(str(a) for a in marker.args)
labels.add(f"{marker.name}[{args_str}]")
# Inject tier (quick/standard/comprehensive/full) labels from
# test_categories.yaml
labels |= _resolve_tier_labels(test_name, labels)
escaped_name = _cmake_escape(test_name)
if escaped_name in seen_names:
pytest.exit(
f"\nDuplicate CTest name '{escaped_name}' generated from:\n"
f" 1) {seen_names[escaped_name]}\n"
f" 2) {test_id}\n"
f"(Due to _standardize_test_name or parametrization)\n"
f"Rework test name or parametrization to produce unique names.",
returncode=1,
)
seen_names[escaped_name] = test_id
lines.extend(_emit_test_timeout_block(item))
lines.extend(
_emit_test_item_block(
item,
labels,
depends_on,
run_serial,
)
)
lines.extend(_emit_cleanup_block())
content = "\n".join(lines)
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content)
print(f"Generated {len(items)} CTest definitions -> {output_path}")
else:
print(content)
pytest.exit("CTest generation complete", returncode=0)
# ----------------------------------------------------------------------------
# Cache warmup
# ----------------------------------------------------------------------------
def _seed_object_cached_properties(obj: object) -> None:
"""Access every ``persistent_cached_property`` on ``obj`` to compute+store it."""
from rocprofsys.cache import SerializationError, persistent_cached_property
seen: set[str] = set()
for klass in type(obj).__mro__:
for name, attr in vars(klass).items():
if name in seen or not isinstance(attr, persistent_cached_property):
continue
seen.add(name)
try:
getattr(obj, name)
except SerializationError:
raise
except Exception:
pass
def _reset_cache_for_regeneration(store) -> None:
"""Discard the previous run's cache so every probe recomputes and rewrites.
Clears the on-disk store and the in-process memoization on
``get_rocprof_config`` so nothing stale carries over.
"""
cache_existed = store.path.exists()
print(
f"Overwriting old cache file: {store.path}"
if cache_existed
else f"Generating cache file: {store.path}"
)
store.clear()
try:
get_rocprof_config.cache_clear()
except Exception:
pass
def _warm_gpu_probes(rocprof_config: RocprofsysConfig) -> None:
"""Compute + store the GPU / ROCm tooling probes for the active ROCm path."""
from rocprofsys.cache import SerializationError
for probe in (detect_gpu, get_offload_extractor, get_xnack_support):
try:
probe(rocprof_config.rocm_path)
except SerializationError:
raise
except Exception as e:
print(f"Warning: seeding {probe.__name__} failed: {e}")
def _warm_cached_properties(rocprof_config: RocprofsysConfig) -> None:
"""Seed every non-target-dependent persistent_cached_property (config + capabilities)."""
from rocprofsys.cache import SerializationError
_seed_object_cached_properties(rocprof_config)
try:
cap = rocprof_config.capabilities
except SerializationError:
raise
except Exception:
cap = None
if cap is not None:
_seed_object_cached_properties(cap)
def _seed_capability_cache() -> None:
"""(Re)generate the persistent capability cache during the config setup step.
This is handled by ``rocprofiler-systems-pytest-config`` test (``--show-config-only``).
This is always run once via ``FIXTURES_SETUP`` and ``FIXTURE_REQUIRED``.
"""
from rocprofsys.cache import SerializationError, get_shared_cache
store = get_shared_cache()
if store is None:
return # caching disabled/unavailable
_reset_cache_for_regeneration(store)
try:
rocprof_config = get_rocprof_config()
except SerializationError:
raise
except Exception:
return
_warm_gpu_probes(rocprof_config)
_warm_cached_properties(rocprof_config)
# ----------------------------------------------------------------------------
# Other helpers
# ----------------------------------------------------------------------------
def _standardize_test_name(
item: pytest.Item, config: pytest.Config, verbose: bool = False
) -> None:
# Strip test prefix from the test method name
test_name = item.name
if test_name.startswith("test"):
test_name = test_name[4:]
if test_name.startswith(("_", "-")):
test_name = test_name[1:]
class_name = None
name_marker = item.get_closest_marker("class_name")
if name_marker and name_marker.args:
class_name = str(name_marker.args[0]).strip()
if class_name:
full_name = f"{class_name}-{test_name}"
elif item.cls:
py_class = item.cls.__name__
if py_class.startswith("Test"):
py_class = py_class[4:]
full_name = f"{py_class}-{test_name}"
else:
full_name = test_name
formatted_name = "".join(c if c.isalnum() or c == "." else "-" for c in full_name)
formatted_name = formatted_name.replace("_", "-")
while "--" in formatted_name:
formatted_name = formatted_name.replace("--", "-")
formatted_name = formatted_name.strip("-")
formatted_name = formatted_name.lower()
item.stash[_original_nodeid_key] = item.nodeid
# nodeid is what is used to display the test name in the terminal
# By default, it groups it by module. In verbose, it shows the full path + class + method
# To get a cleaner output in verbose mode, we modify the nodeid but only if verbose is True
# This avoids breaking the default grouping by module in non-verbose mode
if verbose:
item._nodeid = formatted_name
item.name = formatted_name
# Allow -k filtering by the formatted name
item.extra_keyword_matches.add(formatted_name)
item.extra_keyword_matches.add(formatted_name.lower())
def _config_timeout_handler(signum, frame):
"""SIGALRM handler for ``--show-config-only``: dump where we hung, then hard-exit."""
sys.stderr.write(
"\n" + "=" * 70 + "\n"
f"[rocprofiler-systems-pytest-config] ERROR: configuration probing exceeded "
f"{CONFIG_TIMEOUT}s and was aborted.\n"
"Stack trace at the point of timeout:\n" + "-" * 70 + "\n"
)
traceback.print_stack(frame, file=sys.stderr)
sys.stderr.write("=" * 70 + "\n")
sys.stderr.flush()
# os._exit avoids re-entering the (possibly blocked) interpreter state and ensures
# the process actually dies even if we interrupted an uninterruptible-looking call.
os._exit(2)
def _generate_rocprofsys_config_header() -> list[str]:
"""Generate the config header under an internal SIGALRM watchdog.
The system probes below (rocminfo, GPU detection, MPI/oshrun version, AMD-SMI/PAPI)
can block indefinitely. Without a guard a hang would surface as a silent CTest
``***Timeout``; the watchdog instead prints a diagnostic stack trace and hard-exits.
A plain exception would be swallowed by a blocking syscall, so the handler forces
an immediate non-zero exit (see :func:`_config_timeout_handler`).
"""
_watchdog_active = hasattr(signal, "SIGALRM") # False on non-POSIX platforms
_previous_handler = None
if _watchdog_active:
try:
_previous_handler = signal.signal(signal.SIGALRM, _config_timeout_handler)
signal.alarm(CONFIG_TIMEOUT)
except ValueError:
# signal handlers can only be installed from the main thread of the main
# interpreter; if config probing runs elsewhere, skip the watchdog rather
# than crash the probe.
_watchdog_active = False
try:
return _build_rocprofsys_config_header()
finally:
if _watchdog_active:
signal.alarm(0)
signal.signal(signal.SIGALRM, _previous_handler)
def _build_rocprofsys_config_header() -> list[str]:
"""Collect system configuration and format it as printable header lines."""
try:
rocprof_config = get_rocprof_config()
cap = rocprof_config.capabilities
except Exception as e:
return [f"{e}"]
gpu_info = get_gpu_info()
# Rocm version
rocm_version = (
".".join(map(str, rocprof_config.rocm_version))
if rocprof_config.rocm_version
else "Not found"
)
# Rocminfo
rocminfo_path = get_rocminfo(rocprof_config.rocm_path)
if not rocminfo_path:
rocminfo_err_msg = "Not found - Ensure rocminfo is in ROCM_PATH or PATH - Assuming no GPU configuration"
# Offload extractor
offload_msg = None
tool_path, is_llvm_too_old = get_offload_extractor(rocprof_config.rocm_path)
if tool_path:
if tool_path.name == "llvm-objdump":
offload_msg = f"{tool_path}"
elif tool_path.name == "roc-obj-ls":
if not is_llvm_too_old:
offload_msg = f"Using deprecated {tool_path} - Set ROCM_LLVM_OBJDUMP to use llvm-objdump instead"
else:
offload_msg = f"{tool_path}"
if not offload_msg:
offload_msg = (
"Not found - Set ROCM_LLVM_OBJDUMP to path of llvm-objdump (v20+), "
"or to path of roc-obj-ls if llvm-objdump < v20"
)
xnack_support = get_xnack_support(rocprof_config.rocm_path)
if cap.oshrun_version is not None:
oshrun_version_str = f"{cap.oshrun_version[0]}.{cap.oshrun_version[1]}"
else:
oshrun_version_str = "Not found"
oshrun_strips_str = (
"Yes (decoy '--' inserted)"
if cap.oshrun_strips_double_dash
else "No" if cap.oshrun_exec else "N/A"
)
if cap.amdsmi_version is not None:
amdsmi_version_str = f"{cap.amdsmi_version[0]}.{cap.amdsmi_version[1]}"
else:
amdsmi_version_str = "Not found"
if cap.amdgpu_version is not None:
amdgpu_version_str = (
f"{cap.amdgpu_version[0]}.{cap.amdgpu_version[1]}.{cap.amdgpu_version[2]}"
)
else:
amdgpu_version_str = "Not found"
# Rocprofiler SDK version
rocprofiler_sdk_version_str = (
f"{cap.rocprofiler_sdk_version[0]}.{cap.rocprofiler_sdk_version[1]}.{cap.rocprofiler_sdk_version[2]}"
if cap.rocprofiler_sdk_version
else "Not found"
)
W = 22 # label width for alignment
def _row(label: str, value) -> str:
return f" {label:<{W}}{value}"
def _subrow(label: str, value) -> str:
return f" {label:<{W}}{value}"
header = [
"",
"=" * 70,
"Test Configuration:",
"=" * 70,
_row("ROCm version:", rocm_version),
_row("ROCprof-SDK version:", rocprofiler_sdk_version_str),
_row("AMD-SMI version:", amdsmi_version_str),
_row("amdgpu version:", amdgpu_version_str),
_row("ROCm path:", rocprof_config.rocm_path),
_row("Is installed:", rocprof_config.is_installed),
_row("Output dir:", rocprof_config.test_output_dir),
_row("Validate ROCPD:", check_use_rocpd()),
_row("Validate Perfetto:", check_use_perfetto()),
"-" * 70,
"Core Executables:",
_row("Instrument:", rocprof_config.rocprofsys_instrument),
_row("Run:", rocprof_config.rocprofsys_run),
_row("Sample:", rocprof_config.rocprofsys_sample),
_row("Avail:", rocprof_config.rocprofsys_avail),
_row("Causal:", rocprof_config.rocprofsys_causal),
_row("Python:", rocprof_config.rocprofsys_python),
"-" * 70,
"Executables:",
_row("MPI:", cap.mpiexec_exec),
_subrow("Implementation:", cap.mpi_implementation),
_row("Julia:", cap.julia_exec),
_row("Oshrun:", cap.oshrun_exec),
_subrow("Version:", oshrun_version_str),
_subrow("Strips '--':", oshrun_strips_str),
_row("Offload tool:", offload_msg),
_row("Rocminfo:", rocminfo_path if rocminfo_path else rocminfo_err_msg),
"-" * 70,
"System Capabilities:",
_row("Detected num procs:", cap.num_procs),
_row("UCX available:", cap.ucx_availability),
_row("Perf event paranoid:", cap.perf_event_paranoid),
_row("CAP_SYS_ADMIN:", cap.cap_sys_admin),
_row("CAP_PERFMON:", cap.cap_perfmon),
_row("Perf events usable:", cap.perf_events_usable),
_row("Ptrace scope:", cap.ptrace_scope),
_row("Is inside docker:", rocprof_config.capabilities.is_inside_docker),
_row("PAPI available:", cap.papi_availability),
_row("AI NIC devices:", cap.ai_nic_devices),
_row("Default NIC:", cap.default_nic),
*(
lambda evts: (
[_row("PAPI NIC events:", evts[0])] + [_row("", e) for e in evts[1:]]
if evts
else [_row("PAPI NIC events:", "None")]
)
)(cap.papi_nic_events.split() if cap.papi_nic_events else []),
"-" * 70,
"GPU Information:",
_row("Available:", gpu_info.available),
_row("Architectures:", gpu_info.architectures or "None"),
_row("Device count:", gpu_info.device_count),
_row("Categories:", gpu_info.categories or "None"),
_row("XNACK support:", xnack_support),
"-" * 70,
"Directories:",
_row("Build dir:", rocprof_config.rocprofsys_build_dir),
_row("Lib dir:", rocprof_config.rocprofsys_lib_dir),
_row("Bin dir:", rocprof_config.rocprofsys_bin_dir),
_row("Tests dir:", rocprof_config.rocprofsys_tests_dir),
_row("Examples dir:", rocprof_config.rocprofsys_examples_dir),
_row("Validation dir:", rocprof_config.rocpd_validation_rules),
"-" * 70,
"Python:",
_row("Site packages:", rocprof_config.rocprofsys_site_packages),
]
if cap.supported_python_versions and cap.supported_python_executables:
for version, exe in zip(
cap.supported_python_versions,
cap.supported_python_executables,
):
header.append(_row(version, exe))
else:
header.append(
_row(
"Executables:",
"(no supported Python versions found — each version needs a "
"libpyrocprofsys.<IMPL>-<VERSION>-<ARCH>-<OS>-<ABI>.so in site-packages/rocprofsys)",
)
)
# Use fundamental system env to avoid verbose output
header.extend(["-" * 70, "System Environment:"])
for key, value in environment.fundamental_system_environment().items():
header.append(_row(f"{key}:", value))
header.extend(["=" * 70, ""])
return header
def add_marker_if(
item,
marker_to_add: str,
cond: Callable[[], bool] = lambda: True,
req_mark: Optional[str] = None,
skip_reason: Optional[str] = None,
unavailable_reason: Optional[Callable[[], Optional[str]]] = None,
) -> None:
"""Add a marker to a test item if:
- target_marker is present (or not specified)
- AND condition evaluates to True (lambda)
If ``unavailable_reason`` is set, it is called: ``None`` means add the marker;
a non-empty string means skip the test with that reason (preferred over cond/skip_reason).
If condition is False and skip_reason is provided, add a skip marker instead.
"""
if req_mark and not item.get_closest_marker(req_mark):
return
if unavailable_reason is not None:
msg = unavailable_reason()
if msg is None:
item.add_marker(getattr(pytest.mark, marker_to_add))
else:
item.add_marker(pytest.mark.skip(reason=msg))
return
if cond():
item.add_marker(getattr(pytest.mark, marker_to_add))
elif skip_reason:
item.add_marker(pytest.mark.skip(reason=skip_reason))
@lru_cache(maxsize=1)
def check_use_rocpd() -> bool:
"""Whether ROCpd is available for tests.
ROCpd requires:
- ROCPROFSYS_USE_ROCPD not set to OFF (default: ON)
- A valid GPU
- ROCm >= 7.0
"""
if os.environ.get("ROCPROFSYS_USE_ROCPD", "ON").upper() != "ON":
return False
try:
rocprof_config = get_rocprof_config()
except Exception as e:
pytest.exit(f"{e}")
gpu_info = get_gpu_info()
if not gpu_info.available:
return False
rocm_version = rocprof_config.rocm_version
return rocm_version is not None and rocm_version >= (7, 0, 0)
@lru_cache(maxsize=1)
def check_use_perfetto() -> bool:
"""Whether Perfetto is available for tests.
Perfetto requires:
- ROCPROFSYS_VALIDATE_PERFETTO not set to OFF (default: ON)
- Perfetto Python module installed
"""
if os.environ.get("ROCPROFSYS_VALIDATE_PERFETTO", "ON").upper() != "ON":
return False
try:
import perfetto # noqa
return True
except ImportError:
return False
# The first call to this function MUST be performed in pytest_sessionstart
# as we need the --python-versions and --python-root-dirs options to be set
@lru_cache(maxsize=1)
def get_rocprof_config() -> RocprofsysConfig:
"""Return the rocprofiler-systems configuration."""
try:
pytest_config = getattr(pytest, "_config_ref", None)
python_versions = None
python_root_dirs = None
rocm_optional = False
if pytest_config:
ver_str = pytest_config.getoption("--python-versions", default=None)
dir_str = pytest_config.getoption("--python-root-dirs", default=None)
# When generating the CTestTestfile.cmake in TheRock, ROCm is not present
rocm_optional = (
pytest_config.getoption("--ctest-mode", default="off") == "generate"
)
if ver_str:
python_versions = [v.strip() for v in ver_str.split(";") if v.strip()]
if dir_str:
python_root_dirs = [
Path(d.strip()) for d in dir_str.split(";") if d.strip()
]
return discover_build_config(
python_versions=python_versions,
python_root_dirs=python_root_dirs,
rocm_optional=rocm_optional,
)
except Exception as e:
raise RuntimeError("Failed to get rocprofiler-systems configuration") from e
@lru_cache(maxsize=1)
def get_gpu_info() -> GPUInfo:
"""Return the GPU information."""
try:
rocprof_config = get_rocprof_config()
except Exception as e:
pytest.exit(f"{e}")
try:
return detect_gpu(rocprof_config.rocm_path)
except Exception as e:
pytest.exit(f"Failed to detect GPU: {e}")
def _run_cleanup() -> None:
"""Run cleanup of temp files and optionally the test output directory."""
import glob
import getpass
# Clean up temp files
for pattern in _cleanup_temp_patterns():
for filepath in glob.glob(pattern):
try:
p = Path(filepath)
if p.is_file() and p.owner() == getpass.getuser():
p.unlink()
print(f"Removed: {filepath}")
except (OSError, KeyError):
pass
# Clean up test output directory if ROCPROFSYS_KEEP_TEST_OUTPUT=0
if os.environ.get("ROCPROFSYS_KEEP_TEST_OUTPUT", "1") == "0":
try:
rocprof_config = get_rocprof_config()
output_dir = rocprof_config.test_output_dir
if output_dir.exists():
shutil.rmtree(output_dir)
print(f"Removed test output directory: {output_dir}")
except Exception as e:
print(f"Warning: Could not clean test output directory: {e}")
def _cleanup_temp_patterns() -> list[str]:
"""Return list of rocprofiler-systems temp file patterns to clean up."""
from rocprofsys.cache import resolve_username
tmpdir = os.environ.get("ROCPROFSYS_TMPDIR", os.environ.get("TMPDIR", "/tmp"))
dirs = ["/tmp"]
if tmpdir and not tmpdir.startswith("%") and tmpdir != "/tmp":
dirs.append(tmpdir)
user = resolve_username()
patterns = []
for d in dirs:
patterns.extend(
[
f"{d}/rocprof-sys-*.tmp",
f"{d}/rocprofsys-*.tmp",
f"{d}/buffered_storage*.bin",
f"{d}/metadata*.json",
f"{d}/perfetto-*.proto",
f"{d}/perfetto_trace*.proto",
f"{d}/hsa-*.tmp",
f"{d}/rocm-*.tmp",
f"{d}/hip-*.tmp",
f"{d}/*.inst",
f"{d}/causal-*.json",
f"{d}/experiments-*.coz",
f"{d}/core.*",
]
)
# The persistent capability cache lives in a per-user subdir
# (``<tmp>/$USER/rocprofsys-syscache-*.tmp``; see cache.py).
if user:
patterns.append(f"{d}/{user}/rocprofsys-*.tmp")
return patterns
# ============================================================================
#
# Fixtures
#
# ============================================================================
# ----------------------------------------------------------------------------
# Environment Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture(scope="session")
def library_path(rocprof_config) -> str:
"""Computed LD_LIBRARY_PATH (rocprofsys libs + user override + ROCm LLVM libs)."""
return rocprof_config.get_library_path()
@pytest.fixture
def flat_env() -> dict[str, str]:
"""Environment variables for flat profile tests."""
return environment.flat_environment()
@pytest.fixture
def lock_env() -> dict[str, str]:
"""Environment variables for thread lock tracing tests."""
return environment.lock_environment()
@pytest.fixture
def perfetto_env() -> dict[str, str]:
"""Environment variables for perfetto-only tests."""
return environment.perfetto_environment()
@pytest.fixture
def timemory_env() -> dict[str, str]:
"""Environment variables for timemory-only tests."""
return environment.timemory_environment()
# ----------------------------------------------------------------------------
# Session-scoped Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture(scope="session")
def get_test_num_threads(rocprof_config) -> int:
"""Get the number of threads for the test."""
num_threads = rocprof_config.capabilities.num_procs + (
rocprof_config.capabilities.num_procs // 2
)
if num_threads > 12:
return 12
return num_threads
@pytest.fixture(scope="session")
def rocprof_config() -> RocprofsysConfig:
"""Session-wide rocprofiler-systems configuration.
Discovers build directory and creates configuration object.
Can be overridden with ROCPROFSYS_BUILD_DIR environment variable.
"""
return get_rocprof_config()
@pytest.fixture(scope="session")
def gpu_info() -> GPUInfo:
"""Session-wide GPU information.
Detects available GPUs and their capabilities.
"""
return get_gpu_info()
@pytest.fixture(scope="session")
def tests_dir(rocprof_config) -> Path:
"""Path to tests directory."""
return rocprof_config.rocprofsys_tests_dir
@pytest.fixture(scope="session")
def validation_rules_dir(rocprof_config) -> Path:
"""Path to validation rules directory."""
return rocprof_config.rocpd_validation_rules
# ----------------------------------------------------------------------------
# Module-scoped Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture(scope="module")
def test_output_base(rocprof_config) -> Path:
"""Base directory for test outputs"""
output_dir = rocprof_config.test_output_dir
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
# ----------------------------------------------------------------------------
# Function-scoped Fixtures
# ----------------------------------------------------------------------------
@pytest.fixture
def create_config_file(test_output_dir) -> Path:
"""Create a config file for a test based on env vars and return Path.
Filters out environment-only settings that should not be written to config files
"""
# Settings that should only be in environment, not config files
env_only_pattern = re.compile(
r"ROCPROFSYS_(CI|CI_TIMEOUT|MODE|USE_MPIP|DEBUG_[A-Z_]+|"
r"FORCE_ROCPROFILER_INIT|DEFAULT_MIN_INSTRUCTIONS|MONOCHROME|VERBOSE)$"
)
def _create_config_file(
env: dict[str, str],
name: Optional[str] = "config.cfg",
skip_filter: bool = False,
) -> Path:
config_file = test_output_dir / name
content = "# auto-generated by pytest\n\n"
if skip_filter:
config_vars = {k: v for k, v in env.items() if k != "ROCPROFSYS_CONFIG_FILE"}
else:
# Only write ROCPROFSYS_* settings to config file, excluding env-only settings
# Non-ROCPROFSYS vars (OMP_*, LD_LIBRARY_PATH, etc.) should stay as env vars only
# Also exclude ROCPROFSYS_CONFIG_FILE to avoid self-reference
config_vars = {
k: v
for k, v in env.items()
if k.startswith("ROCPROFSYS_")
and not env_only_pattern.match(k)
and k != "ROCPROFSYS_CONFIG_FILE"
}
content += "\n".join(f"{k}={v}" for k, v in config_vars.items())
config_file.write_text(content)
return config_file
return _create_config_file
@pytest.fixture
def collect_result(request) -> Callable:
"""Fixture to collect test results for display.
Handled by the `run_test` fixture
Manual usage in tests:
result = runner.run()
collect_result(result)
"""
def _collect(result):
request.node.stash[_result_key] = result
return _collect
@pytest.fixture
def test_output_dir(
test_output_base: Path,
request: pytest.FixtureRequest,
) -> Generator[Path, None, None]:
"""Unique output directory for each test.
Creates a directory named after the test and cleans up on success.
On failure, the directory is preserved for debugging.
Directory is removed if it is empty.
Cleanup Order:
1. Test setup: Directory is created
2. Test body: Runner executes, output files are written
3. Test body: Validation happens on output files
4. Test body: Assertions complete
5. Test teardown: This fixture cleans up the directory (AFTER yield)
This ensures validation always has access to output files.
"""
output_dir = test_output_base / request.node.name
if output_dir.exists():
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True)
yield output_dir # Test body executes here (including validation)
# === CLEANUP PHASE (runs AFTER test body completes) ===
# If the output directory is empty, remove it
if output_dir.exists() and not any(output_dir.iterdir()):
shutil.rmtree(output_dir)
keep_output = os.environ.get("ROCPROFSYS_KEEP_TEST_OUTPUT", "1") == "1"
test_failed = hasattr(request.node, "rep_call") and request.node.rep_call.failed
if keep_output or test_failed or not output_dir.exists():
return
# Remove all files in the output directory, then the directory itself
# unless the preserve marker is present
to_preserve = []
preserve_marker = request.node.get_closest_marker("preserve")
if preserve_marker and preserve_marker.args:
for fname in preserve_marker.args:
preserved = output_dir / fname
to_preserve.append(preserved)
for entry in output_dir.iterdir():
if entry not in to_preserve:
safe_remove(entry)
# Remove the output directory if empty
if not any(output_dir.iterdir()):
shutil.rmtree(output_dir)
@pytest.fixture(scope="function", autouse=True)
def apply_rocpd_marker(request):
"""Automatically add ROCpd env vars based on marker.
Usage:
@pytest.mark.rocpd("<env name>")
"""
if not check_use_rocpd():
return
marker = request.node.get_closest_marker("rocpd")
if not marker or not marker.args:
return
# First arg is fixture name
env_fixture_name = marker.args[0]
try:
env = request.getfixturevalue(env_fixture_name)
except pytest.FixtureLookupError:
return
# Add ROCpd base env
env["ROCPROFSYS_USE_ROCPD"] = "ON"
def _print_subtest_output(request, subtest_name: str, output: str) -> None:
"""Print subtest validation output for important subtests when in CTest run mode."""
if request.config.getoption("--ctest-mode", default="off") == "run" and output:
print(f"\n--- {subtest_name} ---\n{output}\n", flush=True)
# Contains a set of kwargs accepted for a given (function, mode) pair.
_FUNCTION_ALLOWED_KWARGS: dict[str, dict[str, set[str]]] = {
"run_test": {
"baseline": {"command"},
"sampling": {"sampling_args"},
"binary_rewrite": {"binary_rewrite_args", "cleanup_on_success"},
"runtime_instrument": {"runtime_instrument_args"},
"sys_run": {"sys_run_args"},
"causal": {"causal_args", "causal_mode"},
"python": {"python_version", "profile_args", "annotated", "standalone"},
},
"assert_regex": {
"baseline": {"baseline_pass_regex", "baseline_fail_regex"},
"sampling": {"sampling_pass_regex", "sampling_fail_regex"},
"binary_rewrite": {"binary_rewrite_pass_regex", "binary_rewrite_fail_regex"},
"runtime_instrument": {
"runtime_instrument_pass_regex",
"runtime_instrument_fail_regex",
},
"sys_run": {"sys_run_pass_regex", "sys_run_fail_regex"},
"causal": {"causal_pass_regex", "causal_fail_regex"},
"python": {"python_pass_regex", "python_fail_regex"},
},
}
def _filter_kwargs(function: str, mode: str, **kwargs: Any) -> dict[str, Any]:
"""Filter ``kwargs`` to those accepted by ``function`` for ``mode``.
This also verifies that the kwargs passed are valid for the given function.
If a kwarg is not valid, pytest.fail is called.
Returns:
A new dict containing only kwargs valid for ``(function, mode)``.
"""
allowed_per_mode = _FUNCTION_ALLOWED_KWARGS.get(function)
if allowed_per_mode is None:
pytest.fail(
f"_filter_kwargs called with unknown function '{function}'. "
f"Expected one of: {sorted(_FUNCTION_ALLOWED_KWARGS.keys())}."
)
mode_key = mode.replace("-", "_")
allowed_for_mode = allowed_per_mode.get(mode_key)
if allowed_for_mode is None:
pytest.fail(
f"Unknown mode '{mode}' for '{function}'. "
f"Expected one of: {sorted(allowed_per_mode.keys())}."
)
# Union of every kwarg accepted by any mode of this function. Anything
# outside this set is considered a typo and an error is raised.
all_known_for_function: set[str] = set().union(*allowed_per_mode.values())
unknown = set(kwargs) - all_known_for_function
if unknown:
pytest.fail(
f"{function}: unknown kwargs {sorted(unknown)}. "
f"Valid kwargs across all modes: {sorted(all_known_for_function)}."
)
return {k: v for k, v in kwargs.items() if k in allowed_for_mode}
# ============================================================================
# Base Test Class
# ============================================================================
class RocprofsysTest:
"""Base class that auto-captures parametrized values and common fixtures onto self."""
@pytest.fixture(autouse=True)
def _setup(
self,
run_test,
assert_regex,
assert_perfetto,
assert_rocpd,
assert_unified_memory_output,
assert_causal_json,
assert_file_exists,
assert_timemory,
assert_file_regex,
get_test_num_threads,
test_output_dir,
library_path,
):
self.run_test = run_test
self.assert_regex = assert_regex
self.assert_perfetto = assert_perfetto
self.assert_rocpd = assert_rocpd
self.assert_unified_memory_output = assert_unified_memory_output
self.assert_causal_json = assert_causal_json
self.assert_file_exists = assert_file_exists
self.assert_timemory = assert_timemory
self.assert_file_regex = assert_file_regex
self.num_threads = get_test_num_threads
self.test_output_dir = test_output_dir
self.library_path = library_path
# ============================================================================
# Test run and assertion fixtures
# ============================================================================
@pytest.fixture
def run_test(
request,
collect_result,
rocprof_config,
gpu_info,
test_output_dir,
):
"""Unified fixture to run any test runner type and handle pytest logic.
If a rocprof-sys binary is provided, uses "base_binary_environment" instead of "base_environment".
Args:
runner_type: One of "baseline", "sampling", "binary_rewrite",
"runtime_instrument", "sys_run"
target: Target executable name
run_args: Arguments passed to the target executable
env: Environment variables dict
launcher: Launcher to use (mpi or shmem)
num_procs: Number of processes (0 = disabled)
working_directory: Custom working directory
check_target_arch: If True, checks if the target supports the current system architectures (default: False)
Note: This requires @pytest.mark.gpu to be present
skip_on_error: If True, pytest.skip on non-zero return code (default: False = fail)
fail_on_pass: If True, pytest.fail on success and pytest.pass on failure (default: False)
fail_on_not_found: If True, pytest.fail when binary not found (default: False = skip)
fail_message: Custom failure message (default: "{runner_type} test failed: {output}")
no_base_env: If true, don't use the base environment (default: False)
**kwargs: Additional runner-specific arguments (see _FUNCTION_ALLOWED_KWARGS for valid kwargs)
Returns:
TestResult for further assertions
"""
def _run_test(
runner_type: str,
target: str,
env: Optional[dict[str, str]] = None,
run_args: Optional[list[str]] = None,
pre_run_args: Optional[list[str]] = None,
launcher: Optional[BaselineRunner.Launcher | str] = None,
num_procs: int = 0,
working_directory: Optional[Path] = None,
check_target_arch: bool = False,
skip_on_error: bool = False,
fail_on_pass: bool = False,
fail_on_not_found: bool = False,
fail_message: Optional[str] = None,
no_base_env: bool = False,
**kwargs,
) -> TestResult:
filtered_kwargs = _filter_kwargs("run_test", runner_type, **kwargs)
if num_procs > 0 and launcher is None:
pytest.fail(
f"num_procs={num_procs} was provided but no launcher was set. "
f"Pass launcher='<launcher_name>' alongside num_procs."
)
if runner_type == "causal" and "causal_mode" not in filtered_kwargs:
pytest.exit("causal_mode is required for causal tests", returncode=1)
runner_class = ROCPROFSYS_RUNNER_CLASSES.get(runner_type)
if not runner_class:
pytest.fail(
f"Invalid runner type: {runner_type}. Use: {list(ROCPROFSYS_RUNNER_CLASSES.keys())}"
)
# For GPU tests, ensure that the target supports at least one of the current system architectures
if request.node.get_closest_marker("gpu") and check_target_arch:
try:
target_path = rocprof_config.get_target_executable(target)
target_archs = get_target_gpu_arch(rocprof_config.rocm_path, target_path)
system_archs = gpu_info.architectures
if not any(arch in target_archs for arch in system_archs):
pytest.skip(
f"{target} does not support any of the current system architectures. "
f"{target} architectures: {target_archs}, system architectures: {system_archs}"
)
except FileNotFoundError:
pass
env = env.copy() if env else {}
# Timeout: ROCPROFSYS_CI_TIMEOUT env, else @pytest.mark.timeout, else default
ci_timeout_env = os.environ.get("ROCPROFSYS_CI_TIMEOUT")
if ci_timeout_env is not None:
# Shell-exported value: drives the subprocess timeout below and is
# already carried by the user env layer. Do NOT echo it into the
# test layer, or it would mask the real base default in dumps.
timeout = int(ci_timeout_env)
elif request.node.get_closest_marker("timeout"):
timeout = request.node.get_closest_marker("timeout").args[0]
env["ROCPROFSYS_CI_TIMEOUT"] = str(timeout)
else:
timeout = 300
env["ROCPROFSYS_CI_TIMEOUT"] = str(timeout)
# Verify that MPI is available for "mpi_optional" tests
if request.node.get_closest_marker("mpi_optional") and num_procs > 0:
if not request.node.get_closest_marker("mpi"):
num_procs = 0
try:
runner = runner_class(
config=rocprof_config,
target=target,
output_dir=test_output_dir,
run_args=run_args,
pre_run_args=pre_run_args,
env=env,
timeout=timeout,
launcher=launcher,
num_procs=num_procs,
working_directory=working_directory,
no_base_env=no_base_env,
**filtered_kwargs,
)
except FileNotFoundError:
if fail_on_not_found:
pytest.fail(f"{target} binary not found")
else:
pytest.skip(f"{target} binary not found")
result = runner.run()
collect_result(result)
if not result.success and not fail_on_pass:
short_msg = fail_message or f"{runner_type} test failed"
if skip_on_error:
pytest.skip(short_msg)
else:
pytest.fail(short_msg)
if fail_on_pass and result.success:
pytest.fail(f"{runner_type} test passed unexpectedly")
return result
return _run_test
@pytest.fixture
def assert_regex(subtests):
"""Fixture that returns an assert_regex function.
Args:
result: TestResult from run_test
mode: Optional runner type (e.g., "binary_rewrite", "sys_run"). If provided, looks up
mode-specific regexes from kwargs (see _FUNCTION_ALLOWED_KWARGS for valid kwargs)
subtest_name: Name shown in subtest output (defaults to "Regex validation")
pass_regex: Explicit list of pass regex patterns (used if mode is None or no mode-specific found)
fail_regex: Explicit list of fail regex patterns (used if mode is None or no mode-specific found)
use_abort_fail_regex: Whether to validate against ROCPROFSYS_ABORT_FAIL_REGEX (default: True)
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
**kwargs: Mode-specific regexes (see _FUNCTION_ALLOWED_KWARGS for valid kwargs)
"""
def _assert_regex(
result: TestResult,
mode: Optional[str] = None,
subtest_name: str = "Regex validation",
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
use_abort_fail_regex: bool = True,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
**kwargs,
) -> None:
if mode is None and kwargs:
pytest.fail(
f"assert_regex received mode-specific kwargs {sorted(kwargs)} but no "
f"'mode' was provided. Pass mode=... so they can be resolved, or use "
f"pass_regex/fail_regex directly."
)
if mode is not None:
filtered = _filter_kwargs("assert_regex", mode, **kwargs)
mode_key = mode.replace("-", "_")
mode_pass_regex = filtered.get(f"{mode_key}_pass_regex")
if mode_pass_regex is not None:
pass_regex = mode_pass_regex
mode_fail_regex = filtered.get(f"{mode_key}_fail_regex")
if mode_fail_regex is not None:
fail_regex = mode_fail_regex
with subtests.test(subtest_name):
validation = validate_regex(
result, pass_regex, fail_regex, use_abort_fail_regex
)
if not validation.is_valid:
msg = fail_message or f"Regex validation failed: {validation.message}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
return _assert_regex
@pytest.fixture
def assert_file_regex(subtests):
"""Variant of assert_regex that validates against a file."""
def _assert_file_regex(
file_path: Path,
subtest_name: str = "File regex validation",
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
use_abort_fail_regex: bool = True,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
with subtests.test(subtest_name):
validation = validate_file_regex(
file_path,
pass_regex,
fail_regex,
use_abort_fail_regex,
)
if not validation.is_valid:
msg = (
fail_message or f"File regex validation failed: {validation.message}"
)
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
return _assert_file_regex
@pytest.fixture
def assert_perfetto(subtests, tests_dir, request, test_output_dir):
"""Fixture that returns an assert_perfetto function.
Trace validation kwargs (``categories``, ``labels``, ``counts``, ``depths``,
``label_substrings``, etc.) are forwarded to
``validate_perfetto_trace``; see that function's docstring.
Args not from validate_perfetto_trace:
subtest_name: Name shown in subtest output (defaults to "Perfetto validation")
perfetto_file: (Optional) Name of the perfetto file in the test output directory (e.g., for merged.proto)
pass_regex: (Optional) Regex patterns that must be found in validation.stdout
fail_regex: (Optional) Regex patterns that must NOT be found in validation.stdout
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
"""
def _assert_perfetto(
result: TestResult,
subtest_name: str = "Perfetto validation",
perfetto_file: Optional[Path] = None,
categories: Optional[list[str]] = None,
labels: Optional[list[str]] = None,
counts: Optional[list[int]] = None,
depths: Optional[list[int]] = None,
label_substrings: Optional[list[str]] = None,
counter_names: Optional[list[str]] = None,
check_counter_pairing: bool = False,
key_names: Optional[list[str]] = None,
key_counts: Optional[list[int]] = None,
trace_processor_path: Optional[Path] = None,
print_output: bool = True,
timeout: int = 120,
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
with subtests.test(subtest_name):
if not check_use_perfetto():
pytest.skip("Perfetto is disabled")
# Perfetto file check
if perfetto_file is not None:
perfetto = Path(test_output_dir) / perfetto_file
else:
perfetto = result.perfetto_file
if perfetto is None:
pytest.fail("No Perfetto trace file was produced by the run")
if not perfetto.exists():
pytest.fail(f"Perfetto trace file {perfetto} not found")
validation = validate_perfetto_trace(
perfetto,
tests_dir=tests_dir,
categories=categories,
labels=labels,
counts=counts,
depths=depths,
label_substrings=label_substrings,
counter_names=counter_names,
check_counter_pairing=check_counter_pairing,
key_names=key_names,
key_counts=key_counts,
trace_processor_path=trace_processor_path,
print_output=print_output,
timeout=timeout,
)
output = f"Command: {validation.command}\n\n{validation.message}"
if not validation.is_valid:
msg = fail_message or f"Perfetto validation failed:\n{output}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
if pass_regex:
for pattern in pass_regex:
if not re.search(pattern, validation.stdout):
pytest.fail(
f"Pass regex not found: {pattern}\n{output}", pytrace=False
)
if fail_regex:
for pattern in fail_regex:
if re.search(pattern, validation.stdout):
pytest.fail(
f"Fail regex found: {pattern}\n{output}", pytrace=False
)
# On success echo only the command: validation.message may contain the
# full "-p"/--print perfetto dump, which floods ctest/CI logs. Failures
# above still surface the complete output.
_print_subtest_output(request, subtest_name, f"Command: {validation.command}")
return _assert_perfetto
@pytest.fixture
def assert_rocpd(subtests, tests_dir, request):
"""Fixture that returns an assert_rocpd function.
Must be used with @pytest.mark.rocpd("<env fixture name>")
Args not from validate_rocpd_database:
subtest_name: Name shown in subtest output (defaults to "ROCpd validation")
pass_regex: (Optional) Regex patterns that must be found in validation.stdout
fail_regex: (Optional) Regex patterns that must NOT be found in validation.stdout
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
gpu_category_to_skip: GPU categories to skip tagged validation queries for
(instinct, radeon, apu). Omit or pass empty to run all queries
"""
def _assert_rocpd(
result: TestResult,
subtest_name: str = "ROCpd validation",
rules_files: Optional[list[Path]] = None,
timeout: int = 60,
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
gpu_category_to_skip: Optional[list[str]] = None,
) -> None:
with subtests.test(subtest_name):
if not check_use_rocpd():
pytest.skip("ROCpd is disabled")
rocpd_files = result.rocpd_files
if not rocpd_files:
pytest.fail("ROCpd database not created")
existing_rules = None
if rules_files is not None:
existing_rules = [r for r in rules_files if r.exists()]
if not existing_rules:
pytest.fail("No validation rules found")
def validate_candidate(rocpd_file: Path) -> ValidationResult:
return validate_rocpd_database(
rocpd_file,
tests_dir=tests_dir,
rules_files=existing_rules,
timeout=timeout,
gpu_category_to_skip=gpu_category_to_skip,
)
passing_output, failures, global_failure = _validate_rocpd_candidates(
rocpd_files,
validate_candidate,
pass_regex=pass_regex,
fail_regex=fail_regex,
)
if global_failure is not None:
msg = fail_message or f"ROCpd validation failed:\n{global_failure}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg, pytrace=False)
elif passing_output is None:
output = "\n\n--- Next ROCpd candidate ---\n\n".join(failures)
msg = fail_message or f"ROCpd validation failed:\n{output}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg, pytrace=False)
_print_subtest_output(request, subtest_name, passing_output)
return _assert_rocpd
def _validate_rocpd_candidates(
rocpd_files: list[Path],
validate_candidate: Callable[[Path], ValidationResult],
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
) -> tuple[Optional[str], list[str], Optional[str]]:
"""Validate ROCpd candidates and return the first passing output.
Multi-process runs can emit multiple ROCpd databases. Some rank-local
databases may not contain the GPU rows required by a rule set, so the
validation succeeds if any emitted candidate fully validates. A fail regex
match is a global failure and stops validation immediately.
"""
failures: list[str] = []
for rocpd_file in rocpd_files:
validation = validate_candidate(rocpd_file)
output = f"Command: {validation.command}\n\n{validation.message}"
if fail_regex:
for pattern in fail_regex:
if re.search(pattern, validation.stdout):
return None, failures, f"Fail regex found: {pattern}\n{output}"
if not validation.is_valid:
failures.append(output)
continue
regex_failure = None
if pass_regex:
for pattern in pass_regex:
if not re.search(pattern, validation.stdout):
regex_failure = f"Pass regex not found: {pattern}"
break
if regex_failure is not None:
failures.append(f"{regex_failure}\n{output}")
continue
return output, failures, None
return None, failures, None
@pytest.fixture
def assert_timemory(subtests, tests_dir, request):
"""Fixture that returns an assert_timemory function.
Args not from validate_timemory_json:
subtest_name: Name shown in subtest output (defaults to "Timemory validation")
pass_regex: (Optional) Regex patterns that must be found in validation.stdout
fail_regex: (Optional) Regex patterns that must NOT be found in validation.stdout
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
"""
def _assert_timemory(
result: TestResult,
file_name: str,
metric: str,
subtest_name: str = "Timemory validation",
labels: Optional[list[str]] = None,
counts: Optional[list[int]] = None,
depths: Optional[list[int]] = None,
print_output: bool = True,
timeout: int = 60,
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
with subtests.test(subtest_name):
timemory_file = result.output_dir / file_name
if not timemory_file.exists():
pytest.fail(f"Timemory file not found: {timemory_file}")
validation = validate_timemory_json(
json_path=timemory_file,
tests_dir=tests_dir,
metric=metric,
labels=labels,
counts=counts,
depths=depths,
print_output=print_output,
timeout=timeout,
)
output = f"Command: {validation.command}\n\n{validation.message}"
if not validation.is_valid:
msg = fail_message or f"Timemory validation failed:\n{output}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
if pass_regex:
for pattern in pass_regex:
if not re.search(pattern, validation.stdout):
pytest.fail(
f"Pass regex not found: {pattern}\n{output}", pytrace=False
)
if fail_regex:
for pattern in fail_regex:
if re.search(pattern, validation.stdout):
pytest.fail(
f"Fail regex found: {pattern}\n{output}", pytrace=False
)
_print_subtest_output(request, subtest_name, output)
return _assert_timemory
@pytest.fixture
def assert_file_exists(subtests):
"""Fixture that returns an assert_file_exists function.
Args not from validate_file_exists:
subtest_name: Name shown in subtest output (defaults to "File existence validation")
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
"""
def _assert_file_exists(
path: Path | list[Path],
description: str = "File",
subtest_name: str = "File existence validation",
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
paths = [path] if isinstance(path, Path) else path
with subtests.test(subtest_name):
for p in paths:
validation = validate_file_exists(p, description)
if not validation.is_valid:
msg = (
fail_message
or f"File existence validation failed: {validation.message}"
)
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
return _assert_file_exists
@pytest.fixture
def assert_unified_memory_output(subtests, tests_dir, request):
"""Fixture that returns an assert_unified_memory_output function."""
def _assert_unified_memory_output(
result: TestResult,
subtest_name: str = "Unified-memory output validation",
timeout: int = 60,
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
with subtests.test(subtest_name):
validation = validate_unified_memory_outputs(
result.output_dir,
tests_dir=tests_dir,
timeout=timeout,
)
output = f"Command: {validation.command}\n\n{validation.message}"
if not validation.is_valid:
msg = fail_message or f"Unified-memory validation failed:\n{output}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
if pass_regex:
for pattern in pass_regex:
if not re.search(pattern, validation.stdout):
pytest.fail(
f"Pass regex not found: {pattern}\n{output}",
pytrace=False,
)
if fail_regex:
for pattern in fail_regex:
if re.search(pattern, validation.stdout):
pytest.fail(
f"Fail regex found: {pattern}\n{output}",
pytrace=False,
)
_print_subtest_output(request, subtest_name, output)
return _assert_unified_memory_output
@pytest.fixture
def assert_causal_json(subtests, tests_dir, request):
"""Fixture that returns an assert_causal_json function.
Args not from validate_causal_json:
pass_regex: (Optional) Regex patterns that must be found in validation.stdout
fail_regex: (Optional) Regex patterns that must NOT be found in validation.stdout
skip_on_fail: If True, skip instead of fail when validation fails
fail_message: Custom message for failure (defaults to validation message)
"""
def _assert_causal_json(
result: TestResult,
file_name: str,
subtest_name: str = "Causal JSON validation",
ci_mode: bool = False,
additional_args: Optional[list[str]] = None,
timeout: int = 60,
pass_regex: Optional[list[str]] = None,
fail_regex: Optional[list[str]] = None,
skip_on_fail: bool = False,
fail_message: Optional[str] = None,
) -> None:
with subtests.test(subtest_name):
causal_file = result.output_dir / file_name
if not causal_file.exists():
pytest.fail(f"Causal JSON file not found: {causal_file}")
validation = validate_causal_json(
json_path=causal_file,
tests_dir=tests_dir,
ci_mode=ci_mode,
additional_args=additional_args,
timeout=timeout,
)
output = f"Command: {validation.command}\n\n{validation.message}"
if not validation.is_valid:
if fail_message:
msg = f"{fail_message}:\n{output}"
else:
msg = f"Causal JSON validation failed:\n{output}"
if skip_on_fail:
pytest.skip(msg)
else:
pytest.fail(msg)
if pass_regex:
for pattern in pass_regex:
if not re.search(pattern, validation.stdout):
pytest.fail(
f"Pass regex not found: {pattern}\n{output}", pytrace=False
)
if fail_regex:
for pattern in fail_regex:
if re.search(pattern, validation.stdout):
pytest.fail(
f"Fail regex found: {pattern}\n{output}", pytrace=False
)
_print_subtest_output(request, subtest_name, output)
return _assert_causal_json