Files
MoviePilot/scripts/startup/performance.py
T

264 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""记录 MoviePilot 关键入口的冷导入耗时基线。"""
import argparse
import json
import os
import platform
import statistics
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUTPUT = (
PROJECT_ROOT
/ "tests"
/ "fixtures"
/ "architecture"
/ "startup-performance-baseline.json"
)
IMPORT_TARGETS = (
"app.startup.lifecycle",
"app.factory",
"app.main",
)
RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE="
LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE="
def measure_import(target: str) -> dict[str, Any]:
"""在独立解释器中测量单个模块的冷导入耗时与模块增量。"""
code = f"""
import importlib
import json
import sys
import time
before = set(sys.modules)
started_at = time.perf_counter()
importlib.import_module({target!r})
elapsed_ms = (time.perf_counter() - started_at) * 1000
print({RESULT_PREFIX!r} + json.dumps({{
'elapsed_ms': elapsed_ms,
'loaded_module_count': len(set(sys.modules) - before),
}}))
"""
environment = os.environ.copy()
environment["PYTHONHASHSEED"] = "0"
result = subprocess.run(
[sys.executable, "-c", code],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"冷导入 {target} 失败:{result.stderr.strip() or result.stdout.strip()}"
)
payload_line = next(
(
line
for line in reversed(result.stdout.splitlines())
if line.startswith(RESULT_PREFIX)
),
None,
)
if payload_line is None:
raise RuntimeError(f"冷导入 {target} 未输出测量结果")
return json.loads(payload_line.removeprefix(RESULT_PREFIX))
def measure_lifecycle(safe_mode: bool) -> dict[str, Any]:
"""在隔离的无 I/O 生命周期中测量正常/安全模式编排和资源增量。
这里故意把每个组件回调替换为 no-op:基线用于比较生命周期编排、阶段计时、任务
和线程是否泄漏,不应在生成基线时启动真实插件、调度器或连接用户数据库。
"""
code = f"""
import asyncio
import dataclasses
import json
import threading
import time
from fastapi import FastAPI
from app.testing.bootstrap import ensure_sites_stub
ensure_sites_stub()
from app.startup import lifecycle
def _noop():
return None
async def _async_noop():
return None
async def _probe():
lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r}
lifecycle.init_extra = _async_noop
lifecycle.global_vars.set_loop = lambda loop: None
lifecycle.global_vars.stop_system = lambda: None
lifecycle.LoggerManager.shutdown = lambda: None
original_components = lifecycle.build_lifecycle_components(FastAPI())
isolated_components = tuple(
dataclasses.replace(
component,
start=_noop if component.start is not None else None,
stop=_noop if component.stop is not None else None,
)
for component in original_components
)
lifecycle.build_lifecycle_components = lambda _app: isolated_components
stage_ms = {{}}
original_step = lifecycle.run_startup_step
async def timed_step(name, callback, timeout_seconds=None):
started = time.perf_counter()
result = await original_step(name, callback, timeout_seconds)
stage_ms[name] = round((time.perf_counter() - started) * 1000, 3)
return result
lifecycle.run_startup_step = timed_step
before_threads = threading.active_count()
before_tasks = len(asyncio.all_tasks())
started = time.perf_counter()
async with lifecycle.lifespan(FastAPI()):
startup_ms = (time.perf_counter() - started) * 1000
started_threads = threading.active_count()
started_tasks = len(asyncio.all_tasks())
finished_ms = (time.perf_counter() - started) * 1000
print({LIFECYCLE_RESULT_PREFIX!r} + json.dumps({{
'mode': 'safe' if {safe_mode!r} else 'normal',
'enabled_component_count': len([
component for component in isolated_components
if component.enabled({safe_mode!r})
]),
'startup_ms': round(startup_ms, 3),
'full_lifespan_ms': round(finished_ms, 3),
'stage_ms': stage_ms,
'threads_before': before_threads,
'threads_started': started_threads,
'threads_after': threading.active_count(),
'tasks_before': before_tasks,
'tasks_started': started_tasks,
'tasks_after': len(asyncio.all_tasks()),
# no-op 采样不建立数据库连接;字段显式记录采样范围,避免误读为生产连接数。
'database_connections_started': 0,
}}))
asyncio.run(_probe())
"""
result = subprocess.run(
[sys.executable, "-c", code],
cwd=PROJECT_ROOT,
env={**os.environ, "PYTHONHASHSEED": "0"},
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"{('安全' if safe_mode else '正常')}模式生命周期采样失败:"
f"{result.stderr.strip() or result.stdout.strip()}"
)
payload_line = next(
(
line
for line in reversed(result.stdout.splitlines())
if line.startswith(LIFECYCLE_RESULT_PREFIX)
),
None,
)
if payload_line is None:
raise RuntimeError("生命周期采样未输出测量结果")
return json.loads(payload_line.removeprefix(LIFECYCLE_RESULT_PREFIX))
def collect_baseline(repeat: int) -> dict[str, Any]:
"""按目标重复采样并生成便于后续对比的统计摘要。"""
targets: dict[str, Any] = {}
for target in IMPORT_TARGETS:
samples = [measure_import(target) for _ in range(repeat)]
elapsed = [sample["elapsed_ms"] for sample in samples]
targets[target] = {
"loaded_module_count": int(
statistics.median(
sample["loaded_module_count"] for sample in samples
)
),
"max_ms": round(max(elapsed), 3),
"median_ms": round(statistics.median(elapsed), 3),
"min_ms": round(min(elapsed), 3),
"samples_ms": [round(value, 3) for value in elapsed],
}
lifecycle_modes: dict[str, Any] = {}
for safe_mode, mode_name in ((False, "normal"), (True, "safe")):
samples = [measure_lifecycle(safe_mode) for _ in range(repeat)]
lifecycle_modes[mode_name] = {
"samples": samples,
"median_startup_ms": round(
statistics.median(sample["startup_ms"] for sample in samples),
3,
),
"median_full_lifespan_ms": round(
statistics.median(sample["full_lifespan_ms"] for sample in samples),
3,
),
"enabled_component_count": samples[0]["enabled_component_count"],
}
return {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"platform": platform.platform(),
"python": platform.python_version(),
"repeat": repeat,
"targets": targets,
"lifecycle": {
"scope": "isolated no-op component callbacks; no plugin/network/database I/O",
"modes": lifecycle_modes,
},
}
def parse_args() -> argparse.Namespace:
"""解析输出路径和采样次数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repeat", type=int, default=3)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser.parse_args()
def main() -> int:
"""执行冷导入采样并写入 JSON 基线。"""
args = parse_args()
if args.repeat < 1:
raise SystemExit("--repeat 必须大于等于 1")
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(collect_baseline(args.repeat), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
try:
display_path = output.relative_to(PROJECT_ROOT)
except ValueError:
display_path = output
print(f"已写入 {display_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())