Files
core/test/performance/process.py
T

232 lines
9.2 KiB
Python

"""Linux 进程与可选专属 cgroup 的生命周期和采样。"""
import os
from pathlib import Path
import signal
import statistics
import subprocess
import time
import uuid
from settings import stage_peer
def key_values(path):
return {fields[0].rstrip(":"): fields[1] for line in path.read_text().splitlines()
if len(fields := line.split()) >= 2}
def process_sample(pid):
root = Path("/proc") / str(pid)
status = key_values(root / "status")
if "VmRSS" not in status:
raise ProcessLookupError(f"process {pid} exited during sampling")
memory = key_values(root / "smaps_rollup")
# comm 字段可含空格和括号;从最后一个右括号之后解析固定 stat 字段。
stat = (root / "stat").read_text().rsplit(")", 1)[1].split()
switches = {"voluntary_ctxt_switches": 0, "nonvoluntary_ctxt_switches": 0}
for thread in (root / "task").iterdir():
try:
thread_status = key_values(thread / "status")
except FileNotFoundError:
continue # A completed blocking thread can disappear during a sample.
for name in switches:
switches[name] += int(thread_status[name])
return {
"time": time.monotonic(),
"rss_bytes": int(status["VmRSS"]) * 1024,
"rss_high_water_bytes": int(status["VmHWM"]) * 1024,
"pss_bytes": int(memory["Pss"]) * 1024,
"threads": int(status["Threads"]),
"fds": len(list((root / "fd").iterdir())),
"cpu_seconds": (int(stat[11]) + int(stat[12])) / os.sysconf("SC_CLK_TCK"),
**switches,
}
class Budget:
def __init__(self, parent, memory_mib, cpu_quota):
self.path = None
self.final = None
if parent is None:
if memory_mib is not None or cpu_quota is not None:
raise ValueError("memory/cpu quotas require --cgroup-parent")
return
self.path = parent.resolve() / ("dever-perf-" + uuid.uuid4().hex)
self.path.mkdir()
try:
if memory_mib is not None:
(self.path / "memory.max").write_text(str(memory_mib * 1024 * 1024))
(self.path / "memory.swap.max").write_text("0")
if cpu_quota is not None:
(self.path / "cpu.max").write_text(f"{round(cpu_quota * 100000)} 100000")
except BaseException:
self.path.rmdir()
raise
def sample(self):
if self.path is None:
return None
return {
"current_bytes": int((self.path / "memory.current").read_text()),
"peak_bytes": int((self.path / "memory.peak").read_text()),
"events": {k: int(v) for k, v in key_values(self.path / "memory.events").items()},
"cpu": {k: int(v) for k, v in key_values(self.path / "cpu.stat").items()},
"max": (self.path / "memory.max").read_text().strip(),
"cpu_max": (self.path / "cpu.max").read_text().strip(),
}
def close(self):
if self.path is not None:
try:
self.final = self.sample()
except OSError as error:
try:
self.path.rmdir()
except OSError as cleanup_error:
error.add_note(f"cannot remove owned cgroup {self.path}: {cleanup_error}")
raise
else:
self.path.rmdir()
class Process:
def __init__(self, command, directory, *, peer_settings=None, cpus=None, budget=None):
self.command = [str(arg) for arg in command]
self.directory = directory
self.budget = budget
self.samples = []
self.directory.mkdir(parents=True, exist_ok=False)
if peer_settings is not None:
self.command[0] = str(stage_peer(command[0], directory, peer_settings))
self.stdout = (directory / "stdout.log").open("w")
self.stderr = (directory / "stderr.log").open("w")
self.started = time.monotonic()
def prepare_child():
# 运行器单线程;只对即将 exec 的自有子进程设置 CPU/cgroup。
if cpus is not None:
os.sched_setaffinity(0, cpus)
if budget is not None and budget.path is not None:
(budget.path / "cgroup.procs").write_text(str(os.getpid()))
try:
self.child = subprocess.Popen(self.command, stdout=self.stdout, stderr=self.stderr,
preexec_fn=prepare_child, start_new_session=True)
except BaseException:
self.stdout.close()
self.stderr.close()
raise
def text(self):
return (self.directory / "stdout.log").read_text()
def descriptors(self):
descriptors = {}
for path in (Path("/proc") / str(self.child.pid) / "fd").iterdir():
try:
descriptors[path.name] = os.readlink(path)
except FileNotFoundError:
continue # A descriptor may close while /proc is being read.
return descriptors
def sample(self):
if self.child.poll() is not None:
return
try:
sample = process_sample(self.child.pid)
except (FileNotFoundError, ProcessLookupError):
# Linux can release /proc memory fields before waitpid reports exit.
# This observation is unavailable; check() still owns exit status.
return
sample["elapsed_seconds"] = sample.pop("time") - self.started
if self.budget is not None:
sample["cgroup"] = self.budget.sample()
self.samples.append(sample)
def ready(self, timeout=10):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
self.sample()
for line in self.text().splitlines():
if line.startswith("ERROR|"):
raise RuntimeError(line)
if line.startswith("READY|"):
self.ready_seconds = time.monotonic() - self.started
return int(line.split("|")[1])
if self.child.poll() is not None:
raise RuntimeError(f"process exited before readiness: {self.directory}")
time.sleep(0.005)
raise TimeoutError(f"readiness timeout: {self.directory}")
def check(self):
code = self.child.poll()
if code is not None and code != 0:
raise RuntimeError(f"process exited {code}: {self.directory}")
if "ERROR|" in self.text() or (self.directory / "stderr.log").stat().st_size:
raise RuntimeError(f"benchmark reported an error: {self.directory}")
def close(self):
try:
if self.child.poll() is None:
try:
os.killpg(self.child.pid, signal.SIGTERM)
except ProcessLookupError:
# 子进程可能恰好在 poll 与 signal 之间自然退出。
pass
try:
self.child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(self.child.pid, signal.SIGKILL)
self.child.wait(timeout=3)
finally:
self.stdout.close()
self.stderr.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def observe(processes, seconds, interval):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
for process in processes:
process.sample()
process.check()
time.sleep(min(interval, max(0, deadline - time.monotonic())))
def summarize(samples):
if not samples:
raise ValueError("process ended before any resource sample")
result = {"samples": len(samples)}
for metric in ("rss_bytes", "pss_bytes", "threads", "fds"):
values = sorted(sample[metric] for sample in samples)
result[metric + "_median"] = values[len(values) // 2]
result[metric + "_max"] = max(values)
result["rss_high_water_bytes"] = max(sample["rss_high_water_bytes"] for sample in samples)
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
cpu = samples[-1]["cpu_seconds"] - samples[0]["cpu_seconds"]
result["cpu_seconds_sampled"] = cpu
result["cpu_cores_sampled"] = cpu / elapsed if elapsed > 0 else None
for name in ("voluntary_ctxt_switches", "nonvoluntary_ctxt_switches"):
result[name + "_sampled"] = samples[-1][name] - samples[0][name]
return result
def resource_trend(samples):
"""报告四段中位数与后半程增长,避免将启动分配误记为长期泄漏。"""
if len(samples) < 4:
raise ValueError("resource trend requires at least four observations")
windows = [samples[len(samples) * index // 4:len(samples) * (index + 1) // 4]
for index in range(4)]
medians = [statistics.median(sample["rss_bytes"] for sample in window) for window in windows]
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
return {"observed_seconds": elapsed, "rss_quarter_medians_bytes": medians,
"rss_late_growth_bytes": medians[-1] - medians[2],
"rss_late_growth_bytes_per_second": (medians[-1] - medians[2]) / (elapsed / 4) if elapsed > 0 else None,
"fds_first": samples[0]["fds"], "fds_last": samples[-1]["fds"]}