Files
TheRock/rocm-systems/projects/rocprofiler-systems/tests/pytest/rocprofsys/runners.py
T
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

727 lines
24 KiB
Python

# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""
Test runners for different rocprofiler-systems instrumentation modes.
Provides classes for running tests with:
- Baseline execution (no instrumentation)
- Sampling instrumentation
- Binary rewrite instrumentation
- Runtime instrumentation
- rocprof-sys-run wrapper
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
import os
from pathlib import Path
import shutil
import subprocess
from typing import Optional
from .config import RocprofsysConfig
from .environment import TestEnvironment, TestEnvKind
def safe_remove(path: Path) -> None:
"""Safely remove a file or directory, ignoring errors."""
try:
if path.is_dir():
shutil.rmtree(path)
elif path.is_file():
path.unlink()
except OSError:
pass
def _decode_bytes(data: bytes | None, encoding: str = "utf-8") -> str:
"""Decode bytes to string, returning empty string if None."""
if data is None:
return ""
return data.decode(encoding, errors="replace")
@dataclass
class TestResult:
"""Result of a test execution
Attributes:
returncode: Process exit code
test_output: Standard output and error content
extra_output: Extra output set by the test itself
(as of now, only used for timeout errors)
output_dir: Directory containing output files
command: The command that was executed
environment: The layered TestEnvironment used for the run
duration: Execution time in seconds (if measured)
_instrumented_files: List of instrumented binary files created
"""
returncode: int
test_output: str
output_dir: Path
command: list[str]
environment: TestEnvironment
extra_output: Optional[str] = None
duration: Optional[float] = None
_instrumented_files: list[Path] = field(default_factory=list)
__test__ = False
@property
def success(self) -> bool:
"""Check if test execution succeeded.
Returns True only if:
- Return code is 0
"""
return self.returncode == 0
@property
def perfetto_file(self) -> Optional[Path]:
candidates = [
self.output_dir / "perfetto-trace.proto",
self.output_dir / "perfetto-trace-0.proto",
]
for candidate in candidates:
if candidate.exists():
return candidate
protos = list(self.output_dir.glob("perfetto-trace*.proto"))
return protos[0] if protos else None
@property
def rocpd_files(self) -> list[Path]:
candidate = self.output_dir / "rocpd.db"
if candidate.exists():
return [candidate]
return sorted(self.output_dir.glob("*.db"))
@property
def timemory_files(self) -> list[Path]:
"""List of timemory output files."""
return list(self.output_dir.glob("*.json")) + list(self.output_dir.glob("*.txt"))
def get_output_file(self, pattern: str) -> Optional[Path]:
"""Get an output file matching the given pattern.
Args:
pattern: Glob pattern to match
Returns:
First matching file or None
"""
matches = list(self.output_dir.glob(pattern))
return matches[0] if matches else None
def cleanup(self, keep_on_failure: bool = True) -> None:
"""Clean up test output files.
Args:
keep_on_failure: If True, keep files when test failed for debugging
"""
if os.environ.get("ROCPROFSYS_KEEP_TEST_OUTPUT", "1") == "1":
return
if keep_on_failure and not self.success:
return
# Clean up instrumented binaries
for inst_file in self._instrumented_files:
safe_remove(inst_file)
# Clean up output directory
if self.output_dir.exists():
safe_remove(self.output_dir)
def cleanup_instrumented_binaries(self) -> None:
"""Clean up only the instrumented binary files."""
if os.environ.get("ROCPROFSYS_KEEP_TEST_OUTPUT", "1") == "1":
return
for inst_file in self._instrumented_files:
safe_remove(inst_file)
# Also clean any .inst files in output directory
if self.output_dir.exists():
for inst_file in self.output_dir.glob("*.inst"):
safe_remove(inst_file)
class BaseRunner(ABC):
"""Abstract base class for test runners."""
class Launcher(str, Enum):
"""Supported launcher types for multi-process test execution."""
MPI = "mpi"
SHMEM = "shmem"
def get_executable(self, capabilities) -> Optional[Path]:
return {
type(self).MPI: capabilities.mpiexec_exec,
type(self).SHMEM: capabilities.oshrun_exec,
}[self]
@property
def supports_oversubscribe_probe(self) -> bool:
return self is type(self).MPI
def __init__(
self,
config: RocprofsysConfig,
test_type: TestEnvKind,
target: str,
output_dir: Path,
run_args: Optional[list[str]] = None,
pre_run_args: Optional[list[str]] = None,
env: Optional[dict[str, str]] = None,
timeout: int = 300,
launcher: Optional[BaseRunner.Launcher | str] = None,
num_procs: int = 0,
working_directory: Optional[Path] = None,
no_base_env: bool = False,
):
self.config = config
self.target = target
self.target_exe = config.get_target_executable(target)
self.output_dir = Path(output_dir)
self.run_args = run_args or []
self.pre_run_args = pre_run_args or []
self.timeout = timeout
try:
self.launcher = None if launcher is None else self.Launcher(launcher)
except ValueError as exc:
valid_launchers = ", ".join(_launcher.value for _launcher in self.Launcher)
raise ValueError(
f"Unknown launcher: {launcher!r}. Expected one of: {valid_launchers}"
) from exc
self.num_procs = num_procs
self.working_directory = working_directory or config.rocprofsys_build_dir
self.environment = TestEnvironment()
self.environment.set_base_environment(
config, TestEnvKind.NONE if no_base_env else test_type
)
# LD_LIBRARY_PATH default on the test layer; the test's own env (applied
# next) may override it (e.g. Julia adds extra lib dirs).
self.environment.set_test_environment(
{"LD_LIBRARY_PATH": config.get_library_path()}
)
# LD_PRELOAD default (sanitizer builds prepend the asan runtime).
preload = config.get_preload_path()
if preload:
self.environment.set_test_environment({"LD_PRELOAD": preload})
if env:
self.environment.set_test_environment(env)
# ROCPROFSYS_OUTPUT_PATH is framework-controlled
self.environment.set_test_environment(
{"ROCPROFSYS_OUTPUT_PATH": str(self.output_dir)}
)
self.environment.set_user_environment()
self.env = self.environment.get_merged_environment(config)
@abstractmethod
def build_command(self) -> list[str]:
"""Build the command to execute.
Returns:
List of command components
"""
pass
def _wrap_with_launcher(self, command: list[str]) -> list[str]:
"""Wrap command with launcher if needed.
Args:
command: Base command
Returns:
Command wrapped with launcher if needed
"""
if self.launcher is None or self.num_procs == 0:
return command
launcher_exec = self.launcher.get_executable(self.config.capabilities)
if launcher_exec is None:
return command
cmd = [
str(launcher_exec),
"-n",
str(self.num_procs),
]
if self.launcher.supports_oversubscribe_probe:
try:
result = subprocess.run(
[str(launcher_exec), "--oversubscribe", "-n", "1", "true"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=5,
)
if result.returncode == 0:
cmd.insert(1, "--oversubscribe")
except (subprocess.TimeoutExpired, OSError):
pass
if self.launcher is type(self).Launcher.SHMEM and command:
# Some PRRTE-based oshrun builds strip the first literal `--` from
# the application argv, breaking `rocprof-sys-run -- <binary>`.
# A second `--` survives the strip, so insert a decoy to absorb it.
# The condition is probed at runtime (see capabilities.oshrun_strips_double_dash)
# rather than checked by version number, because distro packages may
# bundle a different PRRTE than the upstream Open MPI tarball.
if self.config.capabilities.oshrun_strips_double_dash:
command = [command[0], "--"] + command[1:]
return cmd + command
def run(self) -> TestResult:
"""Execute the test.
Returns:
TestResult with execution results
"""
import time
self.output_dir.mkdir(parents=True, exist_ok=True)
try:
command = self.build_command()
except FileNotFoundError as e:
return TestResult(
returncode=-1,
test_output=f"{e}",
output_dir=self.output_dir,
command=["Failed to build command"],
environment=self.environment,
duration=0,
)
command = self._wrap_with_launcher(command)
start_time = time.time()
try:
result = subprocess.run(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=self.timeout,
env=self.env,
cwd=self.working_directory,
)
duration = time.time() - start_time
test_result = TestResult(
returncode=result.returncode,
test_output=_decode_bytes(result.stdout),
output_dir=self.output_dir,
command=command,
environment=self.environment,
duration=duration,
)
except subprocess.TimeoutExpired as e:
duration = time.time() - start_time
stdout = _decode_bytes(e.stdout)
stderr = _decode_bytes(e.stderr)
test_result = TestResult(
returncode=-1,
test_output=stdout,
extra_output=f"Timeout after {self.timeout}s\n{stderr}",
output_dir=self.output_dir,
command=command,
environment=self.environment,
duration=duration,
)
return test_result
class BaselineRunner(BaseRunner):
"""Run target without any instrumentation.
Can also be used to run arbitrary commands by providing the `command` parameter.
- command + run_args are executed as a single command
If a rocprof-sys binary is provided, uses "base_binary_environment" instead of "base_environment".
Args:
config: rocprofiler-systems configuration
target: Name of target executable (used if command is None)
output_dir: Directory for output files
command: Optional full command to run instead of target executable
**kwargs: Additional arguments passed to BaseRunner
"""
# rocprof-sys binaries that should use the "binary" base environment preset
ROCPROFSYS_BINARIES = {
"rocprof-sys-instrument",
"rocprof-sys-sample",
"rocprof-sys-run",
"rocprof-sys-avail",
}
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
command: Optional[list[str]] = None,
**kwargs,
):
test_type = (
TestEnvKind.BINARY if target in self.ROCPROFSYS_BINARIES else TestEnvKind.BASE
)
super().__init__(config, test_type, target, output_dir, **kwargs)
self.command = command
def build_command(self) -> list[str]:
if self.command:
return self.pre_run_args + self.command + self.run_args
return self.pre_run_args + [str(self.target_exe)] + self.run_args
class SamplingRunner(BaseRunner):
"""Run target with sampling instrumentation."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
sampling_args: Optional[list[str]] = None,
**kwargs,
):
"""Initialize sampling runner.
Args:
config: rocprofiler-systems configuration
target: Name of target executable
output_dir: Directory for output files
sampling_args: Arguments for rocprof-sys-sample
**kwargs: Additional arguments passed to BaseRunner
"""
super().__init__(config, TestEnvKind.BASE, target, output_dir, **kwargs)
self.sampling_args = sampling_args or []
def build_command(self) -> list[str]:
return (
[str(self.config.rocprofsys_sample)]
+ self.sampling_args
+ ["--"]
+ self.pre_run_args
+ [str(self.target_exe)]
+ self.run_args
)
class BinaryRewriteRunner(BaseRunner):
"""Run binary rewrite instrumentation (two-phase: rewrite then run)."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
binary_rewrite_args: Optional[list[str]] = None,
cleanup_on_success: bool = False,
**kwargs,
):
"""Initialize binary rewrite runner.
Args:
config: rocprofiler-systems configuration
target: Name of target executable
output_dir: Directory for output files
binary_rewrite_args: Arguments for rocprof-sys-instrument
cleanup_on_success: Whether to clean up instrumented binary immediately
after successful run. Default is False - let the test_output_dir
fixture handle cleanup after validation completes.
**kwargs: Additional arguments passed to BaseRunner
"""
super().__init__(config, TestEnvKind.BASE, target, output_dir, **kwargs)
self.binary_rewrite_args = binary_rewrite_args or []
self.instrumented_exe = output_dir / f"{target}.inst"
self.cleanup_on_success = cleanup_on_success
self._instrumented_files: list[Path] = []
def rewrite(self) -> TestResult:
"""Perform binary rewrite phase.
Returns:
TestResult from rewrite operation
"""
import time
self.output_dir.mkdir(parents=True, exist_ok=True)
command = (
[str(self.config.rocprofsys_instrument)]
+ ["-o", str(self.instrumented_exe)]
+ self.binary_rewrite_args
+ ["--print-instrumented", "functions"]
+ ["--", str(self.target_exe)]
)
start_time = time.time()
try:
result = subprocess.run(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=self.timeout,
env=self.env,
cwd=self.config.rocprofsys_build_dir,
)
duration = time.time() - start_time
test_result = TestResult(
returncode=result.returncode,
test_output=_decode_bytes(result.stdout),
output_dir=self.output_dir,
command=command,
environment=self.environment,
duration=duration,
_instrumented_files=self._instrumented_files.copy(),
)
except subprocess.TimeoutExpired as e:
duration = time.time() - start_time
stdout = _decode_bytes(e.stdout)
stderr = _decode_bytes(e.stderr)
test_result = TestResult(
returncode=-1,
test_output=stdout,
extra_output=f"Timeout after {self.timeout}s (rewrite phase)\n{stderr}",
output_dir=self.output_dir,
command=command,
environment=self.environment,
duration=duration,
_instrumented_files=self._instrumented_files.copy(),
)
# Track instrumented files for cleanup
if self.instrumented_exe.exists():
self._instrumented_files.append(self.instrumented_exe)
return test_result
def build_command(self) -> list[str]:
"""Build command to run the instrumented binary."""
return (
[str(self.config.rocprofsys_run)]
+ ["--"]
+ self.pre_run_args
+ [str(self.instrumented_exe)]
+ self.run_args
)
def run(self) -> TestResult:
"""Execute full rewrite + run sequence.
Returns:
TestResult from full rewrite + run sequence
Note:
By default, cleanup is handled by the test_output_dir fixture
AFTER the test completes (including validation). Set cleanup_on_success=True
only if you want immediate cleanup of .inst files (validation files are
preserved regardless).
"""
# First, perform rewrite
rewrite_result = self.rewrite()
if not rewrite_result.success:
return rewrite_result
# Then run the instrumented binary
run_result = super().run()
# Add instrumented files to result for cleanup (used by fixtures)
run_result._instrumented_files = self._instrumented_files.copy()
# Optional immediate cleanup of .inst files only (NOT validation files)
# Default is False - let test_output_dir fixture handle all cleanup
# after validation completes
if self.cleanup_on_success and run_result.success:
run_result.cleanup_instrumented_binaries()
# Combine rewrite and run output. Surface the rewrite command (the
# rocprof-sys-instrument invocation that produces the .inst) too, since
# the reported result only carries the run-phase command.
rewrite_cmd = " ".join(str(c) for c in rewrite_result.command)
run_result.test_output = (
f"=== REWRITE PHASE ===\n"
f"Command: {rewrite_cmd}\n\n"
f"{rewrite_result.test_output}\n"
f"=== RUN PHASE ===\n{run_result.test_output}"
)
run_result.duration = rewrite_result.duration + run_result.duration
extra_parts = []
if rewrite_result.extra_output:
extra_parts.append(f"=== REWRITE PHASE ===\n{rewrite_result.extra_output}")
if run_result.extra_output:
extra_parts.append(f"=== RUN PHASE ===\n{run_result.extra_output}")
if extra_parts:
run_result.extra_output = "\n".join(extra_parts)
return run_result
def cleanup(self) -> None:
"""Clean up instrumented binary files."""
if os.environ.get("ROCPROFSYS_KEEP_TEST_OUTPUT", "1") == "1":
return
for inst_file in self._instrumented_files:
safe_remove(inst_file)
# Also clean any .inst files in output directory
if self.output_dir.exists():
for inst_file in self.output_dir.glob("*.inst"):
safe_remove(inst_file)
class RuntimeInstrumentRunner(BaseRunner):
"""Run target with runtime instrumentation."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
runtime_instrument_args: Optional[list[str]] = None,
**kwargs,
):
"""Initialize runtime instrument runner.
Args:
config: rocprofiler-systems configuration
target: Name of target executable
output_dir: Directory for output files
runtime_instrument_args: Arguments for rocprof-sys-instrument
**kwargs: Additional arguments passed to BaseRunner
"""
super().__init__(config, TestEnvKind.BASE, target, output_dir, **kwargs)
self.runtime_instrument_args = runtime_instrument_args or []
def build_command(self) -> list[str]:
return (
[str(self.config.rocprofsys_instrument)]
+ self.runtime_instrument_args
+ ["--print-instrumented", "functions"]
+ ["--"]
+ self.pre_run_args
+ [str(self.target_exe)]
+ self.run_args
)
class SysRunRunner(BaseRunner):
"""Run target with rocprof-sys-run wrapper."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
sys_run_args: Optional[list[str]] = None,
**kwargs,
):
"""Initialize sys-run runner.
Args:
config: rocprofiler-systems configuration
target: Name of target executable
output_dir: Directory for output files
sys_run_args: Arguments for rocprof-sys-run (before --)
**kwargs: Additional arguments passed to BaseRunner
"""
super().__init__(config, TestEnvKind.BASE, target, output_dir, **kwargs)
self.sys_run_args = sys_run_args or []
def build_command(self) -> list[str]:
return (
[str(self.config.rocprofsys_run)]
+ self.sys_run_args
+ ["--"]
+ self.pre_run_args
+ [str(self.target_exe)]
+ self.run_args
)
class CausalRunner(BaseRunner):
"""Run target with rocprof-sys-causal wrapper."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
causal_mode: str,
causal_args: Optional[list[str]] = None,
**kwargs,
):
"""Initialize causal runner.
Args:
config: rocprofiler-systems configuration
target: Name of target executable
output_dir: Directory for output files
causal_mode: Causal mode (function/func or line)
causal_args: Arguments for rocprof-sys-causal
**kwargs: Additional arguments passed to BaseRunner
"""
super().__init__(config, TestEnvKind.CAUSAL, target, output_dir, **kwargs)
self.causal_mode = causal_mode
self.causal_args = causal_args or []
def build_command(self) -> list[str]:
return (
[str(self.config.rocprofsys_causal)]
+ ["--reset", "-m", str(self.causal_mode)]
+ self.causal_args
+ ["--"]
+ self.pre_run_args
+ [str(self.target_exe)]
+ self.run_args
)
class PythonRunner(BaseRunner):
"""Run Python target script."""
def __init__(
self,
config: RocprofsysConfig,
target: str,
output_dir: Path,
profile_args: Optional[list[str]] = None,
python_version: Optional[str] = None,
annotated: bool = False,
standalone: bool = False,
**kwargs,
):
super().__init__(config, TestEnvKind.PYTHON, target, output_dir, **kwargs)
self.python_version = python_version
self.annotated = annotated
self.standalone = standalone
self.profile_args = profile_args or []
def build_command(self) -> list[str]:
python_executable = self.config.capabilities.get_python_executable(
self.python_version
)
command = [str(python_executable)]
if not self.standalone:
command.extend(["-m", "rocprofsys"])
if self.profile_args:
command.extend(self.profile_args)
if self.annotated:
command.extend(["--annotate-trace"])
command.extend(["--"])
command.extend(self.pre_run_args + [str(self.target_exe)] + self.run_args)
return command