perf: add scoped CPU budget launcher and calibration evidence

This commit is contained in:
Matthew Stanley
2026-09-04 23:18:53 -07:00
parent beb61d90b1
commit f9fc955ff3
3 changed files with 1255 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# Performance experiment ledger
Campaign: [OpenGL performance campaign](../PERFORMANCE_CAMPAIGN.md).
Live work status: central Beads `beads-jod2`.
Record unsuccessful, incomplete and rejected experiments as well as accepted
ones. Local game-derived artifacts are not release fixtures. Hashes below make
the retained local evidence identifiable without distributing a savestate/ROM.
## CRAB-DISCOVERY-001: verify a usable starting scene
Date: 2026-09-04 (session date). Verdict: exploratory evidence only; no candidate
optimization, A/B comparison, production claim or correctness qualification.
- Host: Windows, Ryzen 7 9800X3D, 8 cores / 16 logical processors. Graphics
adapters reported by the OS: RTX 3080 Ti, AMD integrated graphics, Meta virtual
monitor. Actual GL device and power state were not recorded in this capture.
- Historical binary source: `_wt-ape-crabperf-20260901/build-dbg`, CMake Release
with `PSX_DEBUG_TOOLS=ON`. Existing report identifies build
`v0.3.2-alpha-139-g1bf70960-dirty`. Current source HEAD is not proof of binary
provenance. This executable was not built from the campaign branch.
- Executable SHA256:
`3b27bda4417522eb1ab331e4d2b194828f1d38fb9bbb165d1a78eb2156142ba1`.
- State: `saves-dbg/scph1001/state_800A3660_slot01.pst`, version 5, BIOS wordsum
`F67ECB99`, entry `800A3660`, codegen hash `A4319B6F`, ABI 21, codegen version 10.
SHA256 `2dfc142e65eb32e179b67d352f510d15f6d97efd22b3b43a9eef24301f3ad5a9`.
- Executable, saves, assets, BIOS assets and mods were copied into the campaign
worktree's `.local/crabby-baseline`. Writable cards/settings use that copy.
CLI selected OpenGL, no launcher, debug port 4680. Settings selected 2x SSAA
and antialiasing. `PSX_RUNTIME_PERF_DIAG=1`; no host CPU cap was applied.
- Restore receipt: `savestate_status` generation advanced from 0 to 1 with
`pending=0,last_ok=1,last_op=load,last_slot=1`. Presented screenshot visibly
confirms the sandcastle area on the beach facing the water.
- Original `ApeEscapeRecomp/saves/scph1001` slots 00/01 were v3 and slots
06/07/08/09/11 were v2; they cannot be used as v5 checkpoints.
The passive 10-second sample is retained locally at
`.local/crabby-baseline/stall-10s.json`, SHA256
`448d6a77b08479e0cca9bf11ffa21af1b3c2408ba70b9a3f1a0a7696f4a10b28`.
Snapshot start timestamps differ by 10.038749 seconds; commands are sequential,
so these are not atomic snapshots of a fixed amount of guest work.
| Observation | Value | Interpretation limit |
| --- | --- | --- |
| Overlay native dispatch delta | 0 | Does not include statically compiled game execution |
| Overlay interpreter fallback delta | 33,702 | Dispatch events, not CPU-time share |
| Interpreted instruction delta | 425,410 | Some kernel fallback is expected |
| Interpreter block delta | 33,714 | No claim that reducing calls changes the primary bottleneck |
| Compilation runs/configured | 0 / 0 | Cache/compilation provenance differs from a prepared release campaign |
| Rolling phase samples: interpreter / static / GPU | 1.59% / 80.11% / 18.30% | Instrumented phase stamps, not an independent host CPU profiler |
This observation rejects the inference that zero overlay-native dispatches
alone prove interpretation dominates the slowdown. It does not reject overlay
optimization as a candidate. No controlled compiler/process/power-state audit
was performed during this exploratory sample.
Separately queried `frame_perf` reported approximately 16.68 ms total and
16.65 ms `emu_cpu_ms_avg`. The latter is calculated from total wall time minus
present wall time, not OS thread CPU time. GPU queries span command-stream
intervals; do not interpret these fields as independent exclusive CPU/GPU busy
time or add their values together. Run without timer-query profiling and obtain
host-thread CPU samples before choosing an optimization from these values.
## HOST-BUDGET-001: verify the throttle denominator
Date: 2026-09-04 (session date). Verdict: launcher calibration, not an accepted
runtime optimization or a substitute for low-end hardware.
On the same 16-logical-CPU host, a single Python process busy-looped until
`time.process_time()` advanced by 2.5 seconds. Every run used affinity mask
`0x1`. The game was stopped. The sequence below was a calibration sequence,
not an interleaved optimization campaign. Reports live in
`.local/host-calibration/{unrestricted,core50,core25}.json`.
| Requested budget | Applied Job CpuRate (out of 10,000) | Wall seconds | Process CPU seconds |
| --- | --- | --- | --- |
| Unrestricted | none | 2.580994 | 2.500000 |
| 50% of one logical CPU | 313 | 4.679147 | 2.531250 |
| 25% of one logical CPU | 157 | 9.515031 | 2.500000 |
`--cpu-percent` is a percentage of the whole host's CPU scheduling budget.
`--core-percent` normalizes to one logical CPU equivalent independently of
affinity; it limits the whole launched job, including helper threads/processes.
This does not selectively slow the emulation thread, lower CPU frequency, or
simulate an older GPU/cache/memory subsystem. Reports retain requested and
applied rates, including integer rounding.
An earlier 0.5-CPU-second witness did not demonstrate effective throttling even
at a 3% host budget. Longer witnesses did, so sub-second smoke timing is not a
valid cap calibration. Repeat calibration on each host and use sustained game
windows rather than assuming the scheduler's short-term behavior is linear.
+867
View File
@@ -0,0 +1,867 @@
#!/usr/bin/env python3
"""Launch a benchmark child under a process-scoped host CPU budget.
Windows uses a Job Object hard CPU-rate cap. The cap is applied before the child
thread is resumed, so the target does not do meaningful work outside the budget.
Other platforms may run unrestricted, but CPU throttling fails explicitly.
"""
from __future__ import annotations
import argparse
import ctypes
import ctypes.wintypes
import json
import math
import os
import platform
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
class PerfHostError(RuntimeError):
pass
class UnsupportedThrottle(PerfHostError):
pass
@dataclass
class LaunchResult:
pid: int
exit_code: int | None
timed_out: bool
wall_seconds: float
cpu_user_seconds: float | None
cpu_kernel_seconds: float | None
cpu_total_seconds: float | None
def parse_affinity(text: str | None) -> int | None:
if text is None or text == "":
return None
text = text.strip()
if not text:
return None
if "," in text:
mask = 0
for part in text.split(","):
part = part.strip()
if not part:
raise ValueError("empty CPU index in affinity list")
idx = int(part, 10)
if idx < 0:
raise ValueError("negative CPU index in affinity list")
mask |= 1 << idx
if mask == 0:
raise ValueError("affinity mask may not be zero")
return mask
mask = int(text, 0)
if mask <= 0:
raise ValueError("affinity mask may not be zero")
return mask
def validate_cpu_percent(value: int | None) -> int | None:
if value is None:
return None
if value < 1 or value > 100:
raise ValueError("--cpu-percent must be in 1..100")
return value
def validate_core_percent(value: float | None) -> float | None:
if value is None:
return None
if not math.isfinite(value) or value <= 0.0 or value > 100.0:
raise ValueError("--core-percent must be finite and in (0, 100]")
return value
def topology_report() -> dict[str, Any]:
affinity = None
if hasattr(os, "sched_getaffinity"):
try:
affinity = sorted(os.sched_getaffinity(0)) # type: ignore[attr-defined]
except OSError:
affinity = None
return {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
"logical_cpu_count": os.cpu_count(),
"process_affinity": affinity,
}
def _filetime_seconds(ft: Any) -> float:
ticks = (int(ft.dwHighDateTime) << 32) | int(ft.dwLowDateTime)
return ticks / 10_000_000.0
if os.name == "nt":
wintypes = ctypes.wintypes
CREATE_SUSPENDED = 0x00000004
CREATE_NO_WINDOW = 0x08000000
STARTF_USESHOWWINDOW = 0x00000001
STARTF_USESTDHANDLES = 0x00000100
SW_HIDE = 0
INFINITE = 0xFFFFFFFF
TERMINATE_WAIT_MS = 5000
WAIT_OBJECT_0 = 0
WAIT_TIMEOUT = 0x00000102
JobObjectExtendedLimitInformation = 9
JobObjectCpuRateControlInformation = 15
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x1
JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x4
class STARTUPINFOW(ctypes.Structure):
_fields_ = [
("cb", wintypes.DWORD),
("lpReserved", wintypes.LPWSTR),
("lpDesktop", wintypes.LPWSTR),
("lpTitle", wintypes.LPWSTR),
("dwX", wintypes.DWORD),
("dwY", wintypes.DWORD),
("dwXSize", wintypes.DWORD),
("dwYSize", wintypes.DWORD),
("dwXCountChars", wintypes.DWORD),
("dwYCountChars", wintypes.DWORD),
("dwFillAttribute", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("wShowWindow", wintypes.WORD),
("cbReserved2", wintypes.WORD),
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
("hStdInput", wintypes.HANDLE),
("hStdOutput", wintypes.HANDLE),
("hStdError", wintypes.HANDLE),
]
class PROCESS_INFORMATION(ctypes.Structure):
_fields_ = [
("hProcess", wintypes.HANDLE),
("hThread", wintypes.HANDLE),
("dwProcessId", wintypes.DWORD),
("dwThreadId", wintypes.DWORD),
]
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class IO_COUNTERS(ctypes.Structure):
_fields_ = [
("ReadOperationCount", ctypes.c_ulonglong),
("WriteOperationCount", ctypes.c_ulonglong),
("OtherOperationCount", ctypes.c_ulonglong),
("ReadTransferCount", ctypes.c_ulonglong),
("WriteTransferCount", ctypes.c_ulonglong),
("OtherTransferCount", ctypes.c_ulonglong),
]
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
("IoInfo", IO_COUNTERS),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
class JOBOBJECT_CPU_RATE_CONTROL_INFORMATION(ctypes.Structure):
_fields_ = [("ControlFlags", wintypes.DWORD), ("CpuRate", wintypes.DWORD)]
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateProcessW.argtypes = [
wintypes.LPCWSTR,
wintypes.LPWSTR,
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.BOOL,
wintypes.DWORD,
wintypes.LPVOID,
wintypes.LPCWSTR,
ctypes.POINTER(STARTUPINFOW),
ctypes.POINTER(PROCESS_INFORMATION),
]
kernel32.CreateProcessW.restype = wintypes.BOOL
kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
kernel32.SetInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
]
kernel32.SetInformationJobObject.restype = wintypes.BOOL
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
kernel32.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
kernel32.SetProcessAffinityMask.restype = wintypes.BOOL
kernel32.GetProcessAffinityMask.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_size_t),
]
kernel32.GetProcessAffinityMask.restype = wintypes.BOOL
kernel32.GetCurrentProcess.argtypes = []
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.IsProcessInJob.argtypes = [
wintypes.HANDLE,
wintypes.HANDLE,
ctypes.POINTER(wintypes.BOOL),
]
kernel32.IsProcessInJob.restype = wintypes.BOOL
kernel32.QueryInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
kernel32.ResumeThread.restype = wintypes.DWORD
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.GetProcessTimes.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
]
kernel32.GetProcessTimes.restype = wintypes.BOOL
kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
kernel32.TerminateJobObject.restype = wintypes.BOOL
kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
kernel32.TerminateProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
def _winerr(prefix: str) -> PerfHostError:
return PerfHostError(f"{prefix}: WinError {ctypes.get_last_error()}")
def validate_timeout(value: float | None) -> float | None:
if value is None:
return None
if not math.isfinite(value) or value < 0.0:
raise ValueError("--timeout must be a finite non-negative number")
if value * 1000.0 > 0xFFFFFFFE:
raise ValueError("--timeout is too large for the Windows wait API")
return value
def affinity_scaled_cpu_rate_units(
cpu_percent: float | None,
affinity_mask: int | None,
logical_cpu_count: int | None,
) -> int | None:
if cpu_percent is None or not logical_cpu_count:
return None
affinity_cpus = affinity_mask.bit_count() if affinity_mask is not None else logical_cpu_count
if affinity_cpus <= 0:
return None
units = math.ceil(cpu_percent * affinity_cpus * 100 / logical_cpu_count)
return min(10000, max(1, units))
def one_core_cpu_rate_units(core_percent: float | None, logical_cpu_count: int | None) -> int | None:
if core_percent is None or not logical_cpu_count:
return None
units = math.ceil(core_percent * 100 / logical_cpu_count)
return min(10000, max(1, units))
def host_cpu_rate_units(cpu_percent: int | None) -> int | None:
if cpu_percent is None:
return None
return min(10000, max(1, cpu_percent * 100))
def _path_text(path: Path | None) -> str | None:
return str(path) if path is not None else None
def _set_job_kill_on_close(job: int) -> None:
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
ok = kernel32.SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
raise _winerr("SetInformationJobObject(KILL_ON_JOB_CLOSE) failed")
def _set_job_cpu_rate(job: int, cpu_rate: int) -> int:
info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION()
info.ControlFlags = JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
info.CpuRate = cpu_rate
ok = kernel32.SetInformationJobObject(
job,
JobObjectCpuRateControlInformation,
ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
raise _winerr("SetInformationJobObject(CPU_RATE_CONTROL) failed")
return cpu_rate
def _resolve_application(argv: Sequence[str]) -> str | None:
if not argv:
raise ValueError("missing executable after --")
exe = argv[0]
if any(ch in exe for ch in "\\/:"):
return exe
found = shutil.which(exe)
return found
def _query_windows_affinity(process: int) -> dict[str, int] | None:
if not hasattr(kernel32, "GetProcessAffinityMask"):
return None
proc_mask = ctypes.c_size_t()
sys_mask = ctypes.c_size_t()
ok = kernel32.GetProcessAffinityMask(
process, ctypes.byref(proc_mask), ctypes.byref(sys_mask)
)
if not ok:
return None
return {"process_mask": int(proc_mask.value), "system_mask": int(sys_mask.value)}
def _validate_windows_affinity_mask(affinity_mask: int | None) -> None:
if affinity_mask is None:
return
size_t_bits = ctypes.sizeof(ctypes.c_size_t) * 8
if affinity_mask.bit_length() > size_t_bits:
raise ValueError(
f"affinity mask 0x{affinity_mask:x} exceeds {size_t_bits}-bit size_t"
)
current_process = kernel32.GetCurrentProcess() if hasattr(kernel32, "GetCurrentProcess") else None
current_affinity = (
_query_windows_affinity(current_process) if current_process is not None else None
)
system_mask = current_affinity["system_mask"] if current_affinity else None
if system_mask is not None and affinity_mask & ~system_mask:
raise ValueError(
f"affinity mask 0x{affinity_mask:x} is outside system mask 0x{system_mask:x}"
)
def _is_process_in_job(process: int, job: int | None = None) -> bool | None:
if not hasattr(kernel32, "IsProcessInJob"):
return None
result = wintypes.BOOL()
ok = kernel32.IsProcessInJob(process, job, ctypes.byref(result))
if not ok:
return None
return bool(result.value)
def _query_job_cpu_rate(job: int) -> dict[str, int] | None:
if not hasattr(kernel32, "QueryInformationJobObject"):
return None
info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION()
returned = wintypes.DWORD()
ok = kernel32.QueryInformationJobObject(
job,
JobObjectCpuRateControlInformation,
ctypes.byref(info),
ctypes.sizeof(info),
ctypes.byref(returned),
)
if not ok:
return None
return {"control_flags": int(info.ControlFlags), "cpu_rate_units": int(info.CpuRate)}
def _open_windows_stdio(
stdout_log: Path | None,
stderr_log: Path | None,
) -> tuple[list[Any], dict[str, Any]]:
files: list[Any] = []
info = {
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
"stdio_mode": "log-files" if stdout_log or stderr_log else "detached",
}
if not stdout_log and not stderr_log:
return files, info
import msvcrt
stdin = open(os.devnull, "rb")
files.append(stdin)
os.set_handle_inheritable(msvcrt.get_osfhandle(stdin.fileno()), True)
if stdout_log:
stdout_log.parent.mkdir(parents=True, exist_ok=True)
stdout = open(stdout_log, "wb")
files.append(stdout)
else:
stdout = open(os.devnull, "wb")
files.append(stdout)
os.set_handle_inheritable(msvcrt.get_osfhandle(stdout.fileno()), True)
if stderr_log:
stderr_log.parent.mkdir(parents=True, exist_ok=True)
mode = "ab" if stdout_log and stderr_log == stdout_log else "wb"
stderr = open(stderr_log, mode)
files.append(stderr)
else:
stderr = open(os.devnull, "wb")
files.append(stderr)
os.set_handle_inheritable(msvcrt.get_osfhandle(stderr.fileno()), True)
info["stdin_handle"] = "NUL"
return files, info
def launch_windows(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
cpu_rate_units: int | None = None,
cpu_rate_mode: str = "host_percent",
core_percent: float | None = None,
) -> tuple[LaunchResult, dict[str, Any]]:
if not argv:
raise ValueError("missing executable after --")
_validate_windows_affinity_mask(affinity_mask)
si = STARTUPINFOW()
si.cb = ctypes.sizeof(si)
si.dwFlags = STARTF_USESHOWWINDOW
si.wShowWindow = SW_HIDE
stdio_files, stdio_info = _open_windows_stdio(stdout_log, stderr_log)
if stdio_files:
import msvcrt
si.dwFlags |= STARTF_USESTDHANDLES
si.hStdInput = msvcrt.get_osfhandle(stdio_files[0].fileno())
si.hStdOutput = msvcrt.get_osfhandle(stdio_files[1].fileno())
si.hStdError = msvcrt.get_osfhandle(stdio_files[2].fileno())
pi = PROCESS_INFORMATION()
cmdline = ctypes.create_unicode_buffer(subprocess.list2cmdline(list(argv)))
app = _resolve_application(argv)
creation_flags = CREATE_SUSPENDED | CREATE_NO_WINDOW
start = time.perf_counter()
try:
ok = kernel32.CreateProcessW(
app,
cmdline,
None,
None,
bool(stdio_files),
creation_flags,
None,
cwd,
ctypes.byref(si),
ctypes.byref(pi),
)
if not ok:
raise _winerr("CreateProcessW failed")
except Exception:
for f in stdio_files:
f.close()
raise
job = None
logical_cpu_count = os.cpu_count()
if cpu_rate_units is None:
cpu_rate_units = host_cpu_rate_units(cpu_percent)
current_process = kernel32.GetCurrentProcess() if hasattr(kernel32, "GetCurrentProcess") else None
parent_in_job = (
_is_process_in_job(current_process, None) if current_process is not None else None
)
child_in_job_before_assign = _is_process_in_job(pi.hProcess, None)
effective: dict[str, Any] = {
"platform": "windows",
"throttle_supported": True,
"cpu_rate_mode": cpu_rate_mode,
"core_percent": core_percent,
"job_cpu_rate_units": None,
"job_cpu_rate_denominator": 10000,
"job_cpu_rate_readback": None,
"job_denominator_note": (
"Windows Job Object CpuRate units; 10000 is 100 percent of the "
"OS job CPU-rate denominator, not a single-thread slowdown model. "
"Record affinity separately and calibrate with a one-worker "
"CPU-bound witness on the target host."
),
"parent_process_in_job": parent_in_job,
"child_process_in_job_before_assign": child_in_job_before_assign,
"nested_job_note": (
"If a parent job already has CPU rate control, Windows applies "
"nested job rates relative to that parent. This report records "
"whether the host and child were already in a job before assignment."
),
"affinity_cpu_count": affinity_mask.bit_count() if affinity_mask is not None else None,
"affinity_scaled_job_cpu_rate_units": affinity_scaled_cpu_rate_units(
cpu_percent, affinity_mask, logical_cpu_count
),
"core_percent_job_cpu_rate_units": one_core_cpu_rate_units(
core_percent, logical_cpu_count
),
"affinity_scaled_note": (
"Informational only: raw Job Object CpuRate units that would make "
"--cpu-percent apply to the requested affinity set as a fraction "
"of this host's logical CPU count. --core-percent instead applies "
"a one-logical-CPU-equivalent conversion, independent of affinity."
),
"affinity_mask": affinity_mask,
"affinity_applied": False,
"affinity_actual": None,
**stdio_info,
}
timed_out = False
exit_code: int | None = None
user_s = kernel_s = total_s = None
try:
job = kernel32.CreateJobObjectW(None, None)
if not job:
raise _winerr("CreateJobObjectW failed")
_set_job_kill_on_close(job)
if cpu_rate_units is not None:
effective["job_cpu_rate_units"] = _set_job_cpu_rate(job, cpu_rate_units)
effective["job_cpu_rate_readback"] = _query_job_cpu_rate(job)
if affinity_mask is not None:
if not kernel32.SetProcessAffinityMask(pi.hProcess, affinity_mask):
raise _winerr("SetProcessAffinityMask failed")
effective["affinity_applied"] = True
effective["affinity_actual"] = _query_windows_affinity(pi.hProcess)
if not kernel32.AssignProcessToJobObject(job, pi.hProcess):
raise _winerr("AssignProcessToJobObject failed")
if kernel32.ResumeThread(pi.hThread) == 0xFFFFFFFF:
raise _winerr("ResumeThread failed")
wait_ms = INFINITE if timeout is None else max(0, int(timeout * 1000))
wait = kernel32.WaitForSingleObject(pi.hProcess, wait_ms)
if wait == WAIT_TIMEOUT:
timed_out = True
kernel32.TerminateJobObject(job, 1)
done = kernel32.WaitForSingleObject(pi.hProcess, TERMINATE_WAIT_MS)
if done == WAIT_TIMEOUT:
raise PerfHostError("child did not exit after timeout termination")
elif wait != WAIT_OBJECT_0:
raise _winerr("WaitForSingleObject failed")
code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)):
raise _winerr("GetExitCodeProcess failed")
exit_code = int(code.value)
creation = wintypes.FILETIME()
exit_ft = wintypes.FILETIME()
kernel_ft = wintypes.FILETIME()
user_ft = wintypes.FILETIME()
if kernel32.GetProcessTimes(
pi.hProcess,
ctypes.byref(creation),
ctypes.byref(exit_ft),
ctypes.byref(kernel_ft),
ctypes.byref(user_ft),
):
user_s = _filetime_seconds(user_ft)
kernel_s = _filetime_seconds(kernel_ft)
total_s = user_s + kernel_s
except Exception:
if job:
kernel32.TerminateJobObject(job, 1)
if pi.hProcess:
kernel32.TerminateProcess(pi.hProcess, 1)
kernel32.WaitForSingleObject(pi.hProcess, TERMINATE_WAIT_MS)
raise
finally:
if pi.hThread:
kernel32.CloseHandle(pi.hThread)
if pi.hProcess:
kernel32.CloseHandle(pi.hProcess)
if job:
kernel32.CloseHandle(job)
for f in stdio_files:
f.close()
result = LaunchResult(
pid=int(pi.dwProcessId),
exit_code=exit_code,
timed_out=timed_out,
wall_seconds=time.perf_counter() - start,
cpu_user_seconds=user_s,
cpu_kernel_seconds=kernel_s,
cpu_total_seconds=total_s,
)
return result, effective
def launch_portable(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
cpu_rate_units: int | None = None,
cpu_rate_mode: str = "host_percent",
core_percent: float | None = None,
) -> tuple[LaunchResult, dict[str, Any]]:
if cpu_percent is not None or cpu_rate_units is not None or core_percent is not None:
raise UnsupportedThrottle("CPU throttling is only supported on Windows via Job Objects")
if affinity_mask is not None and not hasattr(os, "sched_setaffinity"):
raise UnsupportedThrottle("affinity is unsupported on this platform")
def set_affinity() -> None:
if affinity_mask is not None and hasattr(os, "sched_setaffinity"):
cpus = {i for i in range(affinity_mask.bit_length()) if affinity_mask & (1 << i)}
os.sched_setaffinity(0, cpus) # type: ignore[attr-defined]
start = time.perf_counter()
stdout_f = None
stderr_f = None
stdout_target: Any = None
stderr_target: Any = None
if stdout_log:
stdout_log.parent.mkdir(parents=True, exist_ok=True)
stdout_f = open(stdout_log, "wb")
stdout_target = stdout_f
if stderr_log:
stderr_log.parent.mkdir(parents=True, exist_ok=True)
stderr_f = open(stderr_log, "ab" if stdout_log and stderr_log == stdout_log else "wb")
stderr_target = stderr_f
proc = subprocess.Popen(
list(argv),
cwd=cwd,
preexec_fn=set_affinity if affinity_mask is not None and os.name == "posix" else None,
start_new_session=os.name == "posix",
stdout=stdout_target,
stderr=stderr_target,
)
timed_out = False
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
proc.wait()
except BaseException:
if proc.poll() is None:
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
proc.wait()
raise
finally:
if stdout_f:
stdout_f.close()
if stderr_f:
stderr_f.close()
result = LaunchResult(
pid=proc.pid,
exit_code=proc.returncode,
timed_out=timed_out,
wall_seconds=time.perf_counter() - start,
cpu_user_seconds=None,
cpu_kernel_seconds=None,
cpu_total_seconds=None,
)
return result, {
"platform": "portable",
"throttle_supported": False,
"cpu_rate_mode": cpu_rate_mode,
"core_percent": core_percent,
"job_cpu_rate_units": None,
"job_cpu_rate_denominator": None,
"affinity_mask": affinity_mask,
"affinity_applied": affinity_mask is not None and hasattr(os, "sched_setaffinity"),
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
"stdio_mode": "log-files" if stdout_log or stderr_log else "inherit",
}
def run_host(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
core_percent: float | None = None,
) -> dict[str, Any]:
cpu_percent = validate_cpu_percent(cpu_percent)
core_percent = validate_core_percent(core_percent)
if cpu_percent is not None and core_percent is not None:
raise ValueError("--cpu-percent and --core-percent are mutually exclusive")
timeout = validate_timeout(timeout)
cpu_rate_mode = "host_percent"
cpu_rate_units = host_cpu_rate_units(cpu_percent)
if core_percent is not None:
cpu_rate_mode = "one_core_percent"
cpu_rate_units = one_core_cpu_rate_units(core_percent, os.cpu_count())
launcher = launch_windows if os.name == "nt" else launch_portable
result, effective = launcher(
argv,
cpu_percent,
affinity_mask,
timeout,
cwd,
stdout_log,
stderr_log,
cpu_rate_units,
cpu_rate_mode,
core_percent,
)
return {
"schema_version": 1,
"host": topology_report(),
"request": {
"cpu_percent": cpu_percent,
"core_percent": core_percent,
"affinity": affinity_mask,
"timeout_seconds": timeout,
"argv": list(argv),
"cwd": cwd,
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
},
"effective": effective,
"process": {
"pid": result.pid,
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"wall_seconds": result.wall_seconds,
"cpu_user_seconds": result.cpu_user_seconds,
"cpu_kernel_seconds": result.cpu_kernel_seconds,
"cpu_total_seconds": result.cpu_total_seconds,
},
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run a command under a process-scoped host CPU budget."
)
parser.add_argument("--cpu-percent", type=int, default=None,
help="Windows Job Object hard CPU cap, 1..100 percent.")
parser.add_argument("--core-percent", type=float, default=None,
help=(
"Windows cap as percent of one logical CPU; "
"converts to raw Job Object units using host "
"logical CPU count, independent of affinity."
))
parser.add_argument("--affinity", default=None,
help="Affinity mask, e.g. 0x3, or CPU list, e.g. 0,1.")
parser.add_argument("--timeout", type=float, default=None,
help="Seconds before terminating only the launched job/process.")
parser.add_argument("--report", type=Path, default=None,
help="Write JSON report to this path. Defaults to stdout.")
parser.add_argument("--stdout-log", type=Path, default=None,
help="Write child stdout to this path.")
parser.add_argument("--stderr-log", type=Path, default=None,
help="Write child stderr to this path.")
parser.add_argument("command", nargs=argparse.REMAINDER,
help="Command to run after --.")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
ns = parser.parse_args(argv)
command = list(ns.command)
if command and command[0] == "--":
command = command[1:]
if not command:
parser.error("missing command after --")
stdout_log = ns.stdout_log
stderr_log = ns.stderr_log
if ns.report:
if stdout_log is None:
stdout_log = ns.report.with_name(ns.report.name + ".stdout.log")
if stderr_log is None:
stderr_log = ns.report.with_name(ns.report.name + ".stderr.log")
try:
report = run_host(
command,
ns.cpu_percent,
parse_affinity(ns.affinity),
ns.timeout,
os.getcwd(),
stdout_log,
stderr_log,
ns.core_percent,
)
except (PerfHostError, OSError, ValueError) as exc:
error = {
"schema_version": 1,
"host": topology_report(),
"request": {
"cpu_percent": ns.cpu_percent,
"core_percent": ns.core_percent,
"affinity": ns.affinity,
"timeout_seconds": ns.timeout,
"argv": command,
"cwd": os.getcwd(),
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
},
"error": str(exc),
}
body = json.dumps(error, indent=2, sort_keys=True)
if ns.report:
ns.report.parent.mkdir(parents=True, exist_ok=True)
ns.report.write_text(body + "\n", encoding="utf-8")
else:
print(body)
return 2
body = json.dumps(report, indent=2, sort_keys=True)
if ns.report:
ns.report.parent.mkdir(parents=True, exist_ok=True)
ns.report.write_text(body + "\n", encoding="utf-8")
else:
print(body)
return int(report["process"]["exit_code"] or 0)
if __name__ == "__main__":
raise SystemExit(main())
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Tests for perf_host.py."""
from __future__ import annotations
import importlib.util
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location("perf_host", ROOT / "tools" / "perf_host.py")
PERF = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules["perf_host"] = PERF
SPEC.loader.exec_module(PERF)
class PerfHostTests(unittest.TestCase):
def test_parse_affinity_accepts_mask_and_list(self):
self.assertEqual(PERF.parse_affinity("0x3"), 0x3)
self.assertEqual(PERF.parse_affinity("1,3"), 0b1010)
self.assertIsNone(PERF.parse_affinity(None))
def test_parse_affinity_rejects_zero_and_negative_index(self):
with self.assertRaises(ValueError):
PERF.parse_affinity("0")
with self.assertRaises(ValueError):
PERF.parse_affinity("0,-1")
def test_validate_cpu_percent_range(self):
self.assertEqual(PERF.validate_cpu_percent(25), 25)
self.assertIsNone(PERF.validate_cpu_percent(None))
with self.assertRaises(ValueError):
PERF.validate_cpu_percent(0)
with self.assertRaises(ValueError):
PERF.validate_cpu_percent(101)
def test_validate_core_percent_range(self):
self.assertEqual(PERF.validate_core_percent(12.5), 12.5)
self.assertIsNone(PERF.validate_core_percent(None))
with self.assertRaises(ValueError):
PERF.validate_core_percent(0.0)
with self.assertRaises(ValueError):
PERF.validate_core_percent(float("inf"))
def test_affinity_scaled_cpu_rate_units(self):
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(50, 0x1, 16), 313)
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(25, 0x3, 8), 625)
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(50, None, 16), 5000)
self.assertIsNone(PERF.affinity_scaled_cpu_rate_units(None, 0x1, 16))
self.assertEqual(PERF.one_core_cpu_rate_units(50, 16), 313)
self.assertEqual(PERF.one_core_cpu_rate_units(25, 16), 157)
def test_core_percent_computes_raw_rate_units(self):
fake = PERF.LaunchResult(
pid=123,
exit_code=0,
timed_out=False,
wall_seconds=0.125,
cpu_user_seconds=None,
cpu_kernel_seconds=None,
cpu_total_seconds=None,
)
with mock.patch.object(PERF, "launch_windows",
return_value=(fake, {"platform": "windows"})) as launcher, \
mock.patch.object(PERF.os, "name", "nt"), \
mock.patch.object(PERF.os, "cpu_count", return_value=16):
PERF.run_host(["tool"], None, 0x1, 1.0, core_percent=50)
self.assertEqual(launcher.call_args.args[7], 313)
self.assertEqual(launcher.call_args.args[8], "one_core_percent")
def test_cpu_percent_and_core_percent_are_mutually_exclusive(self):
with self.assertRaises(ValueError):
PERF.run_host(["tool"], 50, 0x1, 1.0, core_percent=50)
def test_portable_throttle_fails_honestly(self):
with self.assertRaises(PERF.UnsupportedThrottle):
PERF.launch_portable([sys.executable, "-c", "pass"], 25, None, 1.0)
def test_run_host_records_report_shape_with_mock_launcher(self):
fake = PERF.LaunchResult(
pid=123,
exit_code=7,
timed_out=False,
wall_seconds=0.125,
cpu_user_seconds=0.01,
cpu_kernel_seconds=0.02,
cpu_total_seconds=0.03,
)
with mock.patch.object(PERF, "launch_portable",
return_value=(fake, {"platform": "portable"})), \
mock.patch.object(PERF.os, "name", "posix"):
report = PERF.run_host(["tool", "arg"], None, 0x3, 2.0, "cwd")
self.assertEqual(report["request"]["argv"], ["tool", "arg"])
self.assertEqual(report["request"]["affinity"], 0x3)
self.assertEqual(report["effective"]["platform"], "portable")
self.assertEqual(report["process"]["exit_code"], 7)
def test_main_writes_error_report_for_unsupported_throttle(self):
with tempfile.TemporaryDirectory() as td:
report_path = Path(td) / "report.json"
with mock.patch.object(
PERF,
"run_host",
side_effect=PERF.UnsupportedThrottle(
"CPU throttling is only supported on Windows via Job Objects"
),
):
rc = PERF.main([
"--cpu-percent", "50",
"--report", str(report_path),
"--",
sys.executable, "-c", "pass",
])
body = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(rc, 2)
self.assertIn("CPU throttling is only supported", body["error"])
def test_windows_failure_terminates_scoped_job(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
calls: list[str] = []
class FakeKernel:
def CreateProcessW(self, app, cmd, psa, tsa, inherit, flags, env, cwd, si, pi):
pi._obj.hProcess = 100
pi._obj.hThread = 200
pi._obj.dwProcessId = 300
return True
def CreateJobObjectW(self, attrs, name):
return 400
def SetInformationJobObject(self, *args):
return True
def SetProcessAffinityMask(self, *args):
return True
def GetProcessAffinityMask(self, process, process_mask, system_mask):
process_mask._obj.value = 0x3
system_mask._obj.value = 0xFFFF
return True
def AssignProcessToJobObject(self, *args):
calls.append("assign")
return False
def ResumeThread(self, *args):
calls.append("resume")
return 0
def TerminateJobObject(self, *args):
calls.append("terminate_job")
return True
def TerminateProcess(self, *args):
calls.append("terminate_process")
return True
def WaitForSingleObject(self, *args):
calls.append("wait")
return PERF.WAIT_OBJECT_0
def CloseHandle(self, handle):
calls.append(f"close:{handle}")
return True
with mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=5):
with self.assertRaisesRegex(PERF.PerfHostError, "AssignProcessToJobObject"):
PERF.launch_windows([sys.executable, "-c", "pass"], 50, None, 1.0)
self.assertIn("terminate_job", calls)
self.assertIn("terminate_process", calls)
self.assertNotIn("resume", calls)
def test_windows_get_exit_code_failure_terminates_scoped_job(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
calls: list[str] = []
class FakeKernel:
def CreateProcessW(self, app, cmd, psa, tsa, inherit, flags, env, cwd, si, pi):
pi._obj.hProcess = 100
pi._obj.hThread = 200
pi._obj.dwProcessId = 300
return True
def CreateJobObjectW(self, attrs, name):
return 400
def SetInformationJobObject(self, *args):
return True
def AssignProcessToJobObject(self, *args):
return True
def ResumeThread(self, *args):
calls.append("resume")
return 0
def WaitForSingleObject(self, *args):
return PERF.WAIT_OBJECT_0
def GetExitCodeProcess(self, *args):
return False
def TerminateJobObject(self, *args):
calls.append("terminate_job")
return True
def TerminateProcess(self, *args):
calls.append("terminate_process")
return True
def CloseHandle(self, handle):
calls.append(f"close:{handle}")
return True
with mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=6):
with self.assertRaisesRegex(PERF.PerfHostError, "GetExitCodeProcess"):
PERF.launch_windows([sys.executable, "-c", "pass"], None, None, 1.0)
self.assertIn("resume", calls)
self.assertIn("terminate_job", calls)
self.assertIn("terminate_process", calls)
def test_windows_create_process_failure_closes_stdio_files(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
class FakeKernel:
def CreateProcessW(self, *args):
return False
with tempfile.TemporaryDirectory() as td, \
mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=2):
out = Path(td) / "child.out"
err = Path(td) / "child.err"
with self.assertRaisesRegex(PERF.PerfHostError, "CreateProcessW"):
PERF.launch_windows([sys.executable, "-c", "pass"], None, None, 1.0,
stdout_log=out, stderr_log=err)
out.unlink()
err.unlink()
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_unrestricted(self):
report = PERF.run_host([sys.executable, "-c", "import sys; sys.exit(3)"],
None, None, 5.0)
self.assertEqual(report["process"]["exit_code"], 3)
self.assertFalse(report["process"]["timed_out"])
self.assertEqual(report["effective"]["platform"], "windows")
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_preserves_logs(self):
with tempfile.TemporaryDirectory() as td:
report_path = Path(td) / "report.json"
rc = PERF.main([
"--timeout", "5",
"--report", str(report_path),
"--",
sys.executable, "-c",
"import sys; print('out'); print('err', file=sys.stderr); sys.exit(3)",
])
body = json.loads(report_path.read_text(encoding="utf-8"))
stdout_log = Path(body["effective"]["stdout_log"])
stderr_log = Path(body["effective"]["stderr_log"])
self.assertEqual(rc, 3)
self.assertEqual(stdout_log.read_text(encoding="utf-8").strip(), "out")
self.assertEqual(stderr_log.read_text(encoding="utf-8").strip(), "err")
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_exit_code_259_is_preserved(self):
report = PERF.run_host([sys.executable, "-c", "import sys; sys.exit(259)"],
None, None, 5.0)
self.assertEqual(report["process"]["exit_code"], 259)
self.assertFalse(report["process"]["timed_out"])
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_timeout_with_cap(self):
report = PERF.run_host([sys.executable, "-c", "while True: pass"],
25, None, 0.2)
self.assertTrue(report["process"]["timed_out"])
self.assertEqual(report["effective"]["job_cpu_rate_units"], 2500)
if __name__ == "__main__":
unittest.main()