Files
glchat/scripts/desktop-perf.py
T

320 lines
13 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""desktop-perf.py — замеры холодного старта и памяти desktop-обёртки.
Требование спецификации: idle RAM ≤ 350 MB, холодный старт ≤ 2 с
(docs/client-tauri.md §1, §7). Скрипт запускает собранный бандл, замеряет время
от старта процесса до загрузки страницы клиента (строка «загружена страница» в
логе обёртки) и память после того, как клиент успокоился.
Память считается по дереву процессов: на macOS страница живёт в отдельных
процессах WebKit (`WebContent`, `GPU`, `Networking`), поэтому одного RSS
главного процесса мало. Помощники WebKit запускаются через launchd (их
ppid = 1), поэтому «свои» определяются сравнением списка процессов до и после
запуска приложения.
Примеры:
python3 scripts/desktop-perf.py # 3 холодных старта + простой
python3 scripts/desktop-perf.py --runs 5 --json build/desktop-perf.json
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_APP = REPO / "desktop/src-tauri/target/release/bundle/macos/glchat.app"
APP_MARKER = "glchat.app/Contents/MacOS/glchat-desktop"
PAGE_LOADED = "загружена страница"
@dataclass
class ProcInfo:
"""Сведения о процессе, нужные для замера."""
pid: int
ppid: int
path: str
rss: int
footprint: int
class MacProcessTable:
"""Чтение процессов через libproc (ps и top в песочнице недоступны)."""
PROC_ALL_PIDS = 1
PROC_PIDTBSDINFO = 3
RUSAGE_INFO_V2 = 2
def __init__(self) -> None:
import ctypes
import ctypes.util
self.ctypes = ctypes
self.libproc = ctypes.CDLL(ctypes.util.find_library("proc"), use_errno=True)
class BsdInfo(ctypes.Structure):
"""struct proc_bsdinfo: нужны pbi_ppid и pbi_pid."""
_fields_ = [
("pbi_flags", ctypes.c_uint32), ("pbi_status", ctypes.c_uint32),
("pbi_xstatus", ctypes.c_uint32), ("pbi_pid", ctypes.c_uint32),
("pbi_ppid", ctypes.c_uint32), ("pbi_uid", ctypes.c_uint32),
("pbi_gid", ctypes.c_uint32), ("pbi_ruid", ctypes.c_uint32),
("pbi_rgid", ctypes.c_uint32), ("pbi_svuid", ctypes.c_uint32),
("pbi_svgid", ctypes.c_uint32), ("rfu_1", ctypes.c_uint32),
("pbi_comm", ctypes.c_char * 16), ("pbi_name", ctypes.c_char * 32),
("pbi_nfiles", ctypes.c_uint32), ("pbi_pgid", ctypes.c_uint32),
("pbi_pjobc", ctypes.c_uint32), ("e_tdev", ctypes.c_uint32),
("e_tpgid", ctypes.c_uint32), ("pbi_nice", ctypes.c_int32),
("pbi_start_tvsec", ctypes.c_uint64), ("pbi_start_tvusec", ctypes.c_uint64),
]
self.bsd_info = BsdInfo
def _ppid(self, pid: int) -> int | None:
ctypes = self.ctypes
info = self.bsd_info()
size = self.libproc.proc_pidinfo(
ctypes.c_int(pid), ctypes.c_int(self.PROC_PIDTBSDINFO), ctypes.c_uint64(0),
ctypes.byref(info), ctypes.c_int(ctypes.sizeof(info)),
)
return int(info.pbi_ppid) if size == ctypes.sizeof(info) else None
def _rusage(self, pid: int) -> tuple[int, int]:
"""RSS и физический след процесса (rusage_info_v2)."""
ctypes = self.ctypes
buf = (ctypes.c_uint64 * 32)()
if self.libproc.proc_pid_rusage(ctypes.c_int(pid), ctypes.c_int(self.RUSAGE_INFO_V2), ctypes.byref(buf)) != 0:
return 0, 0
# ri_uuid занимает два 64-битных слова, дальше: resident_size, phys_footprint.
return int(buf[8]), int(buf[9])
def _path(self, pid: int) -> str:
ctypes = self.ctypes
buf = ctypes.create_string_buffer(4096)
size = self.libproc.proc_pidpath(ctypes.c_int(pid), buf, ctypes.c_uint32(4096))
return buf.value.decode("utf-8", "replace") if size > 0 else ""
def snapshot(self) -> dict[int, ProcInfo]:
ctypes = self.ctypes
count = 8192
buf = (ctypes.c_int * count)()
size = self.libproc.proc_listpids(
ctypes.c_int(self.PROC_ALL_PIDS), ctypes.c_uint32(0), ctypes.byref(buf),
ctypes.c_int(ctypes.sizeof(buf)),
)
result: dict[int, ProcInfo] = {}
for index in range(max(size, 0) // ctypes.sizeof(ctypes.c_int)):
pid = buf[index]
if pid <= 0:
continue
ppid = self._ppid(pid)
if ppid is None:
continue
resident, footprint = self._rusage(pid)
result[pid] = ProcInfo(
pid=pid, ppid=ppid, path=self._path(pid), rss=resident, footprint=footprint,
)
return result
class LinuxProcessTable:
"""Чтение процессов через /proc (для сборок под Linux)."""
def snapshot(self) -> dict[int, ProcInfo]:
result: dict[int, ProcInfo] = {}
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
try:
fields = (entry / "stat").read_text().rsplit(") ", 1)[1].split()
ppid = int(fields[1])
rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
path = os.readlink(entry / "exe")
except (OSError, IndexError, ValueError):
continue
result[pid] = ProcInfo(pid=pid, ppid=ppid, path=path, rss=rss, footprint=rss)
return result
def process_table():
if platform.system() == "Darwin":
return MacProcessTable()
return LinuxProcessTable()
def app_pids(table, marker: str = APP_MARKER) -> list[int]:
return [info.pid for info in table.snapshot().values() if marker in info.path]
def log_candidates() -> list[Path]:
home = Path.home()
return [
home / "Library/Logs/su.mhspx.glchat/glchat.log",
home / ".local/share/su.mhspx.glchat/logs/glchat.log",
home / ".config/su.mhspx.glchat/logs/glchat.log",
]
def log_file() -> Path | None:
existing = [path for path in log_candidates() if path.exists()]
if not existing:
return None
return max(existing, key=lambda path: path.stat().st_mtime)
def quit_app(table, timeout: float = 15.0) -> None:
"""Завершает запущенные экземпляры обёртки и ждёт их исчезновения."""
for pid in app_pids(table):
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not app_pids(table):
time.sleep(0.6) # окну и WebKit нужно время освободить ресурсы
return
time.sleep(0.1)
raise SystemExit("не удалось завершить запущенный экземпляр glchat")
def measure_cold_start(table, app: Path, extra_args: list[str], timeout: float = 30.0) -> dict:
"""Запускает приложение и измеряет время до загрузки страницы клиента."""
before = table.snapshot()
log = log_file()
offset = log.stat().st_size if log is not None else 0
started = time.monotonic()
subprocess.run(["open", "-a", str(app), "--args", *extra_args], check=True)
pid: int | None = None
pid_time: float | None = None
page_time: float | None = None
deadline = started + timeout
while time.monotonic() < deadline:
if pid is None:
found = app_pids(table)
if found:
pid = found[0]
pid_time = time.monotonic()
if log is not None:
with log.open("rb") as handle:
handle.seek(offset)
chunk = handle.read().decode("utf-8", "replace")
if PAGE_LOADED in chunk:
page_time = time.monotonic()
break
time.sleep(0.02)
if pid is None or page_time is None:
raise SystemExit("приложение не запустилось или страница не загрузилась: смотрите лог обёртки")
return {
"pid": pid,
"open_ms": round((pid_time - started) * 1000),
"page_ms": round((page_time - pid_time) * 1000),
"total_ms": round((page_time - started) * 1000),
"before_pids": set(before),
}
def measure_memory(table, pid: int, before_pids: set[int], settle: float) -> dict:
"""Суммирует память обёртки и её процессов WebKit после паузы."""
time.sleep(settle)
snapshot = table.snapshot()
own = snapshot.get(pid)
helpers = [
info for info in snapshot.values()
if info.pid != pid and info.pid not in before_pids and "WebKit" in info.path
]
rss = (own.rss if own else 0) + sum(info.rss for info in helpers)
footprint = (own.footprint if own else 0) + sum(info.footprint for info in helpers)
return {
"rss_mb": round(rss / 1024 / 1024, 1),
"footprint_mb": round(footprint / 1024 / 1024, 1),
"app_rss_mb": round((own.rss if own else 0) / 1024 / 1024, 1),
"webkit_rss_mb": round(sum(info.rss for info in helpers) / 1024 / 1024, 1),
"webkit_processes": len(helpers),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Замеры холодного старта и памяти glchat.app")
parser.add_argument("--app", type=Path, default=DEFAULT_APP, help="путь к собранному .app")
parser.add_argument("--runs", type=int, default=3, help="сколько холодных стартов измерить")
parser.add_argument("--settle", type=float, default=20.0, help="пауза перед замером памяти, с")
parser.add_argument("--json", type=Path, default=None, help="сохранить результаты в JSON")
args = parser.parse_args()
if not args.app.exists():
print(f"нет собранного приложения: {args.app} (сначала make desktop-build)", file=sys.stderr)
return 1
table = process_table()
results: dict[str, object] = {"app": str(args.app), "runs": []}
# Настройки обёртки замер не меняет: приложение запускается как обычно,
# автозапуск не включается и не выключается.
for _ in range(args.runs):
quit_app(table)
run = measure_cold_start(table, args.app, [])
run.update(measure_memory(table, run["pid"], run.pop("before_pids"), args.settle))
results["runs"].append(run)
print(
f"старт: open→процесс {run['open_ms']} мс, процесс→страница {run['page_ms']} мс, "
f"итого {run['total_ms']} мс; память RSS {run['rss_mb']} МБ "
f"(обёртка {run['app_rss_mb']} + WebKit {run['webkit_rss_mb']}), "
f"footprint {run['footprint_mb']} МБ"
)
# Простой в трее: окно скрыто, клиент загружен (closeToTray).
quit_app(table)
tray = measure_cold_start(table, args.app, ["--minimized"])
tray.update(measure_memory(table, tray["pid"], tray.pop("before_pids"), args.settle))
results["tray"] = tray
print(
f"трей: старт {tray['total_ms']} мс; память RSS {tray['rss_mb']} МБ "
f"(обёртка {tray['app_rss_mb']} + WebKit {tray['webkit_rss_mb']}), "
f"footprint {tray['footprint_mb']} МБ"
)
cold = [run["total_ms"] for run in results["runs"]]
results["summary"] = {
"cold_ms_min": min(cold),
"cold_ms_max": max(cold),
"cold_ms_avg": round(sum(cold) / len(cold)),
"ram_open_mb": max(run["rss_mb"] for run in results["runs"]),
"ram_tray_mb": tray["rss_mb"],
"budget_ram_mb": 350,
"budget_cold_ms": 2000,
}
print(
f"итог: холодный старт {results['summary']['cold_ms_min']}–"
f"{results['summary']['cold_ms_max']} мс (бюджет 2000), "
f"RAM открытого клиента {results['summary']['ram_open_mb']} МБ, "
f"в трее {results['summary']['ram_tray_mb']} МБ (бюджет 350)"
)
if args.json is not None:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
print(f"результаты: {args.json}")
quit_app(table)
return 0
if __name__ == "__main__":
sys.exit(main())