Files
TheRock/rocm-systems/projects/rocprofiler-systems/tests/pytest/DEVELOPMENT.md
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

303 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# rocprofiler-systems Test Infrastructure
## rocprofsys Package
This package contains the code infrastructure required to run pytests. It contains 7 main components:
### cache.py
A persistent, cross-process cache for expensive test-setup probes. CTest spawns many short-lived pytest processes; without caching, each would re-run the same `rocminfo` / `amd-smi` / `mpicc` / `ldd` / `python --version` subprocesses. `cache.py` stores probe results in a single per-build-tree JSON file so each probe runs once per CTest session. See [Caching Expensive Probes](#caching-expensive-probes) for how (and when) to use it.
### capabilities.py
Contains functions that allow pytests to discover what the current system supports. Everything here is lazy-initialized and, where the value is session-constant, cached across processes (see [Caching Expensive Probes](#caching-expensive-probes)). This contains information on the system that is not required for every test. Should only be accessed through an instance of a `RocprofsysConfig` (see `config.py`).
### config.py
Contains configuration information used by every test. This includes the location of core binaries, an instance of the `SystemCapabilities` class, the computed `LD_LIBRARY_PATH` and some core helper functions.
### environment.py
Contains `TestEnvironment`, which models the per-run environment as three layers ordered from lowest to highest precedence: `base` (framework defaults for the test type), `test` (settings supplied by the test, plus framework-injected values like `ROCPROFSYS_OUTPUT_PATH`), and `user` (the inherited shell environment). A runner builds these layers and merges them (highest layer wins) into the environment handed to the subprocess.
### gpu.py
Contains information about the GPU on the system. Also finds `rocminfo` and `llvm-objdump`/`roc-obj-ls` binaries used to check if a test binary was compiled for the proper architecture.
### runners.py
Contains classes corresponding to each type of test. For example, a runtime instrumentation test would run through the `RuntimeInstrumentRunner` class. These runner classes handle test execution and return an instance of `TestResult`, which contains the information one would need to evaluate whether a test has passed or failed.
### validators.py
Contains wrappers for the validation scripts and other helper functions that can be used during a test. They return a `ValidationResult` which can be used to determine if a test has failed or passed. In practice, the `assert_` fixtures in `conftest.py` wrap these validators and should be used instead.
## Caching Expensive Probes
`cache.py` exists for one reason: **CTest runs each test in its own short-lived pytest process**, so any value computed by shelling out to a subprocess would otherwise be recomputed hundreds of times. The `rocprofiler-systems-pytest-config` setup fixture regenerates the cache once at the start of a `ctest` run (writing every value to a shared JSON file under `/tmp/$USER/`), the cleanup fixture deletes it at the end, and every test process in between reads the values back instead of re-probing.
> **Rule of thumb:** if your code launches a **subprocess** (or does an expensive filesystem scan) to compute a value that is constant for the whole build tree, cache it here. In-process memoization alone (`functools.lru_cache` / `cached_property`) does not help across CTest's many single-test processes — the persistent cache is what makes the probe run once per *session* instead of once per *process*.
### The API
Two decorators, both in `cache.py`:
| Decorator | Use for | Keyed on |
| --- | --- | --- |
| `persistent_cache("ns.name")` | a function/method you **call** | namespace + call args |
| `persistent_cached_property` | a computed **attribute** (`obj.x`) | class + attribute name |
- **System capabilities (most common):** add the probe as a `persistent_cached_property` on `SystemCapabilities` (`capabilities.py`). Config setup introspects the class and warms it automatically, so a new capability needs no extra wiring.
- **Standalone functions:** decorate with `@persistent_cache("ns.name")`; arguments become part of the key. For an instance method that also takes arguments, add `method=True` so `self` is dropped from the key (e.g. `target_support_mpi(target_path)`).
- **Return types:** `None`/`bool`/`int`/`float`/`str`/`Path`/`tuple`/`set`/`list`/`dict`, and any dataclass whose fields are (recursively) those, serialize automatically. Supply `to_cache`/`from_cache` only for something exotic — a non-dataclass that isn't JSON-native.
### When NOT to persist
A `persistent_cached_property` key ignores per-process inputs, so never use it for a value that varies with a CLI flag or other per-process state (e.g. `--python-versions`) — one process's result would leak into another. Pass such inputs as function arguments (so they enter the key) or don't cache. See `supported_python_versions` in `capabilities.py`.
### Safety
Reads and I/O are **fail-open**: a corrupt/stale entry or an I/O error just recomputes, so the cache can't break a test for environmental reasons. But an unserializable value raises `SerializationError`, surfacing the bug at config-setup instead of silently re-probing in every process. Set `ROCPROFSYS_DISABLE_TEST_CACHE=1` to disable caching entirely.
## conftest.py
`conftest.py` contains all the logic required for pytest to parse the `test_*.py` code and generate tests.
### Core Classes
- **`RocprofsysTest`**: Base class for all test classes. It auto-injects common fixtures (`run_test`, `assert_regex`, `test_output_dir`, etc.) onto `self` via its `_setup` fixture, so test methods can call `self.run_test(...)`, `self.assert_regex(...)`, etc. directly.
### Key Hooks
- **`pytest_configure`**: Registers all custom markers and sets up CLI options.
- **`pytest_collection_modifyitems`**: Handles test skipping based on marker conditions.
- **`_generate_ctest_definitions`**: Generates a `CTestTestfile.cmake` from collected pytest items.
### Subtests (Validation Fixtures)
These fixtures run as **subtests**, meaning multiple validations within a single test are independently reported (one can fail without blocking others). They are automatically injected into `RocprofsysTest` via its `_setup` fixture, so test methods access them as `self.assert_regex(...)`, `self.assert_perfetto(...)`, etc.
| Fixture | Description |
| --------- | ------------- |
| `assert_regex` | Validates test output against pass/fail regex patterns. Patterns can be per-mode (e.g., different patterns for `binary_rewrite` vs `sampling`). |
| `assert_file_regex` | Like `assert_regex` but validates against a file's contents. |
| `assert_perfetto` | Validates that a Perfetto trace was generated and optionally checks its contents. |
| `assert_rocpd` | Validates that a ROCpd database was created. Requires `@pytest.mark.rocpd("env_name")`. |
| `assert_timemory` | Validates timemory JSON output files. |
| `assert_file_exists` | Validates that a specific file exists in the output directory. |
| `assert_unified_memory_output` | Validates unified-memory text and JSON outputs (`unified_memory*.txt` / `unified_memory*.json`) under the test output directory. |
| `assert_causal_json` | Validates causal profiling JSON output. |
> For precise, one-off checks, plain `assert` statements can also be used. They are reported as a single test failure (not a subtest), but the same runner output is included in the failure report.
See the docstrings in `conftest.py` for full argument details.
## Writing a Test
### General Rules
At the minimum, the following rules should always be followed. If you have a reason not to, a comment should be left explaining the reasoning behind your choice.
- Test methods should always be inside a test class.
- Every test class should inherit from `RocprofsysTest`.
- At minimum, a test should use both `run_test` and `assert_regex`. They have complementary responsibilities: `run_test` performs process-level validation (subprocess launch, exit code, signals, timeout), while `assert_regex` performs content-level validation (matching against pass/fail patterns in the captured output). A test that only calls `run_test` will detect a crash but not a silent regression where the binary runs to completion without emitting the expected instrumentation output.
### Runner Modes
When parametrizing a test to run against different runner modes (e.g., sampling, sys_run), use `"mode"` as the parameter name:
```python
class TestExample(RocprofsysTest):
@pytest.mark.parametrize("mode", ["sampling", "sys_run"])
def test(self, mode, ...):
result = self.run_test(mode, ...)
self.assert_regex(result)
```
If a test is hard-coded to a single mode (no `@pytest.mark.parametrize("mode", ...)` decorator), tag it with `@pytest.mark.<mode>` (e.g. `@pytest.mark.sampling`) so `_generate_ctest_definitions` records the correct CTest mode label. When `parametrize("mode", [...])` is used — even with a single-element list — this is handled automatically.
### Test Parametrization
Parametrizing a test affects the generated CTest name. The order of `@pytest.mark.parametrize` decorators determines parameter order in the name. The **runner mode should always come last** so that tests group naturally when sorted:
```python
# Good: parameters first, mode last
@pytest.mark.class_name("transpose")
class TestTranspose(RocprofsysTest):
@pytest.mark.parametrize("mode", ["sampling", "sys_run"])
@pytest.mark.parametrize("iterations,tile_dim,block_rows", [(1, 16, 16), (2, 32, 32)])
def test_parametrized(self, mode, iterations, tile_dim, block_rows):
...
```
This produces names like:
```text
transpose-parametrized-1-16-16-sampling
transpose-parametrized-1-16-16-sys_run
transpose-parametrized-2-32-32-sampling
transpose-parametrized-2-32-32-sys_run
```
Note: pytest applies decorators bottom-up, so the **bottom** `@pytest.mark.parametrize` varies first (outermost loop). Place `mode` on top so it varies last in the name.
### Standardized test names
During collection, `_standardize_test_name()` in `conftest.py` builds a short name used for `-k` filtering and CTest identity.
- The `test_` / `test-` prefix is stripped from the method name.
- By default, the **`Test`** prefix is stripped from the class name, then the class segment and method segment are joined.
**Shape:** Prefer names that read as **`<word>-<word>-...`**. Standardized names use **lowercase hyphenated** form: underscores become hyphens, repeated hyphens are collapsed, and the combined string is lowercased.
**Long CamelCase classes:** A class like `TestRocprofilerSystemsRun` would produce a long, hard-to-read segment. Put **`@pytest.mark.class_name("rocprofiler-systems-run")`** on the class to supply an explicit hyphenated prefix. Example:
```python
@pytest.mark.class_name("rocprofiler-systems-run")
class TestRocprofilerSystemsRun(RocprofsysTest):
def test_help(self):
...
```
That yields a stable name such as `rocprofiler-systems-run-help`.
`@pytest.mark.depends_on(...)` refers to the **standardized** name; use the same naming rules when declaring dependencies.
### ROCpd Validation
ROCpd requires an environment fixture for injection. Mark the test with `@pytest.mark.rocpd("<env_fixture_name>")`. Do **not** set `ROCPROFSYS_USE_ROCPD=ON` explicitly. It is injected automatically when conditions are met.
```python
@pytest.fixture
def my_env() -> dict[str, str]:
return {} # Do NOT specify ROCPROFSYS_USE_ROCPD=ON
class TestExample(RocprofsysTest):
@pytest.mark.rocpd("my_env")
def test(self, my_env):
result = self.run_test("sampling", "target", env=my_env)
self.assert_rocpd(result) # Skipped automatically if ROCpd is unavailable
```
### Custom Commands
To run an arbitrary command, use the `baseline` runner with the `command` option. You must provide absolute paths and handle missing targets yourself:
```python
class TestExample(RocprofsysTest):
def test(self, rocprof_config):
script_path = rocprof_config.rocprofsys_tests_dir / "example-script.sh"
if not script_path.exists():
pytest.skip("example-script.sh not found")
try:
target = rocprof_config.get_target_executable("example-binary")
except FileNotFoundError:
pytest.skip("example-binary not found")
result = self.run_test(
"baseline", target, command=[script_path, target]
)
self.assert_regex(result)
```
> If your custom command uses a rocprof-sys binary (e.g., `rocprof-sys-instrument`), add the corresponding marker (e.g., `@pytest.mark.runtime_instrument`).
### Timeouts
Tests have a default timeout of 300 seconds (including subtests). To override, use `@pytest.mark.timeout(<seconds>)`. This timeout is passed to CTest.
### Serialization
If a test requires significant resources, mark it with `@pytest.mark.serialize` to prevent it from running in parallel.
### Test Dependencies
If a test depends on the output of another test, use `depends_on` and `preserve` together.
Use the **standardized test name** of the producer in both `@pytest.mark.depends_on(...)` and any path under `test_output_base` (output directories are named after `request.node.name`, which is set during collection). Those names follow the rules in **Standardized test names** above—derive them from class + method (and parametrization) the same way, or copy the name from a generated `CTestTestfile.cmake` / `ctest -N` listing when in doubt.
- `@pytest.mark.preserve("file1", "file2", ...)` — Prevents files from being deleted after the test completes, even when `ROCPROFSYS_KEEP_TEST_OUTPUT=0`.
- `@pytest.mark.depends_on("producer-generate", ...)` — Declares a CTest dependency on one or more tests. Each argument must **exactly** match the producer’s standardized name (implementation: `_standardize_test_name()` in `conftest.py`).
```python
class TestProducer(RocprofsysTest):
@pytest.mark.preserve("coverage.json")
def test_generate(self, ...):
...
class TestConsumer(RocprofsysTest):
@pytest.mark.depends_on("producer-generate")
def test_consume(self, test_output_base):
file_path = test_output_base / "producer-generate" / "coverage.json"
...
```
### Adding Markers
**Every marker must be registered in `pytest_configure()` before use.** Check the `non_functional_markers` and `generic_functional_markers` lists first; if the marker is not there, add it.
There are two types of markers:
- **Informational markers** — Used to categorize tests for CTest label filtering (e.g., `-L "avail"`). They don't affect test execution. Add these to the `non_functional_markers` list.
- **Functional markers** — Affect test behavior, typically used to skip tests based on system capabilities. Their logic is implemented in `pytest_collection_modifyitems`. Add these to the `generic_functional_markers` list (or register them individually with `config.addinivalue_line` if they need a custom description).
When adding a functional marker:
1. Register it in `pytest_configure()`.
2. Add skip logic in `pytest_collection_modifyitems`. The convention is to define a checker function named `<keyword>_unavailable_reason(...)` that returns either `None` (when the requirement is met) or a `str` explaining why the test must be skipped (e.g. `gpu_unavailable_reason`, `mpi_unavailable_reason`). Then plug that function into the `_skip(...)` call for your marker.
3. If the marker depends on a system capability not already tracked by `SystemCapabilities`, add it to `capabilities.py`.
**CTest label behavior:** By default, all markers are included as CTest labels (e.g., `ctest -L "rocm"` filters by the `@pytest.mark.rocm` marker). To change how a marker appears in the generated CTest definitions, add it to one of these sets in `_generate_ctest_definitions()`:
- `no_report_markers` — Marker is **not** added as a CTest label (e.g., `timeout`, `serialize`).
- `no_report_args_markers` — Marker name is added as a label, but its **arguments are hidden** (e.g., `rocpd`).
- `only_report_args_markers` — Only the marker's **arguments** are added as labels, not the marker name itself (e.g., `mpi_implementation`).
### Template
```python
# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""<Description of what this test module covers>."""
from __future__ import annotations
import pytest
from conftest import RocprofsysTest
pytestmark = [
# Module level markers
]
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def <test>_env() -> dict[str, str]:
# Environment for your test
# =============================================================================
# <Feature> tests
# =============================================================================
# Any class level markers here
class Test<NAME>(RocprofsysTest):
# Any test level markers here
def test_<NAME>(self, <test>_env):
result = self.run_test(
# mode,
# target,
# env =<test>_env,
# ...
)
# Subtests
self.assert_regex(result)
# ...
```