Files
MoviePilot/scripts/startup/performance.py
T

364 lines
12 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, Optional
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="
PERFORMANCE_FACTOR = 2.0
PERFORMANCE_SLACK_MS = 500.0
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 check_baseline(
expected: dict[str, Any],
actual: dict[str, Any],
) -> list[str]:
"""比较稳定资源契约与宽松耗时预算,返回所有不符合项。"""
errors: list[str] = []
expected_targets = expected.get("targets", {})
actual_targets = actual.get("targets", {})
if set(expected_targets) != set(actual_targets):
errors.append("冷导入目标集合已变化")
for target in sorted(set(expected_targets) & set(actual_targets)):
expected_target = expected_targets[target]
actual_target = actual_targets[target]
if (
actual_target["loaded_module_count"]
!= expected_target["loaded_module_count"]
):
errors.append(
f"{target} 加载模块数变化:"
f"{expected_target['loaded_module_count']} -> "
f"{actual_target['loaded_module_count']}"
)
budget_ms = max(
expected_target["max_ms"] * PERFORMANCE_FACTOR,
expected_target["max_ms"] + PERFORMANCE_SLACK_MS,
)
if actual_target["median_ms"] > budget_ms:
errors.append(
f"{target} 冷导入中位数 {actual_target['median_ms']}ms "
f"超过预算 {round(budget_ms, 3)}ms"
)
expected_modes = expected.get("lifecycle", {}).get("modes", {})
actual_modes = actual.get("lifecycle", {}).get("modes", {})
if set(expected_modes) != set(actual_modes):
errors.append("生命周期模式集合已变化")
for mode_name in sorted(set(expected_modes) & set(actual_modes)):
expected_mode = expected_modes[mode_name]
actual_mode = actual_modes[mode_name]
if (
actual_mode["enabled_component_count"]
!= expected_mode["enabled_component_count"]
):
errors.append(
f"{mode_name} 模式组件数变化:"
f"{expected_mode['enabled_component_count']} -> "
f"{actual_mode['enabled_component_count']}"
)
for sample in actual_mode.get("samples", []):
if sample["threads_after"] != sample["threads_before"]:
errors.append(f"{mode_name} 模式存在未释放线程")
if sample["tasks_after"] != sample["tasks_before"]:
errors.append(f"{mode_name} 模式存在未释放异步任务")
if sample["database_connections_started"] != 0:
errors.append(f"{mode_name} 模式隔离采样建立了数据库连接")
return errors
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
"""解析只读打印、检查或显式写入操作。"""
parser = argparse.ArgumentParser(description=__doc__)
action = parser.add_mutually_exclusive_group()
action.add_argument(
"--print",
dest="operation",
action="store_const",
const="print",
help="打印本次采样且不写文件(默认)",
)
action.add_argument(
"--check",
dest="operation",
action="store_const",
const="check",
help="按已提交基线检查资源契约和宽松性能预算",
)
action.add_argument(
"--write",
dest="operation",
action="store_const",
const="write",
help="显式写入采样基线",
)
parser.add_argument("--repeat", type=int, default=3)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.set_defaults(operation="print")
return parser.parse_args(argv)
def _display_path(path: Path) -> Path:
try:
return path.relative_to(PROJECT_ROOT)
except ValueError:
return path
def main(argv: Optional[list[str]] = None) -> int:
"""采样关键入口,并按显式操作打印、检查或写入结果。"""
args = parse_args(argv)
if args.repeat < 1:
raise SystemExit("--repeat 必须大于等于 1")
baseline = collect_baseline(args.repeat)
output = args.output.resolve()
if args.operation == "print":
print(json.dumps(baseline, ensure_ascii=False, indent=2))
return 0
if args.operation == "check":
if not output.is_file():
raise SystemExit(f"性能基线不存在:{output}")
expected = json.loads(output.read_text(encoding="utf-8"))
errors = check_baseline(expected, baseline)
if errors:
for error in errors:
print(f"性能基线检查失败:{error}", file=sys.stderr)
return 1
print(f"性能基线检查通过:{_display_path(output)}")
return 0
print(f"即将写入:{_display_path(output)}")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(baseline, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"已写入:{_display_path(output)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())