This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

278
ptdiag/performance.py Normal file
View File

@@ -0,0 +1,278 @@
"""Low-overhead process and device sampling for performance experiments."""
from __future__ import annotations
import csv
from dataclasses import dataclass
import os
from pathlib import Path
import re
import time
from typing import Any, TextIO
_SAMPLE_FIELDS = (
"epoch",
"elapsed_seconds",
"process_cpu_percent",
"system_cpu_percent",
"rss_mib",
"read_mib_per_second",
"write_mib_per_second",
"voluntary_context_switches_per_second",
"nonvoluntary_context_switches_per_second",
"gpu_busy_percent",
"vram_busy_percent",
"gpu_sclk_mhz",
"gpu_mclk_mhz",
"cpu_average_mhz",
"cpu_minimum_mhz",
"cpu_maximum_mhz",
)
@dataclass(frozen=True)
class _Counters:
process_ticks: int
rss_bytes: int
read_bytes: int
write_bytes: int
voluntary_context_switches: int
nonvoluntary_context_switches: int
system_total_ticks: int
system_idle_ticks: int
class PerformanceSampler:
"""Sample one game process and the active AMD GPU at a fixed interval."""
def __init__(
self,
pid: int,
output: Path,
*,
interval_seconds: float = 0.5,
) -> None:
if pid <= 0:
raise ValueError("pid must be positive")
if interval_seconds <= 0:
raise ValueError("interval_seconds must be positive")
self.pid = pid
self.output = output
self.interval_seconds = interval_seconds
self._interval_ns = int(interval_seconds * 1_000_000_000)
self._started_ns = time.time_ns()
self._last_sample_ns: int | None = None
self._previous: _Counters | None = None
self._handle: TextIO = output.open("w", encoding="utf-8", newline="")
self._writer = csv.DictWriter(self._handle, fieldnames=_SAMPLE_FIELDS)
self._writer.writeheader()
self._gpu_device = _discover_gpu_device()
self._sample_count = 0
self._peaks: dict[str, float] = {}
self._first_counters: _Counters | None = None
self._last_counters: _Counters | None = None
def maybe_sample(self, now_ns: int | None = None) -> bool:
now_ns = now_ns if now_ns is not None else time.time_ns()
previous_sample_ns = self._last_sample_ns
if (
previous_sample_ns is not None
and now_ns - previous_sample_ns < self._interval_ns
):
return False
self._last_sample_ns = now_ns
try:
counters = _read_counters(self.pid)
except (OSError, ValueError):
return False
if self._first_counters is None:
self._first_counters = counters
self._last_counters = counters
row = self._sample_row(now_ns, previous_sample_ns, counters)
self._writer.writerow(row)
self._handle.flush()
self._sample_count += 1
for key, value in row.items():
if key in {"epoch", "elapsed_seconds"} or not isinstance(value, (int, float)):
continue
self._peaks[key] = max(float(value), self._peaks.get(key, float("-inf")))
self._previous = counters
return True
def close(self) -> dict[str, Any]:
if not self._handle.closed:
self._handle.close()
first = self._first_counters
last = self._last_counters
return {
"pid": self.pid,
"output": str(self.output),
"interval_seconds": self.interval_seconds,
"sample_count": self._sample_count,
"gpu_device": str(self._gpu_device) if self._gpu_device else None,
"peaks": dict(sorted(self._peaks.items())),
"process_io_bytes": {
"read": max(0, last.read_bytes - first.read_bytes) if first and last else None,
"write": max(0, last.write_bytes - first.write_bytes) if first and last else None,
},
}
def _sample_row(
self,
now_ns: int,
previous_sample_ns: int | None,
counters: _Counters,
) -> dict[str, float | str | None]:
elapsed_seconds = (now_ns - self._started_ns) / 1_000_000_000
row: dict[str, float | str | None] = {
"epoch": now_ns / 1_000_000_000,
"elapsed_seconds": elapsed_seconds,
"process_cpu_percent": None,
"system_cpu_percent": None,
"rss_mib": counters.rss_bytes / 1024**2,
"read_mib_per_second": None,
"write_mib_per_second": None,
"voluntary_context_switches_per_second": None,
"nonvoluntary_context_switches_per_second": None,
"gpu_busy_percent": _read_float(self._gpu_device, "gpu_busy_percent"),
"vram_busy_percent": _read_float(self._gpu_device, "mem_busy_percent"),
"gpu_sclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_sclk"),
"gpu_mclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_mclk"),
"cpu_average_mhz": None,
"cpu_minimum_mhz": None,
"cpu_maximum_mhz": None,
}
frequencies = _read_cpu_frequencies_mhz()
if frequencies:
row["cpu_average_mhz"] = sum(frequencies) / len(frequencies)
row["cpu_minimum_mhz"] = min(frequencies)
row["cpu_maximum_mhz"] = max(frequencies)
previous = self._previous
if previous is None or previous_sample_ns is None:
return row
delta_seconds = max((now_ns - previous_sample_ns) / 1_000_000_000, 0.001)
clock_ticks = os.sysconf("SC_CLK_TCK")
row["process_cpu_percent"] = (
max(0, counters.process_ticks - previous.process_ticks)
/ clock_ticks
/ delta_seconds
* 100
)
system_total = counters.system_total_ticks - previous.system_total_ticks
system_idle = counters.system_idle_ticks - previous.system_idle_ticks
if system_total > 0:
row["system_cpu_percent"] = max(0, system_total - system_idle) / system_total * 100
row["read_mib_per_second"] = (
max(0, counters.read_bytes - previous.read_bytes) / 1024**2 / delta_seconds
)
row["write_mib_per_second"] = (
max(0, counters.write_bytes - previous.write_bytes) / 1024**2 / delta_seconds
)
row["voluntary_context_switches_per_second"] = (
max(
0,
counters.voluntary_context_switches - previous.voluntary_context_switches,
)
/ delta_seconds
)
row["nonvoluntary_context_switches_per_second"] = (
max(
0,
counters.nonvoluntary_context_switches
- previous.nonvoluntary_context_switches,
)
/ delta_seconds
)
return row
def _read_counters(pid: int) -> _Counters:
root = Path("/proc") / str(pid)
process_ticks, rss_bytes = _parse_proc_stat((root / "stat").read_text(encoding="utf-8"))
io = _parse_key_values((root / "io").read_text(encoding="utf-8"))
status = _parse_key_values((root / "status").read_text(encoding="utf-8"))
system_total, system_idle = _parse_system_cpu(Path("/proc/stat").read_text(encoding="utf-8"))
return _Counters(
process_ticks=process_ticks,
rss_bytes=rss_bytes,
read_bytes=io.get("read_bytes", 0),
write_bytes=io.get("write_bytes", 0),
voluntary_context_switches=status.get("voluntary_ctxt_switches", 0),
nonvoluntary_context_switches=status.get("nonvoluntary_ctxt_switches", 0),
system_total_ticks=system_total,
system_idle_ticks=system_idle,
)
def _parse_proc_stat(value: str) -> tuple[int, int]:
closing = value.rfind(")")
if closing < 0:
raise ValueError("Malformed /proc process stat")
fields = value[closing + 2 :].split()
if len(fields) < 22:
raise ValueError("Truncated /proc process stat")
process_ticks = int(fields[11]) + int(fields[12])
rss_bytes = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
return process_ticks, rss_bytes
def _parse_key_values(value: str) -> dict[str, int]:
result: dict[str, int] = {}
for line in value.splitlines():
key, separator, raw = line.partition(":")
if not separator:
continue
match = re.match(r"\s*([0-9]+)", raw)
if match:
result[key] = int(match.group(1))
return result
def _parse_system_cpu(value: str) -> tuple[int, int]:
first = value.splitlines()[0].split()
if not first or first[0] != "cpu" or len(first) < 5:
raise ValueError("Malformed /proc/stat CPU line")
values = [int(item) for item in first[1:]]
return sum(values), values[3] + (values[4] if len(values) > 4 else 0)
def _discover_gpu_device() -> Path | None:
for path in sorted(Path("/sys/class/drm").glob("card*/device")):
if (path / "gpu_busy_percent").is_file():
return path
return None
def _read_float(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
return float((root / name).read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def _read_active_mhz(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
value = (root / name).read_text(encoding="utf-8")
except OSError:
return None
return _parse_active_mhz(value)
def _parse_active_mhz(value: str) -> float | None:
match = re.search(r"([0-9]+)\s*Mhz\s*\*", value, flags=re.IGNORECASE)
return float(match.group(1)) if match else None
def _read_cpu_frequencies_mhz() -> list[float]:
result: list[float] = []
for path in sorted(Path("/sys/devices/system/cpu").glob("cpu[0-9]*/cpufreq/scaling_cur_freq")):
try:
result.append(float(path.read_text(encoding="utf-8").strip()) / 1000)
except (OSError, ValueError):
continue
return result