дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
perf: Add a black-box comparison with other IDEs (#6)
perf/compare/bench.py launches each IDE in apps.toml on the same project and measures it from the outside, the same way for each: time to the project window, to CPU settling and to log markers; memory and CPU of the whole process tree; idle CPU, exit time, cache and install size. Runs on Windows, macOS and Linux with psutil; --wipe-caches measures a cold index. README: a Benchmark section with the first results against IntelliJ IDEA.
Цей коміт міститься в:
1 джерело
79f6caeaa8
коміт
6165cb0174
6 files changed
+732
No files matched your search
@@ -17,6 +17,26 @@ its search-everything habits — to a native app with a Rust core.
|
||||
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
|
||||
for 60+ languages.
|
||||
|
||||
## Benchmark
|
||||
|
||||
Opening [freqtrade](https://github.com/freqtrade/freqtrade) (Python, ~770 files)
|
||||
with warm caches, median of 5 runs:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
|---|---|---|
|
||||
| Window with the project | **0.9 s** | 3.1 s |
|
||||
| Ready (CPU settled) | **1.1 s** | 13.2 s |
|
||||
| Memory (USS, all processes) | **169 MB** | 2011 MB |
|
||||
| CPU time until ready | **2.4 s** | 47.9 s |
|
||||
| Idle CPU | **4 %** of a core | 7 % of a core |
|
||||
| Quit | **0.3 s** | 5.9 s |
|
||||
|
||||
A release build of OpenIDEA from `main` and IDEA with default settings and plugins, on
|
||||
Windows 11, 32-core AMD, 62 GB RAM.
|
||||
IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
results/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,51 @@
|
||||
# IDE comparison
|
||||
|
||||
`pnpm perf` measures OpenIDEA from inside its webview. This measures any IDE
|
||||
from the outside, the same way for each, so OpenIDEA can be compared with
|
||||
IntelliJ IDEA (or anything else listed in `apps.toml`).
|
||||
|
||||
```sh
|
||||
pip install -r perf/compare/requirements.txt
|
||||
python perf/compare/bench.py ~/src/spring-petclinic --runs 10
|
||||
python perf/compare/bench.py ~/src/spring-petclinic --runs 5 --wipe-caches # cold index
|
||||
```
|
||||
|
||||
Each run launches the app on the project, waits until it is ready, idles,
|
||||
closes it, and records:
|
||||
|
||||
| Metric | How |
|
||||
|---|---|
|
||||
| `window_s` | first visible window whose title matches `window_title` (the project name) |
|
||||
| `ready_s` | the process tree's CPU stays under `--quiet-cores` for `--quiet-secs`, after the window and any `ready_after` markers |
|
||||
| markers | first/last time a log line matches, e.g. OpenIDEA `indexed`, IDEA `exit dumb mode` |
|
||||
| `peak_rss_mb`, `rss_mb`, `uss_mb`, `pss_mb` | summed over every process the app started (WebView2/WebKit, jdtls, language servers, the JVM) |
|
||||
| `total_cpu_s`, `idle_cpu_pct` | CPU time until ready; CPU while idle for `--idle-secs` |
|
||||
| `exit_s` | until every process has exited |
|
||||
| `cache_mb`, install size | `cache_dirs`, `install_dir` |
|
||||
|
||||
Apps run interleaved, rotating the order, after `--warmup` discarded runs.
|
||||
Output goes to `results/<time>-<project>/`: `report.md` (median, min–max),
|
||||
`results.json`, and each run's stdout.
|
||||
|
||||
## Fair runs
|
||||
|
||||
- Release builds only; a running instance of the same executable (a
|
||||
`pnpm tauri dev` build too) would take over the launch, so the script refuses.
|
||||
- IDEA: open the project once by hand and trust it; turn off *Reopen projects
|
||||
on startup* and *Confirm before exiting*; note the version, `-Xmx` and whether
|
||||
Shared Indexes were on.
|
||||
- Same machine, power plan and plugged in; exclude the project and caches from
|
||||
antivirus scanning, or neither.
|
||||
- `--drop-os-cache` runs a command before each launch for a cold OS cache:
|
||||
`sync; echo 3 | sudo tee /proc/sys/vm/drop_caches` (Linux), `sudo purge`
|
||||
(macOS), `RAMMap64.exe -Et` (Windows, admin).
|
||||
- Typing latency is not covered: measure it on screen with Typometer for both.
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Windows: windows via `user32`, closed with `WM_CLOSE`.
|
||||
- macOS: windows via Quartz (`pyobjc-framework-Quartz`; titles need Screen
|
||||
Recording permission). WebKit helpers are children of launchd and are
|
||||
attributed through `responsibility_get_pid_responsible_for_pid`.
|
||||
- Linux: windows via `wmctrl` (X11). Without it `window_s` is skipped and
|
||||
readiness counts from launch.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Apps compared by bench.py. Any value can be a { windows, macos, linux } table.
|
||||
# Placeholders: {project} (absolute path), {project_name} (its directory name);
|
||||
# ~ and environment variables are expanded.
|
||||
#
|
||||
# command executable, launched directly (not through a shell or `open`)
|
||||
# args default ["{project}"]
|
||||
# window_title regex; the first visible window whose title matches marks "window_s"
|
||||
# logs globs of log files to watch, besides the app's stdout
|
||||
# markers name → regex; the first and last matching log line time is recorded
|
||||
# ready_after markers that must have appeared before "ready" can be declared
|
||||
# cache_dirs globs deleted by --wipe-caches, and measured for "cache_mb"
|
||||
# install_dir measured once for the install size
|
||||
# quit_command instead of WM_CLOSE (Windows) / SIGTERM (macOS, Linux)
|
||||
# daemons process names meant to outlive the app (not waited for or killed on exit)
|
||||
# single_instance default true: refuse to start while a process with the same
|
||||
# executable name runs (it would take over the launch)
|
||||
|
||||
[apps.openidea]
|
||||
name = "OpenIDEA"
|
||||
command = { windows = "%LOCALAPPDATA%/OpenIDEA/openidea.exe", macos = "/Applications/OpenIDEA.app/Contents/MacOS/openidea", linux = "/usr/bin/openidea" }
|
||||
install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/OpenIDEA.app" }
|
||||
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
|
||||
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
|
||||
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
|
||||
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
|
||||
|
||||
[apps.openidea.markers]
|
||||
project_opened = 'opened .* in window'
|
||||
indexed = 'indexed \d+ files'
|
||||
lsp_initialized = 'initialized: \{'
|
||||
|
||||
[apps.idea]
|
||||
name = "IntelliJ IDEA"
|
||||
command = { windows = "C:/Program Files/JetBrains/IntelliJ IDEA 2026.2.3/bin/idea64.exe", macos = "/Applications/IntelliJ IDEA.app/Contents/MacOS/idea", linux = "~/idea/bin/idea" }
|
||||
daemons = ["jetbrainsd.exe", "jetbrainsd"]
|
||||
install_dir = { windows = "C:/Program Files/JetBrains/IntelliJ IDEA 2026.2.3", macos = "/Applications/IntelliJ IDEA.app", linux = "~/idea" }
|
||||
logs = { windows = "%LOCALAPPDATA%/JetBrains/IntelliJIdea2026.2/log/idea.log", macos = "~/Library/Logs/JetBrains/IntelliJIdea2026.2/idea.log", linux = "~/.cache/JetBrains/IntelliJIdea2026.2/log/idea.log" }
|
||||
# The VFS and the indexes (the whole "system" dir also holds logs and local history).
|
||||
cache_dirs = { windows = ["%LOCALAPPDATA%/JetBrains/IntelliJIdea2026.2/caches", "%LOCALAPPDATA%/JetBrains/IntelliJIdea2026.2/index"], macos = ["~/Library/Caches/JetBrains/IntelliJIdea2026.2/caches", "~/Library/Caches/JetBrains/IntelliJIdea2026.2/index"], linux = ["~/.cache/JetBrains/IntelliJIdea2026.2/caches", "~/.cache/JetBrains/IntelliJIdea2026.2/index"] }
|
||||
|
||||
[apps.idea.markers]
|
||||
# Indexing done: IDEA leaves dumb mode, possibly several times; compare "last".
|
||||
smart = 'exit dumb mode \[{project_name}\]'
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box IDE comparison: startup, readiness, memory, idle CPU, disk.
|
||||
|
||||
Launches each app from apps.toml on the same project, measures it from the
|
||||
outside (process tree, window list, log lines), closes it, and repeats with
|
||||
the apps interleaved. Runs on Windows, macOS and Linux; see README.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import ctypes
|
||||
import datetime as dt
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
sys.exit("psutil is required: pip install -r perf/compare/requirements.txt")
|
||||
|
||||
OS = {"Windows": "windows", "Darwin": "macos", "Linux": "linux"}[platform.system()]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
||||
GONE = (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied)
|
||||
MB = 1024 * 1024
|
||||
POLL_S = 0.2
|
||||
|
||||
|
||||
# ── config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def per_os(value):
|
||||
"""A value, or a {windows, macos, linux} table of per-platform values."""
|
||||
if isinstance(value, dict) and value and value.keys() <= {"windows", "macos", "linux"}:
|
||||
return value.get(OS)
|
||||
return value
|
||||
|
||||
|
||||
def as_list(value) -> list:
|
||||
return [] if value is None else [value] if isinstance(value, str) else list(value)
|
||||
|
||||
|
||||
def expand(s: str, project: Path) -> str:
|
||||
s = s.replace("{project}", str(project)).replace("{project_name}", project.name)
|
||||
return os.path.expandvars(os.path.expanduser(s))
|
||||
|
||||
|
||||
def pattern(s: str, project: Path) -> re.Pattern:
|
||||
return re.compile(s.replace("{project_name}", re.escape(project.name)))
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, key: str, raw: dict, project: Path):
|
||||
self.key = key
|
||||
self.name = raw.get("name", key)
|
||||
command = per_os(raw.get("command"))
|
||||
if not command:
|
||||
raise SystemExit(f"apps.{key}: no command for {OS}")
|
||||
args = per_os(raw.get("args", ["{project}"]))
|
||||
self.command = [expand(command, project), *(expand(a, project) for a in args)]
|
||||
self.logs = [expand(g, project) for g in as_list(per_os(raw.get("logs")))]
|
||||
self.markers = {k: pattern(v, project) for k, v in raw.get("markers", {}).items()}
|
||||
self.ready_after = as_list(raw.get("ready_after"))
|
||||
self.window_title = pattern(raw.get("window_title", "{project_name}"), project)
|
||||
self.cache_dirs = [expand(d, project) for d in as_list(per_os(raw.get("cache_dirs")))]
|
||||
self.install_dir = per_os(raw.get("install_dir"))
|
||||
self.install_dir = expand(self.install_dir, project) if self.install_dir else None
|
||||
self.quit_command = as_list(per_os(raw.get("quit_command")))
|
||||
self.single_instance = raw.get("single_instance", True)
|
||||
self.daemons = {n.lower() for n in as_list(raw.get("daemons"))}
|
||||
unknown = set(self.ready_after) - self.markers.keys()
|
||||
if unknown:
|
||||
raise SystemExit(f"apps.{key}.ready_after names unknown markers: {sorted(unknown)}")
|
||||
|
||||
def cache_paths(self) -> list[Path]:
|
||||
return [Path(p) for d in self.cache_dirs for p in sorted(glob.glob(d))]
|
||||
|
||||
|
||||
# ── windows ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if OS == "windows":
|
||||
from ctypes import wintypes
|
||||
|
||||
_user32 = ctypes.WinDLL("user32")
|
||||
_ENUM = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
_user32.EnumWindows.argtypes = [_ENUM, wintypes.LPARAM]
|
||||
_user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
||||
_user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
||||
_user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
|
||||
_user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
||||
_user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
||||
|
||||
|
||||
def _win_windows(pids: set[int]) -> list[tuple[object, str]]:
|
||||
found = []
|
||||
|
||||
def visit(hwnd, _):
|
||||
if _user32.IsWindowVisible(hwnd):
|
||||
pid = wintypes.DWORD()
|
||||
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value in pids:
|
||||
buf = ctypes.create_unicode_buffer(_user32.GetWindowTextLengthW(hwnd) + 1)
|
||||
_user32.GetWindowTextW(hwnd, buf, len(buf))
|
||||
found.append((hwnd, buf.value))
|
||||
return True
|
||||
|
||||
_user32.EnumWindows(_ENUM(visit), 0)
|
||||
return found
|
||||
|
||||
|
||||
def _mac_windows(pids: set[int]) -> list[tuple[object, str]] | None:
|
||||
try:
|
||||
import Quartz
|
||||
except ImportError:
|
||||
return None
|
||||
infos = Quartz.CGWindowListCopyWindowInfo(
|
||||
Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements, Quartz.kCGNullWindowID
|
||||
)
|
||||
# Titles need the Screen Recording permission; without it they come back empty.
|
||||
return [
|
||||
(None, str(w.get("kCGWindowName") or ""))
|
||||
for w in infos
|
||||
if w.get("kCGWindowOwnerPID") in pids and w.get("kCGWindowLayer") == 0
|
||||
]
|
||||
|
||||
|
||||
def _x11_windows(pids: set[int]) -> list[tuple[object, str]] | None:
|
||||
if not shutil.which("wmctrl"):
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["wmctrl", "-lp"], capture_output=True, text=True, timeout=2).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
found = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split(None, 4)
|
||||
if len(parts) >= 4 and parts[2].isdigit() and int(parts[2]) in pids:
|
||||
found.append((parts[0], parts[4] if len(parts) > 4 else ""))
|
||||
return found
|
||||
|
||||
|
||||
def visible_windows(pids: set[int]) -> list[tuple[object, str]] | None:
|
||||
"""(handle, title) of the visible top-level windows owned by `pids`; None when this platform can't tell."""
|
||||
if OS == "windows":
|
||||
return _win_windows(pids)
|
||||
if OS == "macos":
|
||||
return _mac_windows(pids)
|
||||
return _x11_windows(pids)
|
||||
|
||||
|
||||
# ── process tree ────────────────────────────────────────────────────────────
|
||||
|
||||
_responsible = None
|
||||
if OS == "macos":
|
||||
try:
|
||||
_responsible = ctypes.CDLL("/usr/lib/libSystem.B.dylib").responsibility_get_pid_responsible_for_pid
|
||||
_responsible.argtypes = [ctypes.c_int]
|
||||
_responsible.restype = ctypes.c_int
|
||||
except (OSError, AttributeError):
|
||||
_responsible = None
|
||||
|
||||
|
||||
class Tree:
|
||||
"""Every process the app started, including ones whose parent already exited.
|
||||
|
||||
On macOS, WKWebView's helper processes are children of launchd, so they are
|
||||
attributed through the responsible-pid API (or, failing that, by name and
|
||||
start time).
|
||||
"""
|
||||
|
||||
def __init__(self, root: psutil.Process, started: float):
|
||||
self.root = root
|
||||
self.started = started
|
||||
self.procs: dict[tuple[int, float], psutil.Process] = {}
|
||||
self.cpu: dict[tuple[int, float], float] = {}
|
||||
self.cpu_exited = 0.0
|
||||
|
||||
def _discover(self):
|
||||
found = [self.root]
|
||||
for p in list(self.procs.values()):
|
||||
try:
|
||||
found += p.children(recursive=True)
|
||||
except GONE:
|
||||
pass
|
||||
if OS == "macos":
|
||||
found += self._webkit()
|
||||
for p in found:
|
||||
try:
|
||||
self.procs.setdefault((p.pid, p.create_time()), p)
|
||||
except GONE:
|
||||
pass
|
||||
|
||||
def _webkit(self) -> list[psutil.Process]:
|
||||
out = []
|
||||
for p in psutil.process_iter(["name", "create_time"]):
|
||||
if not (p.info["name"] or "").startswith("com.apple.WebKit"):
|
||||
continue
|
||||
if _responsible:
|
||||
if _responsible(p.pid) == self.root.pid:
|
||||
out.append(p)
|
||||
elif (p.info["create_time"] or 0) >= self.started - 1:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def sample(self) -> tuple[float, int]:
|
||||
"""(total CPU seconds ever used by the tree, current summed RSS bytes)."""
|
||||
self._discover()
|
||||
rss = 0
|
||||
for key, p in list(self.procs.items()):
|
||||
try:
|
||||
with p.oneshot():
|
||||
t = p.cpu_times()
|
||||
self.cpu[key] = t.user + t.system
|
||||
rss += p.memory_info().rss
|
||||
except GONE:
|
||||
self.cpu_exited += self.cpu.pop(key, 0.0)
|
||||
del self.procs[key]
|
||||
return self.cpu_exited + sum(self.cpu.values()), rss
|
||||
|
||||
def pids(self) -> set[int]:
|
||||
return {pid for pid, _ in self.procs}
|
||||
|
||||
def alive(self) -> list[psutil.Process]:
|
||||
return [p for p in self.procs.values() if p.is_running()]
|
||||
|
||||
def memory(self) -> dict:
|
||||
"""Summed memory of the live tree. RSS double-counts shared pages, USS leaves them out."""
|
||||
rss = uss = pss = 0
|
||||
by_name: dict[str, int] = collections.Counter()
|
||||
count = 0
|
||||
for p in self.alive():
|
||||
try:
|
||||
full = p.memory_full_info()
|
||||
except GONE:
|
||||
continue
|
||||
count += 1
|
||||
rss += full.rss
|
||||
uss += full.uss
|
||||
pss += getattr(full, "pss", 0)
|
||||
by_name[p.name()] += full.uss
|
||||
out = {"rss_mb": rss / MB, "uss_mb": uss / MB, "procs": count}
|
||||
if OS == "linux":
|
||||
out["pss_mb"] = pss / MB
|
||||
out["uss_by_process_mb"] = {k: round(v / MB, 1) for k, v in by_name.most_common()}
|
||||
return out
|
||||
|
||||
|
||||
# ── logs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LogTail:
|
||||
"""New lines appended to the files matching some globs since construction."""
|
||||
|
||||
def __init__(self, globs: list[str], extra: list[Path]):
|
||||
self.globs = globs
|
||||
self.extra = extra
|
||||
self.offsets: dict[str, int] = {}
|
||||
self.partial: dict[str, str] = {}
|
||||
for path in self._paths():
|
||||
self.offsets[path] = os.path.getsize(path)
|
||||
|
||||
def _paths(self) -> list[str]:
|
||||
paths = [p for g in self.globs for p in glob.glob(g)]
|
||||
return paths + [str(p) for p in self.extra if p.exists()]
|
||||
|
||||
def lines(self) -> list[str]:
|
||||
out = []
|
||||
for path in self._paths():
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(self.offsets.get(path, 0))
|
||||
chunk = f.read()
|
||||
self.offsets[path] = f.tell()
|
||||
except OSError:
|
||||
continue
|
||||
if not chunk:
|
||||
continue
|
||||
text = self.partial.pop(path, "") + chunk.decode("utf-8", "replace")
|
||||
*complete, rest = text.split("\n")
|
||||
if rest:
|
||||
self.partial[path] = rest
|
||||
out += [ANSI.sub("", line) for line in complete]
|
||||
return out
|
||||
|
||||
|
||||
# ── one run ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def running_instances(app: App) -> list[psutil.Process]:
|
||||
"""Processes with the app's executable name, from any install or build: a
|
||||
single-instance app hands the launch to them even when the paths differ."""
|
||||
name = os.path.normcase(Path(app.command[0]).name)
|
||||
return [p for p in psutil.process_iter(["name"]) if os.path.normcase(p.info["name"] or "") == name]
|
||||
|
||||
|
||||
def dir_size(path: Path) -> int:
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
total = 0
|
||||
for root, _, files in os.walk(path):
|
||||
for f in files:
|
||||
try:
|
||||
total += os.lstat(os.path.join(root, f)).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def close(app: App, tree: Tree, proc: subprocess.Popen, timeout: float) -> tuple[float | None, list[str]]:
|
||||
"""Asks the app to quit, waits for the whole tree, kills what's left.
|
||||
|
||||
Returns (seconds until every process exited, or None if it had to kill; names of killed leftovers).
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
if app.quit_command:
|
||||
subprocess.run(app.quit_command, check=False)
|
||||
elif OS == "windows":
|
||||
# Only the project windows: closing a framework's hidden message windows
|
||||
# (Tauri's single-instance and event-target ones) stalls the real shutdown.
|
||||
for hwnd, title in _win_windows(tree.pids()):
|
||||
if app.window_title.search(title):
|
||||
_user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
|
||||
else:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
|
||||
def remaining() -> list[psutil.Process]:
|
||||
out = []
|
||||
for p in tree.alive():
|
||||
try:
|
||||
if p.name().lower() not in app.daemons:
|
||||
out.append(p)
|
||||
except GONE:
|
||||
pass
|
||||
return out
|
||||
|
||||
while time.perf_counter() - t0 < timeout:
|
||||
tree.sample()
|
||||
if not remaining():
|
||||
return time.perf_counter() - t0, []
|
||||
time.sleep(POLL_S)
|
||||
leftovers = remaining()
|
||||
names = []
|
||||
for p in leftovers:
|
||||
try:
|
||||
names.append(p.name())
|
||||
p.kill()
|
||||
except GONE:
|
||||
pass
|
||||
psutil.wait_procs(leftovers, timeout=10)
|
||||
return None, names
|
||||
|
||||
|
||||
def run_once(app: App, args, out_dir: Path, label: str) -> dict:
|
||||
running = running_instances(app) if app.single_instance else []
|
||||
if running:
|
||||
pids = ", ".join(str(p.pid) for p in running)
|
||||
raise SystemExit(f"{app.name} is already running (pid {pids}); close it first, it would take over the launch.")
|
||||
if args.wipe_caches:
|
||||
for path in app.cache_paths():
|
||||
shutil.rmtree(path, ignore_errors=True) if path.is_dir() else path.unlink(missing_ok=True)
|
||||
if args.drop_os_cache:
|
||||
subprocess.run(args.drop_os_cache, shell=True, check=True)
|
||||
if args.cooldown:
|
||||
time.sleep(args.cooldown)
|
||||
|
||||
stdout_path = out_dir / f"{label}.stdout.log"
|
||||
tail = LogTail(app.logs, [stdout_path])
|
||||
started_wall = time.time()
|
||||
with open(stdout_path, "wb") as stdout:
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.Popen(app.command, stdout=stdout, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
||||
tree = Tree(psutil.Process(proc.pid), started_wall)
|
||||
|
||||
result: dict = {"app": app.key, "label": label, "markers_first_s": {}, "markers_last_s": {}}
|
||||
window_supported = True
|
||||
window_s = None
|
||||
history: collections.deque[tuple[float, float]] = collections.deque()
|
||||
peak_rss = 0
|
||||
ready_s = None
|
||||
|
||||
while True:
|
||||
now = time.perf_counter() - t0
|
||||
cpu, rss = tree.sample()
|
||||
peak_rss = max(peak_rss, rss)
|
||||
history.append((now, cpu))
|
||||
while history and now - history[0][0] > args.quiet_secs:
|
||||
history.popleft()
|
||||
|
||||
for line in tail.lines():
|
||||
for name, rx in app.markers.items():
|
||||
if rx.search(line):
|
||||
result["markers_first_s"].setdefault(name, now)
|
||||
result["markers_last_s"][name] = now
|
||||
|
||||
if window_s is None and window_supported:
|
||||
windows = visible_windows(tree.pids())
|
||||
if windows is None:
|
||||
window_supported = False
|
||||
elif any(app.window_title.search(title) for _, title in windows):
|
||||
window_s = now
|
||||
|
||||
if not tree.alive():
|
||||
raise SystemExit(
|
||||
f"{app.name} exited during startup (code {proc.poll()}); an instance started elsewhere may have "
|
||||
f"taken the launch. Output: {stdout_path}"
|
||||
)
|
||||
|
||||
waited = (window_s is not None or not window_supported) and all(
|
||||
m in result["markers_first_s"] for m in app.ready_after
|
||||
)
|
||||
span = now - history[0][0]
|
||||
if waited and span >= args.quiet_secs * 0.95:
|
||||
busy = (cpu - history[0][1]) / span
|
||||
if busy < args.quiet_cores:
|
||||
# Never before what it waited for (the quiet span can start earlier).
|
||||
ready_s = max(now - span, window_s or 0, *(result["markers_first_s"][m] for m in app.ready_after))
|
||||
break
|
||||
if now > args.timeout:
|
||||
result["timed_out"] = True
|
||||
break
|
||||
time.sleep(POLL_S)
|
||||
|
||||
result["window_s"] = window_s
|
||||
result["ready_s"] = ready_s
|
||||
result["peak_rss_mb"] = peak_rss / MB
|
||||
|
||||
# Idle: what it costs to just sit there with the project open.
|
||||
idle_cpu0, _ = tree.sample()
|
||||
i0 = time.perf_counter()
|
||||
while time.perf_counter() - i0 < args.idle_secs:
|
||||
time.sleep(POLL_S * 5)
|
||||
_, rss = tree.sample()
|
||||
peak_rss = max(peak_rss, rss)
|
||||
idle_cpu1, _ = tree.sample()
|
||||
result["idle_cpu_pct"] = 100 * (idle_cpu1 - idle_cpu0) / (time.perf_counter() - i0)
|
||||
result["peak_rss_mb"] = peak_rss / MB
|
||||
result.update(tree.memory())
|
||||
result["total_cpu_s"] = idle_cpu0
|
||||
|
||||
exit_s, killed = close(app, tree, proc, args.close_timeout)
|
||||
result["exit_s"] = exit_s
|
||||
if killed:
|
||||
result["killed_on_exit"] = killed
|
||||
result["cache_mb"] = sum(dir_size(p) for p in app.cache_paths()) / MB
|
||||
return result
|
||||
|
||||
|
||||
# ── report ──────────────────────────────────────────────────────────────────
|
||||
|
||||
METRICS = [
|
||||
("window_s", "Window with project title (s)"),
|
||||
("ready_s", "Ready: CPU settled (s)"),
|
||||
("peak_rss_mb", "Peak RSS, startup + idle (MB)"),
|
||||
("rss_mb", "RSS after idle (MB)"),
|
||||
("uss_mb", "USS after idle (MB)"),
|
||||
("pss_mb", "PSS after idle (MB)"),
|
||||
("procs", "Processes"),
|
||||
("total_cpu_s", "CPU used until ready (s)"),
|
||||
("idle_cpu_pct", "Idle CPU (% of a core)"),
|
||||
("exit_s", "Exit (s)"),
|
||||
("cache_mb", "Cache dirs on disk (MB)"),
|
||||
]
|
||||
|
||||
|
||||
def fmt(value: float | None) -> str:
|
||||
return "–" if value is None else f"{value:.2f}"
|
||||
|
||||
|
||||
def summarize(values: list[float]) -> str:
|
||||
if not values:
|
||||
return "–"
|
||||
med = statistics.median(values)
|
||||
if len(values) == 1:
|
||||
return f"{med:.2f}"
|
||||
return f"{med:.2f} ({min(values):.2f}–{max(values):.2f})"
|
||||
|
||||
|
||||
def report(apps: list[App], runs: list[dict], meta: dict) -> str:
|
||||
def column(app: App, getter) -> str:
|
||||
vals = [v for r in runs if r["app"] == app.key and (v := getter(r)) is not None]
|
||||
return summarize(vals)
|
||||
|
||||
rows = [(title, lambda r, k=key: r.get(k)) for key, title in METRICS]
|
||||
for app in apps:
|
||||
for m in app.markers:
|
||||
rows.append((f"{app.name}: log `{m}` first (s)", lambda r, m=m: r["markers_first_s"].get(m)))
|
||||
rows.append((f"{app.name}: log `{m}` last (s)", lambda r, m=m: r["markers_last_s"].get(m)))
|
||||
|
||||
lines = [
|
||||
f"# IDE benchmark: {meta['project']}",
|
||||
"",
|
||||
f"{meta['os']} · {meta['cpu']} · {meta['cores']} cores · {meta['ram_gb']:.0f} GB RAM · "
|
||||
f"mode **{meta['mode']}** · {meta['runs']} runs · {meta['date']}",
|
||||
"",
|
||||
"Median (min–max). Times are seconds from launch.",
|
||||
"",
|
||||
"| Metric | " + " | ".join(a.name for a in apps) + " |",
|
||||
"|---|" + "---|" * len(apps),
|
||||
]
|
||||
for title, getter in rows:
|
||||
cells = [column(a, getter) for a in apps]
|
||||
if any(c != "–" for c in cells):
|
||||
lines.append(f"| {title} | " + " | ".join(cells) + " |")
|
||||
for app in apps:
|
||||
if app.install_dir and Path(app.install_dir).exists():
|
||||
lines.append(f"\n{app.name} install size: {dir_size(Path(app.install_dir)) / MB:.0f} MB")
|
||||
timeouts = [r["label"] for r in runs if r.get("timed_out")]
|
||||
if timeouts:
|
||||
lines.append(f"\n⚠ CPU never settled within the timeout: {', '.join(timeouts)}")
|
||||
killed = [f"{r['label']}: {', '.join(r['killed_on_exit'])}" for r in runs if r.get("killed_on_exit")]
|
||||
if killed:
|
||||
lines.append("\n⚠ Killed after the exit timeout: " + "; ".join(killed))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ── main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("project", type=Path, help="project directory every app opens")
|
||||
ap.add_argument("--config", type=Path, default=HERE / "apps.toml")
|
||||
ap.add_argument("--apps", help="comma-separated keys from the config (default: all)")
|
||||
ap.add_argument("--runs", type=int, default=5, help="measured runs per app (default 5)")
|
||||
ap.add_argument("--warmup", type=int, default=1, help="discarded runs per app first (default 1)")
|
||||
ap.add_argument("--wipe-caches", action="store_true", help="delete each app's cache_dirs before every run (cold index)")
|
||||
ap.add_argument("--drop-os-cache", metavar="CMD", help="shell command run before every run to drop the OS file cache")
|
||||
ap.add_argument("--quiet-secs", type=float, default=5, help="CPU must stay low this long to count as ready")
|
||||
ap.add_argument("--quiet-cores", type=float, default=0.1, help="'low' = below this many cores busy (default 0.1)")
|
||||
ap.add_argument("--idle-secs", type=float, default=30, help="idle period measured after ready")
|
||||
ap.add_argument("--timeout", type=float, default=900, help="give up waiting for ready after this many seconds")
|
||||
ap.add_argument("--close-timeout", type=float, default=60)
|
||||
ap.add_argument("--cooldown", type=float, default=5, help="pause before each launch")
|
||||
ap.add_argument("--out", type=Path, default=HERE / "results")
|
||||
ap.add_argument("--yes", action="store_true", help="don't ask before wiping caches")
|
||||
args = ap.parse_args()
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
project = args.project.resolve()
|
||||
if not project.is_dir():
|
||||
raise SystemExit(f"not a directory: {project}")
|
||||
config = tomllib.loads(args.config.read_text(encoding="utf-8"))
|
||||
keys = args.apps.split(",") if args.apps else list(config["apps"])
|
||||
apps = [App(k, config["apps"][k], project) for k in keys]
|
||||
|
||||
if args.wipe_caches:
|
||||
targets = [p for a in apps for p in a.cache_paths()]
|
||||
for p in targets:
|
||||
if len(p.resolve().parts) < 4 or p.resolve() == Path.home():
|
||||
raise SystemExit(f"refusing to wipe a top-level path: {p}")
|
||||
print("Before every run these will be deleted:\n " + "\n ".join(map(str, targets)))
|
||||
if targets and not args.yes and input("Continue? [y/N] ").strip().lower() != "y":
|
||||
return
|
||||
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = args.out / f"{stamp}-{project.name}"
|
||||
out_dir.mkdir(parents=True)
|
||||
mode = "cold index" if args.wipe_caches else "warm"
|
||||
if args.drop_os_cache:
|
||||
mode += " + cold OS cache"
|
||||
meta = {
|
||||
"project": str(project),
|
||||
"os": f"{platform.system()} {platform.release()}",
|
||||
"cpu": platform.processor() or platform.machine(),
|
||||
"cores": psutil.cpu_count(logical=True),
|
||||
"ram_gb": psutil.virtual_memory().total / 1024**3,
|
||||
"mode": mode,
|
||||
"runs": args.runs,
|
||||
"date": stamp,
|
||||
"commands": {a.key: a.command for a in apps},
|
||||
"args": {k: str(v) for k, v in vars(args).items()},
|
||||
}
|
||||
|
||||
runs: list[dict] = []
|
||||
total = args.warmup + args.runs
|
||||
for i in range(total):
|
||||
# Rotate the order so neither app always runs right after the other.
|
||||
order = apps[i % len(apps) :] + apps[: i % len(apps)]
|
||||
for app in order:
|
||||
warm = i < args.warmup
|
||||
label = f"{app.key}-{'warmup' if warm else 'run'}{i + 1 if warm else i - args.warmup + 1}"
|
||||
print(f"[{label}] launching…", flush=True)
|
||||
r = run_once(app, args, out_dir, label)
|
||||
print(
|
||||
f"[{label}] window {fmt(r['window_s'])} s · ready {fmt(r['ready_s'])} s · "
|
||||
f"USS {r['uss_mb']:.0f} MB · idle CPU {r['idle_cpu_pct']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
if not warm:
|
||||
runs.append(r)
|
||||
(out_dir / "results.json").write_text(json.dumps({"meta": meta, "runs": runs}, indent=2), encoding="utf-8")
|
||||
|
||||
text = report(apps, runs, meta)
|
||||
(out_dir / "report.md").write_text(text, encoding="utf-8")
|
||||
print("\n" + text)
|
||||
print(f"Raw results: {out_dir / 'results.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
psutil>=6
|
||||
pyobjc-framework-Quartz>=10; sys_platform == "darwin"
|
||||
Reference in new issue
Заблокувати користувача