Files
glchat/scripts/desktop-perf.py
T
grendervill 72a51f083d test(desktop): замеры холодного старта и памяти обёртки
Требование спецификации §1/§7 (idle RAM ≤ 350 МБ, холодный старт ≤ 2 с) до сих
пор не было измерено. `scripts/desktop-perf.py` запускает собранный `.app`,
меряет время от `open` до строки «загружена страница» в логе обёртки и память
после того, как клиент успокоился.

`ps` и `top` в песочнице агента недоступны, поэтому на macOS память и список
процессов читаются через `libproc` (`proc_pid_rusage`, `proc_listpids`), а для
Linux есть ветка на `/proc`. Помощники WebKit (`WebContent`, `GPU`,
`Networking`) запускаются через launchd, их родитель — не приложение, поэтому
«свои» определяются сравнением списка процессов до и после запуска: иначе RSS
главного процесса занижал бы потребление втрое.

Замеры (macOS arm64, Mac на M-серии, инстанс через внешний прокси):
холодный старт 708–740 мс, RSS открытого клиента 252 МБ (обёртка 117 + WebKit
135), в трее 251 МБ, физический след 103 МБ. Результаты — в `docs/PERF.md` и
`desktop/README.md`.

В Makefile добавлена цель `desktop-build-app` (`tauri build --bundles app`):
артефакты обновления собираются без `.dmg`, который в песочнице и headless-
окружении падает на `bundle_dmg.sh` (монтирование образа + AppleScript).
2026-09-22 23:17:39 +03:00

320 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())