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
521 lines
18 KiB
Python
521 lines
18 KiB
Python
# Copyright (c) Advanced Micro Devices, Inc.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
Unit tests for GPU-specific test counter selection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from conftest import RocprofsysTest, _validate_rocpd_candidates
|
|
from rocprofsys import GPUInfo, TestResult as RocprofsysTestResult, ValidationResult
|
|
from rocprofsys import cache
|
|
|
|
pytestmark = [pytest.mark.pytest_impl]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / fixtures for the persistent-cache tests
|
|
# ---------------------------------------------------------------------------
|
|
@dataclass
|
|
class _Sample:
|
|
"""A non-JSON-native return type, used to exercise to_cache/from_cache."""
|
|
|
|
name: str
|
|
tags: set
|
|
|
|
|
|
def _sample_to_cache(sample: _Sample) -> dict:
|
|
return {"name": sample.name, "tags": sorted(sample.tags)}
|
|
|
|
|
|
def _sample_from_cache(data: dict) -> _Sample:
|
|
return _Sample(name=data["name"], tags=set(data["tags"]))
|
|
|
|
|
|
@dataclass
|
|
class _DataclassSample:
|
|
"""A plain dataclass: the codec round-trips it automatically, no converters."""
|
|
|
|
name: str
|
|
tags: set
|
|
path: Path = Path(".")
|
|
|
|
|
|
def _cache_concurrent_writer(
|
|
cache_path: str, proc_idx: int, n_keys: int, barrier
|
|
) -> None:
|
|
"""Worker: hammer a shared ``PersistentCache`` with a distinct key range.
|
|
|
|
Module-level so forked worker processes can reference it. The barrier releases
|
|
all writers together so their read-modify-write cycles overlap and actually
|
|
exercise the file lock.
|
|
"""
|
|
store = cache.PersistentCache(Path(cache_path))
|
|
barrier.wait()
|
|
for k in range(n_keys):
|
|
store.set(f"p{proc_idx}.key{k}", proc_idx * 1000 + k)
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_store(tmp_path, monkeypatch):
|
|
"""An isolated ``PersistentCache`` wired in as the process-wide shared cache.
|
|
|
|
Backed by a file under pytest's ``tmp_path`` and injected by monkeypatching
|
|
``cache.get_shared_cache``, so the decorator/property tests never touch the
|
|
real cache file that a live test session uses.
|
|
"""
|
|
store = cache.PersistentCache(tmp_path / "syscache.json")
|
|
monkeypatch.setattr(cache, "get_shared_cache", lambda: store)
|
|
return store
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.class_name("gpu-info")
|
|
class TestGPUInfo(RocprofsysTest):
|
|
def test_gfx1250_uses_gfx1250_counter_set(self):
|
|
gpu_info = GPUInfo(
|
|
available=True,
|
|
architectures=["gfx1250"],
|
|
device_count=1,
|
|
categories={"instinct"},
|
|
)
|
|
|
|
assert (
|
|
gpu_info.rocm_events_for_test
|
|
== "GRBM_COUNT,SQ_WAVES,SQ_INSTS_VALU,TX_VCA_VCA_BUSY"
|
|
)
|
|
assert gpu_info.counter_names == [
|
|
"GRBM_COUNT",
|
|
"SQ_WAVES",
|
|
"SQ_INSTS_VALU",
|
|
"TX_VCA_VCA_BUSY",
|
|
]
|
|
assert gpu_info.expected_counter_files == [
|
|
"rocprof-device-[0-9]-GRBM_COUNT*.txt",
|
|
"rocprof-device-[0-9]-SQ_WAVES*.txt",
|
|
"rocprof-device-[0-9]-SQ_INSTS_VALU*.txt",
|
|
"rocprof-device-[0-9]-TX_VCA_VCA_BUSY*.txt",
|
|
]
|
|
|
|
def test_mi300_and_later_keep_ta_ta_busy(self):
|
|
gpu_info = GPUInfo(
|
|
available=True,
|
|
architectures=["gfx942"],
|
|
device_count=1,
|
|
categories={"instinct"},
|
|
)
|
|
|
|
assert (
|
|
gpu_info.rocm_events_for_test
|
|
== "GRBM_COUNT,SQ_WAVES,SQ_INSTS_VALU,TA_TA_BUSY"
|
|
)
|
|
assert gpu_info.counter_names == [
|
|
"GRBM_COUNT",
|
|
"SQ_WAVES",
|
|
"SQ_INSTS_VALU",
|
|
"TA_TA_BUSY",
|
|
]
|
|
|
|
def test_non_mi300_non_gfx1250_keep_single_counter(self):
|
|
gpu_info = GPUInfo(
|
|
available=True,
|
|
architectures=["gfx1201"],
|
|
device_count=1,
|
|
categories={"radeon"},
|
|
)
|
|
|
|
assert gpu_info.rocm_events_for_test == "SQ_WAVES"
|
|
assert gpu_info.counter_names == ["SQ_WAVES"]
|
|
|
|
def test_unsupported_perf_counter_archs_flags_matches(self):
|
|
gpu_info = GPUInfo(
|
|
available=True,
|
|
architectures=["gfx1102", "gfx942"],
|
|
device_count=2,
|
|
categories={"radeon", "instinct"},
|
|
)
|
|
|
|
assert gpu_info.unsupported_perf_counter_archs == {"gfx1102"}
|
|
|
|
def test_unsupported_perf_counter_archs_empty_when_supported(self):
|
|
gpu_info = GPUInfo(
|
|
available=True,
|
|
architectures=["gfx942"],
|
|
device_count=1,
|
|
categories={"instinct"},
|
|
)
|
|
|
|
assert gpu_info.unsupported_perf_counter_archs == set()
|
|
|
|
|
|
@pytest.mark.class_name("test-result")
|
|
class TestTestResult(RocprofsysTest):
|
|
def test_rocpd_files_prefers_default_database(self, tmp_path):
|
|
default_db = tmp_path / "rocpd.db"
|
|
rank_db = tmp_path / "rocpd-2-0.db"
|
|
default_db.touch()
|
|
rank_db.touch()
|
|
|
|
result = RocprofsysTestResult(0, "", tmp_path, [], {})
|
|
|
|
assert result.rocpd_files == [default_db]
|
|
|
|
def test_rocpd_files_returns_sorted_rank_databases(self, tmp_path):
|
|
higher_pid_db = tmp_path / "rocpd-66607-0.db"
|
|
lower_pid_db = tmp_path / "rocpd-66606-0.db"
|
|
higher_pid_db.touch()
|
|
lower_pid_db.touch()
|
|
|
|
result = RocprofsysTestResult(0, "", tmp_path, [], {})
|
|
|
|
assert result.rocpd_files == [lower_pid_db, higher_pid_db]
|
|
|
|
def test_rocpd_candidates_accepts_later_valid_candidate(
|
|
self,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
invalid_db = tmp_path / "rocpd-66606-0.db"
|
|
valid_db = tmp_path / "rocpd-66607-0.db"
|
|
invalid_db.touch()
|
|
valid_db.touch()
|
|
calls: list[Path] = []
|
|
|
|
def validate_rocpd_database(db_path: Path) -> ValidationResult:
|
|
calls.append(db_path)
|
|
if db_path == valid_db:
|
|
return ValidationResult(
|
|
True,
|
|
"valid candidate",
|
|
stdout="rocpd validated",
|
|
command=f"validate {db_path.name}",
|
|
)
|
|
return ValidationResult(
|
|
False,
|
|
"missing GPU counter rows",
|
|
stdout="validation failed",
|
|
command=f"validate {db_path.name}",
|
|
)
|
|
|
|
passing_output, failures, global_failure = _validate_rocpd_candidates(
|
|
[invalid_db, valid_db],
|
|
validate_rocpd_database,
|
|
pass_regex=[r"rocpd validated"],
|
|
)
|
|
|
|
assert calls == [invalid_db, valid_db]
|
|
assert passing_output == f"Command: validate {valid_db.name}\n\nvalid candidate"
|
|
assert global_failure is None
|
|
assert failures == [
|
|
f"Command: validate {invalid_db.name}\n\nmissing GPU counter rows"
|
|
]
|
|
|
|
def test_rocpd_candidates_reports_all_candidate_failures(
|
|
self,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
first_db = tmp_path / "rocpd-66606-0.db"
|
|
second_db = tmp_path / "rocpd-7-0.db"
|
|
first_db.touch()
|
|
second_db.touch()
|
|
|
|
def validate_rocpd_database(db_path: Path) -> ValidationResult:
|
|
return ValidationResult(
|
|
False,
|
|
f"{db_path.name} is missing GPU counter rows",
|
|
stdout="validation failed",
|
|
command=f"validate {db_path.name}",
|
|
)
|
|
|
|
passing_output, failures, global_failure = _validate_rocpd_candidates(
|
|
[first_db, second_db],
|
|
validate_rocpd_database,
|
|
)
|
|
|
|
assert passing_output is None
|
|
assert global_failure is None
|
|
message = "\n\n--- Next ROCpd candidate ---\n\n".join(failures)
|
|
assert "rocpd-66606-0.db is missing GPU counter rows" in message
|
|
assert "rocpd-7-0.db is missing GPU counter rows" in message
|
|
assert "--- Next ROCpd candidate ---" in message
|
|
|
|
def test_rocpd_candidates_fail_regex_is_global(
|
|
self,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
invalid_db = tmp_path / "rocpd-66606-0.db"
|
|
valid_db = tmp_path / "rocpd-66607-0.db"
|
|
invalid_db.touch()
|
|
valid_db.touch()
|
|
calls: list[Path] = []
|
|
|
|
def validate_rocpd_database(db_path: Path) -> ValidationResult:
|
|
calls.append(db_path)
|
|
if db_path == invalid_db:
|
|
return ValidationResult(
|
|
False,
|
|
"invalid candidate",
|
|
stdout="validation failed with FORBIDDEN marker",
|
|
command=f"validate {db_path.name}",
|
|
)
|
|
return ValidationResult(
|
|
True,
|
|
"valid candidate",
|
|
stdout="rocpd validated",
|
|
command=f"validate {db_path.name}",
|
|
)
|
|
|
|
passing_output, failures, global_failure = _validate_rocpd_candidates(
|
|
[invalid_db, valid_db],
|
|
validate_rocpd_database,
|
|
fail_regex=[r"FORBIDDEN"],
|
|
)
|
|
|
|
assert calls == [invalid_db]
|
|
assert passing_output is None
|
|
assert failures == []
|
|
assert global_failure is not None
|
|
assert "Fail regex found: FORBIDDEN" in global_failure
|
|
assert f"validate {invalid_db.name}" in global_failure
|
|
|
|
|
|
@pytest.mark.class_name("cache")
|
|
class TestCache(RocprofsysTest):
|
|
"""Unit tests for the persistent cross-process cache (``rocprofsys.cache``).
|
|
|
|
One test per component to keep the count small; each walks that component's
|
|
behaviours in a single flow. The decorator/property tests use the
|
|
``cache_store`` fixture so they never touch the real shared cache file.
|
|
"""
|
|
|
|
def test_codec_round_trips_supported_types_and_rejects_others(self) -> None:
|
|
"""The tagged JSON codec preserves every supported type and rejects the rest."""
|
|
payload = {
|
|
"none": None,
|
|
"bools": [True, False],
|
|
"ints": [0, 42, -7],
|
|
"float": 3.14,
|
|
"strings": ["", "hello"],
|
|
"path": Path("/opt/rocm"),
|
|
"tuples": [(), (7, 1, 1)],
|
|
"sets": [set(), {"instinct", "apu"}],
|
|
"mixed_list": [1, "a", Path("/x")],
|
|
1: "non-string keys survive the tagged encoding",
|
|
"nested": {"paths": [Path("/a")], "categories": {"instinct"}},
|
|
}
|
|
assert cache.decode(cache.encode(payload)) == payload
|
|
|
|
with pytest.raises(cache.SerializationError): # unsupported type
|
|
cache.encode(object())
|
|
with pytest.raises(cache.DeserializationError): # unknown tag on decode
|
|
cache.decode({"__type__": "bogus", "value": 1})
|
|
|
|
def test_persistent_cache_store(self, tmp_path) -> None:
|
|
"""get/set/miss, cross-instance persistence, clear, typed values, resilience."""
|
|
path = tmp_path / "c.json"
|
|
store = cache.PersistentCache(path)
|
|
|
|
assert store.get("missing") == (False, None) # miss
|
|
store.set("k", {"a": 1})
|
|
store.set("path", Path("/opt/rocm")) # non-JSON-native types
|
|
store.set("tuple", (7, 1, 1))
|
|
store.set("set", {"instinct", "apu"})
|
|
# An unserializable value is a programming error: set() raises instead of
|
|
# silently skipping, and nothing is stored under the key.
|
|
with pytest.raises(cache.SerializationError):
|
|
store.set("bad", object())
|
|
assert store.get("bad") == (False, None)
|
|
|
|
# A second instance (i.e. another process) reads what the first wrote.
|
|
reader = cache.PersistentCache(path)
|
|
assert reader.get("k") == (True, {"a": 1})
|
|
assert reader.get("path") == (True, Path("/opt/rocm"))
|
|
assert reader.get("tuple") == (True, (7, 1, 1))
|
|
assert reader.get("set") == (True, {"instinct", "apu"})
|
|
|
|
store.clear() # wipes memory + disk
|
|
assert cache.PersistentCache(path).get("k") == (False, None)
|
|
|
|
# A corrupt file loads as empty but stays usable.
|
|
path.write_text("{ not valid json")
|
|
corrupt = cache.PersistentCache(path)
|
|
assert corrupt.get("k") == (False, None)
|
|
corrupt.set("k", 1)
|
|
assert corrupt.get("k") == (True, 1)
|
|
|
|
def test_persistent_cache_decorator(self, cache_store, monkeypatch) -> None:
|
|
"""@persistent_cache: compute-once, arg keying, converters, method=True, unserializable-raises, fail-open."""
|
|
fn_calls: list[int] = []
|
|
|
|
@cache.persistent_cache("test.fn")
|
|
def fn(x: int) -> int:
|
|
fn_calls.append(x)
|
|
return x * 2
|
|
|
|
assert fn(21) == 42 # miss -> compute + store
|
|
assert fn(21) == 42 # hit -> served from cache
|
|
fn(3) # distinct arg -> recompute (keyed on args)
|
|
assert fn_calls == [21, 3]
|
|
|
|
# to_cache/from_cache round-trip a non-JSON-native return type.
|
|
make_calls: list[str] = []
|
|
|
|
@cache.persistent_cache(
|
|
"test.sample", to_cache=_sample_to_cache, from_cache=_sample_from_cache
|
|
)
|
|
def make(name: str) -> _Sample:
|
|
make_calls.append(name)
|
|
return _Sample(name=name, tags={"a", "b"})
|
|
|
|
first, second = make("v"), make("v") # second is rebuilt via from_cache
|
|
assert make_calls == ["v"]
|
|
assert isinstance(second, _Sample) and second == first
|
|
|
|
# method=True drops self from the key, so distinct instances share.
|
|
meth_calls: list[tuple[str, str]] = []
|
|
|
|
class Probe:
|
|
def __init__(self, tag: str) -> None:
|
|
self.tag = tag
|
|
|
|
@cache.persistent_cache("test.meth", method=True)
|
|
def check(self, target: str) -> str:
|
|
meth_calls.append((self.tag, target))
|
|
return f"result:{target}"
|
|
|
|
assert Probe("A").check("x") == "result:x" # computed by A
|
|
assert Probe("B").check("x") == "result:x" # different self -> still a hit
|
|
assert meth_calls == [("A", "x")]
|
|
|
|
# A non-serializable *result* is a bug: it propagates out of the wrapped
|
|
# call (loud) rather than being silently left uncached.
|
|
@cache.persistent_cache("test.unserializable")
|
|
def bad() -> object:
|
|
return object()
|
|
|
|
with pytest.raises(cache.SerializationError):
|
|
bad()
|
|
|
|
# Fail-open: with no shared cache the wrapper still computes, never raises.
|
|
monkeypatch.setattr(cache, "get_shared_cache", lambda: None)
|
|
disabled_calls: list[int] = []
|
|
|
|
@cache.persistent_cache("test.disabled")
|
|
def disabled(x: int) -> int:
|
|
disabled_calls.append(x)
|
|
return x + 1
|
|
|
|
assert disabled(1) == 2
|
|
assert disabled(1) == 2
|
|
assert disabled_calls == [1, 1] # recomputed each time, no store
|
|
|
|
def test_persistent_cached_property(self, cache_store) -> None:
|
|
"""Cross-instance sharing, per-class key namespacing, and loud on unserializable."""
|
|
calls: list[int] = []
|
|
|
|
class Caps:
|
|
def __init__(self, n: int) -> None:
|
|
self._n = n
|
|
|
|
@cache.persistent_cached_property
|
|
def value(self) -> int:
|
|
calls.append(self._n)
|
|
return 100
|
|
|
|
assert Caps(1).value == 100 # miss -> compute + store
|
|
assert Caps(2).value == 100 # fresh instance -> served from shared file
|
|
assert calls == [1]
|
|
|
|
# Same attribute name on a different class must not collide in the store.
|
|
class Other:
|
|
@cache.persistent_cached_property
|
|
def value(self) -> str:
|
|
return "other"
|
|
|
|
assert Other().value == "other"
|
|
|
|
# A property whose computed value can't be serialized raises on access
|
|
# (loud), instead of silently caching nothing.
|
|
class BadProp:
|
|
@cache.persistent_cached_property
|
|
def value(self) -> object:
|
|
return object()
|
|
|
|
with pytest.raises(cache.SerializationError):
|
|
BadProp().value
|
|
|
|
def test_dataclass_round_trips(self, cache_store) -> None:
|
|
"""Dataclasses (de)serialize automatically, with no to_cache/from_cache.
|
|
|
|
Covers a direct codec round-trip (nested set + Path fields), reuse through
|
|
@persistent_cache, and rejection of a field of an unsupported type.
|
|
"""
|
|
sample = _DataclassSample(
|
|
name="mi300", tags={"instinct", "apu"}, path=Path("/opt/rocm")
|
|
)
|
|
assert cache.decode(cache.encode(sample)) == sample # direct round-trip
|
|
|
|
calls: list[str] = []
|
|
|
|
@cache.persistent_cache("test.dataclass")
|
|
def make(name: str) -> _DataclassSample:
|
|
calls.append(name)
|
|
return _DataclassSample(name=name, tags={"x"})
|
|
|
|
first, second = make("a"), make("a") # second served from cache
|
|
assert calls == ["a"]
|
|
assert isinstance(second, _DataclassSample) and second == first
|
|
|
|
# A dataclass carrying a field of an unsupported type cannot be encoded.
|
|
@dataclass
|
|
class _BadField:
|
|
x: object
|
|
|
|
with pytest.raises(cache.SerializationError):
|
|
cache.encode(_BadField(object()))
|
|
|
|
def test_concurrent_writers_preserve_all_entries(self, tmp_path) -> None:
|
|
"""Parallel writers to distinct keys must not clobber each other.
|
|
|
|
Regression guard for the file-lock design: the exclusive lock is held on a
|
|
stable sidecar file, so overlapping writers each preserve the others'
|
|
entries across the atomic tmp+replace. Locking the data file itself would
|
|
drop most writes, since ``os.replace`` swaps the locked inode.
|
|
"""
|
|
import multiprocessing as mp
|
|
|
|
cache_path = tmp_path / "syscache.tmp"
|
|
cache.PersistentCache(cache_path).set("seed", 1) # create the file first
|
|
|
|
n_proc, n_keys = 6, 40
|
|
ctx = mp.get_context("fork") # the test suite is Linux-only
|
|
barrier = ctx.Barrier(n_proc)
|
|
procs = [
|
|
ctx.Process(
|
|
target=_cache_concurrent_writer,
|
|
args=(str(cache_path), i, n_keys, barrier),
|
|
)
|
|
for i in range(n_proc)
|
|
]
|
|
for p in procs:
|
|
p.start()
|
|
for p in procs:
|
|
p.join(timeout=60)
|
|
assert p.exitcode == 0
|
|
|
|
final = cache.PersistentCache(cache_path)
|
|
lost = [
|
|
f"p{i}.key{k}"
|
|
for i in range(n_proc)
|
|
for k in range(n_keys)
|
|
if not final.get(f"p{i}.key{k}")[0]
|
|
]
|
|
assert not lost, f"{len(lost)} of {n_proc * n_keys} concurrent writes were lost"
|