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

486 lines
17 KiB
Python

# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Persistent, cross-process cache for expensive test-setup probes.
This module stores the results of those probes in a single JSON file that all
the CTest-spawned processes share. The first process that needs a value pays for
it once and writes it; every later process reads it back.
Set ``ROCPROFSYS_DISABLE_TEST_CACHE=1`` to disable the cache entirely.
"""
from __future__ import annotations
import contextlib
import dataclasses
import fcntl
import functools
import getpass
import hashlib
import importlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, Callable, Optional
_DISABLE_ENV = "ROCPROFSYS_DISABLE_TEST_CACHE"
# Set only by disable_for_process() to turn off cache for remainder of this process
_proc_cache_disabled = False
# Debug aid, logs HIT/MISS lines for cache lookup attempts.
_DEBUG_ENV = "ROCPROFSYS_TEST_CACHE_DEBUG"
_logger = logging.getLogger("rocprofsys.syscache")
def _configure_debug_logger() -> None:
"""Enable ``[syscache] ...`` debug output when ``_DEBUG_ENV`` is truthy."""
if os.environ.get(_DEBUG_ENV, "").strip() not in ("1", "ON", "on", "true", "True"):
return
_logger.setLevel(logging.DEBUG)
if not _logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[syscache] %(message)s"))
_logger.addHandler(handler)
_logger.propagate = False
_configure_debug_logger()
def _debug_log(event: str, cache_key: str) -> None:
"""Log a ``[syscache] HIT/MISS: <key>`` line when debugging is enabled."""
_logger.debug("%s: %s", event, cache_key)
# ---------------------------------------------------------------------------
# Codec errors
# ---------------------------------------------------------------------------
class CacheError(Exception):
"""Base class for errors raised by the cache codec."""
class SerializationError(CacheError):
"""A value cannot be encoded for the cache (generally a bug)."""
class DeserializationError(CacheError):
"""A cached payload cannot be decoded (corrupt/stale/incompatible entry)."""
# ---------------------------------------------------------------------------
# Typed JSON codec
#
# JSON natively supports null, booleans, numbers, strings, lists, and dicts.
# Wrap other types as tagged objects:
# {"__type__": "<kind>", "<value>": <payload>}
#
# Dataclasses are handled automatically: any dataclass instance is stored by
# value (its ``init=True`` fields, each recursively encoded) tagged with the
# defining ``module``/``qualname``, and decode() re-imports that class to rebuild
# it.
# ---------------------------------------------------------------------------
def encode(obj: Any) -> Any:
# No lookup table: we dispatch by subclass, not exact type.
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
if isinstance(obj, Path):
return {"__type__": "path", "value": str(obj)}
if isinstance(obj, tuple):
return {"__type__": "tuple", "value": [encode(x) for x in obj]}
if isinstance(obj, set):
return {"__type__": "set", "value": [encode(x) for x in obj]}
if isinstance(obj, list):
return [encode(x) for x in obj]
if isinstance(obj, dict):
return {
"__type__": "dict",
"value": [[encode(k), encode(v)] for k, v in obj.items()],
}
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
return {
"__type__": "dataclass",
"module": type(obj).__module__,
"qualname": type(obj).__qualname__,
"value": {
f.name: encode(getattr(obj, f.name))
for f in dataclasses.fields(obj)
if f.init
},
}
raise SerializationError(f"cannot cache value of type {type(obj).__name__}")
def _resolve_dataclass(module_name: str, qualname: str) -> type:
"""Import and return the dataclass named ``module_name.qualname``.
Raises ``DeserializationError`` (which callers treat as a cache miss) if the
class can't be imported or isn't a dataclass.
"""
try:
resolved: Any = importlib.import_module(module_name)
for part in qualname.split("."):
resolved = getattr(resolved, part)
except (ImportError, AttributeError) as exc:
raise DeserializationError(
f"cannot resolve cached dataclass {module_name}.{qualname}"
) from exc
if not (isinstance(resolved, type) and dataclasses.is_dataclass(resolved)):
raise DeserializationError(f"{module_name}.{qualname} is not a dataclass")
return resolved
def _decode_dataclass(obj: dict) -> Any:
cls = _resolve_dataclass(obj.get("module"), obj.get("qualname"))
return cls(**{name: decode(v) for name, v in obj["value"].items()})
_DECODERS: dict[str, Callable[[dict], Any]] = {
"path": lambda obj: Path(obj["value"]),
"tuple": lambda obj: tuple(decode(x) for x in obj["value"]),
"set": lambda obj: set(decode(x) for x in obj["value"]),
"dict": lambda obj: {decode(k): decode(v) for k, v in obj["value"]},
"dataclass": _decode_dataclass,
}
def decode(obj: Any) -> Any:
if isinstance(obj, list):
return [decode(x) for x in obj]
if isinstance(obj, dict):
kind = obj.get("__type__")
handler = _DECODERS.get(kind)
if handler is None:
raise DeserializationError(f"unknown cache tag: {kind!r}")
return handler(obj)
return obj
# ---------------------------------------------------------------------------
# PersistentCache
# ---------------------------------------------------------------------------
class PersistentCache:
"""A JSON key/value store shared across processes for one build tree."""
def __init__(self, path: Path) -> None:
self.path = path
# Lock a separate sidecar file, not the cache file itself: _flush
# swaps the cache file's inode via os.replace and flock locks the inode,
# so locking the cache file would not serialize concurrent writers
# (lost writes in CTest parallel mode).
self.lock_path = path.with_name(f"{path.stem}.lock{path.suffix}")
self._entries: dict[str, Any] = self._read_entries_from_disk()
def get(self, key: str) -> tuple[bool, Any]:
"""Return ``(found, value)``. ``found`` is False on miss or any error.
A decode failure is fail-open (treated as a miss)
"""
if key not in self._entries:
return False, None
try:
return True, decode(self._entries[key])
except (DeserializationError, ValueError, TypeError, KeyError):
return False, None
def clear(self) -> None:
"""Drop all entries, in memory and on disk.
Used by the config setup step to force a full regeneration so stale
capability data is never carried over from a previous run.
"""
self._entries = {}
try:
with self._exclusive_lock():
self._flush({"entries": {}})
except OSError:
pass # fail-open: in-memory clear still forces recompute this process
def set(self, key: str, value: Any) -> None:
"""Persist ``value`` under ``key`` (locked read-modify-write).
A serialization failure propagates.
"""
encoded = encode(value)
self._entries[key] = encoded
try:
self._persist(key, encoded)
except OSError:
pass
@contextlib.contextmanager
def _exclusive_lock(self):
"""Hold an exclusive lock on the stable sidecar file for a whole
read-modify-write operation.
"""
# 0o700: the cache files are 0o600, so keep the per-user dir owner-only
# too (defense-in-depth on shared /tmp; no effect if it already exists).
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
lock_fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
os.close(lock_fd)
def _read_entries_from_disk(self) -> dict[str, Any]:
"""Return the on-disk entries mapping (empty on missing/corrupt/bad shape).
Does not lock; callers needing consistency hold ``_exclusive_lock``.
"""
try:
with open(self.path, "r") as f:
data = json.load(f)
except (OSError, ValueError):
return {} # missing/corrupt -> start fresh
if isinstance(data, dict) and isinstance(data.get("entries"), dict):
return data["entries"]
return {}
def _flush(self, data: dict[str, Any]) -> None:
"""Replace the cache file with ``data`` atomically (caller holds the lock).
It is placed in the target directory so ``os.replace`` stays on one
filesystem (atomic swap), and named ``<cachefile>.*.tmp`` so the suite's
cleanup still sweeps it if a crash leaves it behind.
"""
fd, tmp = tempfile.mkstemp(
dir=self.path.parent, prefix=f"{self.path.name}.", suffix=".tmp"
)
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f)
os.replace(tmp, self.path)
except BaseException:
# mkstemp makes a fresh file each call, so a leftover would accumulate;
# remove it on any failure (os.replace consumes it on success).
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def _persist(self, key: str, encoded: Any) -> None:
"""Merge ``key`` into the on-disk cache under an exclusive lock.
Re-reads current entries before writing so a concurrent writer's
entries are preserved (CTest parallel mode).
"""
with self._exclusive_lock():
entries = self._read_entries_from_disk()
entries[key] = encoded
self._flush({"entries": entries})
# ---------------------------------------------------------------------------
# Cache file location
#
# The config-setup CTest fixture clears and fully regenerates the cache at the
# start of every run, and the cleanup fixture deletes the file at the end, so
# freshness does not depend on a content signature.
# ---------------------------------------------------------------------------
def _cache_discriminator() -> str:
"""Short hash identifying the build/install tree this cache belongs to."""
root = (
os.environ.get("ROCPROFSYS_BUILD_DIR")
or os.environ.get("ROCPROFSYS_INSTALL_DIR")
# Fallback: the pytest package is copied into each build/install tree,
# so its location distinguishes builds when neither env var is set.
or str(Path(__file__).resolve().parent)
)
return hashlib.sha256(root.encode("utf-8")).hexdigest()[:16]
def resolve_username() -> str:
"""Best-effort current username for building per-user tmp paths.
Falls back to the numeric uid, then ``"0"``.
"""
try:
return getpass.getuser()
except Exception:
try:
return str(os.getuid())
except Exception:
return "0"
def _cache_file_path(discriminator: str) -> Path:
# Resolve the tmp base exactly like the suite's cleanup step
# (ROCPROFSYS_TMPDIR -> TMPDIR -> /tmp)
base = os.environ.get("ROCPROFSYS_TMPDIR", os.environ.get("TMPDIR", "/tmp"))
try:
uid = os.getuid()
except AttributeError: # pragma: no cover - non-POSIX
uid = 0
# Nest under the user's name (``/tmp/$USER`` by default)
filename = f"rocprofsys-syscache-{uid}-{discriminator}.tmp"
return Path(base) / resolve_username() / filename
def disable_for_process() -> None:
"""Disable the shared cache for the remainder of this process.
Used by the CTest *generation* pass (``--ctest-mode generate``)
"""
global _proc_cache_disabled
_proc_cache_disabled = True
get_shared_cache.cache_clear()
@functools.lru_cache(maxsize=1)
def get_shared_cache() -> Optional[PersistentCache]:
"""Return the process-wide cache, or ``None`` if disabled/unavailable.
The result is memoized so the file is read at most once per process.
"""
if _proc_cache_disabled:
return None
if os.environ.get(_DISABLE_ENV, "").strip() in ("1", "ON", "on", "true", "True"):
return None
try:
return PersistentCache(_cache_file_path(_cache_discriminator()))
except Exception:
return None
# ---------------------------------------------------------------------------
# Decorators
# ---------------------------------------------------------------------------
def _args_key(args: tuple, kwargs: dict) -> str:
"""Stable string key for positional/keyword args (Paths -> str)."""
def norm(value: Any) -> str:
if isinstance(value, Path):
return str(value)
return repr(value)
parts = [norm(a) for a in args]
parts += [f"{k}={norm(v)}" for k, v in sorted(kwargs.items())]
return "(" + ",".join(parts) + ")"
def persistent_cache(
key: str,
*,
to_cache: Optional[Callable[[Any], Any]] = None,
from_cache: Optional[Callable[[Any], Any]] = None,
method: bool = False,
) -> Callable:
"""Persistently memoize a function across processes.
The cross-process analogue of ``functools.lru_cache``: a function decorator
keyed on the call arguments. Unlike ``lru_cache`` it is unbounded (no
eviction, like ``maxsize=None``) and keyed on the explicit ``key`` namespace
plus the args rather than on the function object.
``to_cache``/``from_cache`` convert between the function's return type and a
cache-serializable form
The ``method`` flag selects how the cache key treats the first argument:
- ``method=False`` (default) -- a free (module-level) function: every
argument is part of the key.
- ``method=True`` -- drops the first argument (``self`` for instance methods)
For a no-argument property, use ``persistent_cached_property`` instead of
this decorator.
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
store = get_shared_cache()
key_args = args[1:] if method else args
cache_key = f"{key}:{_args_key(key_args, kwargs)}"
if store is not None:
try:
found, cached = store.get(cache_key)
if found:
_debug_log("HIT", cache_key)
return from_cache(cached) if from_cache else cached
except Exception:
pass # fall through to compute
_debug_log("MISS", cache_key)
result = func(*args, **kwargs)
if store is not None:
# Serialization errors propagate on purpose (see PersistentCache.set);
# write/lock errors are already handled fail-open inside set().
store.set(cache_key, to_cache(result) if to_cache else result)
return result
return wrapper
return decorator
class persistent_cached_property:
"""``functools.cached_property`` that also persists across processes.
When reading, it looks up the value in the following order:
- instance ``__dict__`` (this process)
- shared file cache (another process)
- compute (if necessary, then written to shared file)
"""
def __init__(self, func: Callable) -> None:
self.func = func
self.attrname: Optional[str] = None
self.__doc__ = func.__doc__
def __set_name__(self, owner, name: str) -> None:
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same persistent_cached_property to two names "
f"({self.attrname!r} and {name!r})."
)
def __get__(self, instance, owner=None):
if instance is None:
return self
if self.attrname is None:
raise TypeError("persistent_cached_property used without __set_name__")
try:
inst_dict = instance.__dict__
except AttributeError:
return self.func(instance)
if self.attrname in inst_dict:
return inst_dict[self.attrname]
# cap.<module>.<ClassQualName>.<attr>. Including __module__ (not just the
# qualname) prevents two same-named classes in different modules from
# colliding on one key when they share the cache file. Note the key is
# deliberately independent of instance/env state: within a single CTest
# session the config and environment are constant, and keying on them
# would defeat the cross-process sharing this cache exists for.
klass = type(instance)
cache_key = f"cap.{klass.__module__}.{klass.__qualname__}.{self.attrname}"
store = get_shared_cache()
if store is not None:
try:
found, cached = store.get(cache_key)
if found:
_debug_log("HIT", cache_key)
inst_dict[self.attrname] = cached
return cached
except Exception:
pass
_debug_log("MISS", cache_key)
value = self.func(instance)
if store is not None:
store.set(cache_key, value)
inst_dict[self.attrname] = value
return value