Files

136 lines
6.9 KiB
Python

"""Reproducible isolated benchmark; never writes to registered user sources.
python scripts/benchmark_gallery.py --output artifacts/foundation-baseline.json
python scripts/benchmark_gallery.py --output artifacts/foundation-after.json --compare artifacts/foundation-baseline.json
Synthetic index timings are NOT measurements of a real file traversal.
"""
from __future__ import annotations
import argparse
import gc
import importlib.util
import json
import platform
import statistics
import sys
import tempfile
import time
from threading import Event
from contextlib import ExitStack
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def measure(function, samples=30):
for _ in range(5):
function()
values = []
for _ in range(samples):
start = time.perf_counter()
function()
values.append((time.perf_counter() - start) * 1000)
return {"p50_ms": statistics.median(values), "p95_ms": sorted(values)[int(.95 * (samples - 1))]}
def main():
import pytest
from PIL import Image
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--compare", type=Path)
args = parser.parse_args()
spec = importlib.util.spec_from_file_location("benchmark_fixtures", ROOT / "tests/conftest.py")
fixtures = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fixtures)
report = {"python": sys.version, "platform": platform.platform(), "real_sources": "not measured", "samples": 30, "warmups": 5, "datasets": {}}
with tempfile.TemporaryDirectory(prefix="ue-benchmark-") as temporary, pytest.MonkeyPatch.context() as patch, ExitStack() as cleanup:
fixture = fixtures.isolated_gallery_env.__wrapped__(Path(temporary), patch)
env = next(fixture) if hasattr(fixture, "__next__") else fixture
service = env.service
def close_environment():
runtime = getattr(service, "_runtime", None)
if runtime:
runtime.close()
else:
for executor in (service.COLOR_INDEX_EXECUTOR, service.DIMENSION_BACKFILL_EXECUTOR, service.THUMB_PREWARM_EXECUTOR):
executor.shutdown(wait=True)
if hasattr(fixture, "__next__"):
next(fixture, None)
gc.collect()
cleanup.callback(close_environment)
for number in range(20):
Image.new("RGB", (64, 48), (number, 90, 120)).save(env.output_dir / f"sample_{number}.png")
start = time.perf_counter()
service.list_images_page()
report["cold_real_sample_ms"] = (time.perf_counter() - start) * 1000
report["freshness_real_20_images"] = measure(lambda: service.get_image_freshness())
scan_sources = service.list_gallery_sources()
report["scan_real_20_images"] = measure(lambda: service._scan_index_records_for_scope(scan_sources))
report["batch_real_20_images"] = measure(lambda: service.batch_update_images([f"sample_{n}.png" for n in range(20)], {"notes": str(time.perf_counter())}))
runtime = getattr(service, "_runtime", None)
if runtime and runtime.jobs:
for job in runtime.jobs.list(active_only=True)["jobs"]:
runtime.jobs.future(job["id"]).result(timeout=30)
def snapshot(function):
if not hasattr(service, "get_runtime"):
return function()
from py.gallery.execution import QUERY_ONLY, READ_CONNECTIONS
from py.gallery.environment import SOURCE_SNAPSHOT
source_token = SOURCE_SNAPSHOT.set(service.get_runtime().source_snapshot())
token = QUERY_ONLY.set(True)
connections = {}
read_token = READ_CONNECTIONS.set(connections)
try:
return function()
finally:
for connection in connections.values():
connection.close()
READ_CONNECTIONS.reset(read_token)
QUERY_ONLY.reset(token)
SOURCE_SNAPSHOT.reset(source_token)
# Synthetic rows benchmark SQL/serialization, not absent files/decoding.
patch.setattr(service, "_schedule_dimension_backfill_if_needed", lambda *a, **k: None)
patch.setattr(service, "_schedule_color_index_backfill", lambda *a, **k: None)
for count in (10_000, 50_000):
with service._connect_gallery_index_db() as db:
db.executemany("INSERT OR IGNORE INTO gallery_images(relative_path,source_id,source_relative_path,filename,relative_dir,subfolder,display_subfolder,size,image_width,image_height,created_at,modified_at,scanned_at) VALUES(?,?,?,?, '', '', '', 100,64,48,?,?,1)",
[(f"synthetic_{n}.png", "default_output", f"synthetic_{n}.png", f"synthetic_{n}.png", n, n) for n in range(count)])
db.commit()
# Prepare derived helpers before taking hot-query measurements.
service.list_images_page()
report["datasets"][str(count)] = {"list": measure(lambda: snapshot(service.list_images_page)), "context": measure(lambda: snapshot(service.get_gallery_context))}
report["datasets"][str(count)]["filtered_page"] = measure(lambda: snapshot(lambda: service.list_images_page(
page=10, limit=48, subfolder="default_output::", sort_by="filename")))
runtime = getattr(service, "_runtime", None)
if runtime:
started, release = Event(), Event()
def blocked_analysis(context, payload):
started.set()
release.wait(30)
return {"ok": True}
jobs = runtime.get_jobs()
jobs.handlers["fingerprint_assets"] = ("analysis", blocked_analysis)
job = jobs.enqueue("fingerprint_assets", {"_benchmark": True})
try:
assert started.wait(5), "analysis task did not start"
report["blocked_analysis_50000"] = measure(lambda: snapshot(service.list_images_page))
assert not release.is_set(), "analysis was released before query samples finished"
assert not jobs.future(job["id"]).done(), "analysis finished before query samples completed"
finally:
release.set()
jobs.future(job["id"]).result(5)
if args.compare:
baseline = json.loads(args.compare.read_text())
report["ratios"] = {f"{size}/{kind}": report["datasets"][size][kind]["p95_ms"] / max(.001, baseline["datasets"][size][kind]["p95_ms"]) for size in report["datasets"] for kind in ("list", "context")}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
if any(ratio > 1.2 for ratio in report.get("ratios", {}).values()):
raise SystemExit("Performance gate failed: hot-query P95 exceeds 120% of the fixed baseline")
if __name__ == "__main__":
main()