Init
This commit is contained in:
278
ptdiag/performance.py
Normal file
278
ptdiag/performance.py
Normal 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
|
||||
Reference in New Issue
Block a user