- Add a --full benchmark mode that selects the UI's 987-model default set (lora/checkpoint/embedding) and copies the complete sidecar tree, plus a longer poll timeout for full-library runs. - Record the full run: 894.7 s versus the 1,164 s baseline (-23.1%), 7,404 files / 995.3 MB, 46 failed models, zero 429s, two probe steps. - Record the 80-model / 729-image new-only comparison: pipeline -28.5% and AIMD -49.4% versus new-serial, zero 429s. Document that the old serial medium run was aborted after stalling on stale metadata at 13/80 models.
579 lines
20 KiB
Python
579 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Reproducible A/B benchmark for example-image downloads.
|
|
|
|
The benchmark keeps real model files read-only and runs every candidate in a
|
|
pinned settings directory whose cache, sidecars and example folder are copies
|
|
under ``--workdir``. It selects one model subset from the user's cache/sidecars,
|
|
then runs the same subset against:
|
|
|
|
* ``serial``: the pre-pipeline tree (or any tree), model concurrency forced to 1
|
|
* ``pipeline``: the current tree, model concurrency 2, AIMD ceiling = base 6
|
|
* ``aimd``: the current tree, model concurrency 2, AIMD ceiling 8
|
|
|
|
Each run starts from an empty examples directory and a fresh cache/sidecar
|
|
copy. The script never writes to the real settings directory or model library.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_WORKDIR = REPO_ROOT / ".tmp" / "example-images-bench"
|
|
REAL_CONFIG = Path.home() / ".config" / "ComfyUI-LoRA-Manager"
|
|
MODES = {
|
|
# Old tree, historical serial baseline.
|
|
"serial": {"model_concurrency": 1, "probe_ceiling": 6},
|
|
# New tree with the model pipeline disabled: isolates the pipeline gain
|
|
# from the earlier per-model media-pool gain.
|
|
"new-serial": {"model_concurrency": 1, "probe_ceiling": 6},
|
|
"pipeline": {"model_concurrency": 2, "probe_ceiling": 6},
|
|
"aimd": {"model_concurrency": 2, "probe_ceiling": 8},
|
|
}
|
|
|
|
|
|
def _log(message: str) -> None:
|
|
print(message, flush=True)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
handle.write("\n")
|
|
|
|
|
|
def _http_json(url: str, payload: dict[str, Any] | None = None, timeout: float = 30.0):
|
|
data = None
|
|
headers = {}
|
|
method = "GET"
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
method = "POST"
|
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def _wait_for_server(base_url: str, proc: subprocess.Popen, timeout: float = 120.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
last_error: Exception | None = None
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
raise RuntimeError(f"server exited with code {proc.returncode}")
|
|
try:
|
|
_http_json(base_url + "/api/lm/example-images-status", timeout=3.0)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 - startup polling
|
|
last_error = exc
|
|
time.sleep(0.5)
|
|
raise TimeoutError(f"server did not become ready: {last_error}")
|
|
|
|
|
|
def _index_sidecars() -> tuple[Path, dict[str, Path]]:
|
|
"""Index real centralized sidecars by sha256 without touching settings.
|
|
|
|
Sidecar names mirror model names, but the authoritative link is the
|
|
``sha256`` inside the JSON. Building the map once is cheaper than resolving
|
|
each path through the app (which also may initialize settings).
|
|
"""
|
|
|
|
real_root = REAL_CONFIG / "sidecars"
|
|
if not real_root.is_dir():
|
|
raise FileNotFoundError(f"real sidecar root missing: {real_root}")
|
|
index: dict[str, Path] = {}
|
|
for path in real_root.rglob("*.metadata.json"):
|
|
try:
|
|
payload = _read_json(path)
|
|
except (OSError, ValueError):
|
|
continue
|
|
sha = str(payload.get("sha256") or "").lower()
|
|
if len(sha) == 64:
|
|
index.setdefault(sha, path)
|
|
_log(f"indexed {len(index)} sidecars under {real_root}")
|
|
return real_root, index
|
|
|
|
|
|
def _copy_model_cache(source_cache: Path, dest_cache: Path) -> None:
|
|
shutil.copytree(source_cache, dest_cache, dirs_exist_ok=True)
|
|
|
|
|
|
def prepare(args: argparse.Namespace) -> None:
|
|
workdir = Path(args.workdir).resolve()
|
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
cache_source = REAL_CONFIG / "cache"
|
|
cache_dest = workdir / "cache"
|
|
if cache_dest.exists():
|
|
shutil.rmtree(cache_dest)
|
|
_log(f"copying cache -> {cache_dest}")
|
|
_copy_model_cache(cache_source, cache_dest)
|
|
|
|
db_path = cache_dest / "model" / "comfyui.sqlite"
|
|
if args.full:
|
|
# Full production-shaped run: all example-capable model types, no
|
|
# sidecar/image filters, and a complete sidecar copy. The download
|
|
# manager's explicit target path then processes the same 987 models
|
|
# as the normal UI run.
|
|
connection = sqlite3.connect(str(db_path))
|
|
connection.row_factory = sqlite3.Row
|
|
rows = connection.execute(
|
|
"select model_type, file_path, file_name, model_name, sha256 "
|
|
"from models where model_type in ('lora','checkpoint','embedding')"
|
|
).fetchall()
|
|
connection.close()
|
|
selected = []
|
|
for row in rows:
|
|
sha = (row["sha256"] or "").lower()
|
|
if len(sha) != 64:
|
|
continue
|
|
selected.append(
|
|
{
|
|
"sha256": sha,
|
|
"model_name": row["model_name"],
|
|
"model_type": row["model_type"],
|
|
"file_path": row["file_path"],
|
|
"sidecar": "",
|
|
"image_count": 0,
|
|
}
|
|
)
|
|
if not selected:
|
|
raise RuntimeError("no models found for full benchmark")
|
|
sidecar_dest = workdir / "sidecars"
|
|
if sidecar_dest.exists():
|
|
shutil.rmtree(sidecar_dest)
|
|
_log(f"copying full sidecars -> {sidecar_dest}")
|
|
shutil.copytree(REAL_CONFIG / "sidecars", sidecar_dest)
|
|
selection = {
|
|
"created_at": time.time(),
|
|
"models": selected,
|
|
"model_count": len(selected),
|
|
"total_images": 0,
|
|
"full": True,
|
|
}
|
|
_write_json(workdir / "selection.json", selection)
|
|
_log(f"selected {len(selected)} models for full run -> {workdir / 'selection.json'}")
|
|
return
|
|
|
|
real_sidecar_root, sidecar_by_hash = _index_sidecars()
|
|
if not db_path.is_file():
|
|
raise FileNotFoundError(f"model cache missing: {db_path}")
|
|
|
|
connection = sqlite3.connect(str(db_path))
|
|
connection.row_factory = sqlite3.Row
|
|
rows = connection.execute(
|
|
"select model_type, file_path, file_name, model_name, sha256, from_civitai, civitai_deleted from models"
|
|
).fetchall()
|
|
connection.close()
|
|
|
|
selected: list[dict[str, Any]] = []
|
|
total_images = 0
|
|
for row in rows:
|
|
if len(selected) >= args.count or total_images >= args.images:
|
|
break
|
|
if row["model_type"] not in {"lora", "checkpoint"}:
|
|
continue
|
|
if not row["from_civitai"] or row["civitai_deleted"]:
|
|
continue
|
|
sha = (row["sha256"] or "").lower()
|
|
if len(sha) != 64:
|
|
continue
|
|
file_path = Path(row["file_path"] or "")
|
|
if not file_path.is_file():
|
|
continue
|
|
metadata_path = sidecar_by_hash.get(sha)
|
|
if metadata_path is None or not metadata_path.is_file():
|
|
continue
|
|
try:
|
|
payload = _read_json(metadata_path)
|
|
except (OSError, ValueError):
|
|
continue
|
|
civitai = payload.get("civitai") or {}
|
|
images = civitai.get("images") or []
|
|
urls = [
|
|
image.get("url")
|
|
for image in images
|
|
if isinstance(image, dict)
|
|
and image.get("url")
|
|
and image.get("type") != "video"
|
|
]
|
|
if not (args.min_images <= len(urls) <= args.max_images):
|
|
continue
|
|
if not all("image.civitai.com" in url for url in urls):
|
|
continue
|
|
try:
|
|
relative = metadata_path.relative_to(real_sidecar_root)
|
|
except ValueError:
|
|
continue
|
|
selected.append(
|
|
{
|
|
"sha256": sha,
|
|
"model_name": row["model_name"],
|
|
"model_type": row["model_type"],
|
|
"file_path": str(file_path),
|
|
"sidecar": str(relative),
|
|
"image_count": len(urls),
|
|
}
|
|
)
|
|
total_images += len(urls)
|
|
|
|
if not selected:
|
|
raise RuntimeError("no models matched the benchmark filters")
|
|
|
|
sidecar_dest = workdir / "sidecars"
|
|
if sidecar_dest.exists():
|
|
shutil.rmtree(sidecar_dest)
|
|
for model in selected:
|
|
source = real_sidecar_root / model["sidecar"]
|
|
target = sidecar_dest / model["sidecar"]
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target)
|
|
|
|
selection = {
|
|
"created_at": time.time(),
|
|
"models": selected,
|
|
"model_count": len(selected),
|
|
"total_images": total_images,
|
|
}
|
|
_write_json(workdir / "selection.json", selection)
|
|
_log(
|
|
f"selected {len(selected)} models / {total_images} images "
|
|
f"-> {workdir / 'selection.json'}"
|
|
)
|
|
|
|
|
|
def _build_settings(
|
|
run_dir: Path,
|
|
mode: str,
|
|
examples_path: Path,
|
|
) -> dict[str, Any]:
|
|
source = REAL_CONFIG / "settings.json"
|
|
settings = _read_json(source)
|
|
settings.update(
|
|
{
|
|
"use_portable_settings": False,
|
|
"example_images_path": str(examples_path),
|
|
"sidecar_storage_mode": "centralized",
|
|
"sidecar_storage_path": str(run_dir / "sidecars"),
|
|
"auto_download_example_images": False,
|
|
"optimize_example_images": True,
|
|
"example_images_download_concurrency": 6,
|
|
"example_images_download_max_concurrency": MODES[mode]["probe_ceiling"],
|
|
"example_images_model_concurrency": MODES[mode]["model_concurrency"],
|
|
}
|
|
)
|
|
return settings
|
|
|
|
|
|
def _media_stats(root: Path) -> tuple[int, int]:
|
|
files = 0
|
|
size = 0
|
|
if not root.exists():
|
|
return files, size
|
|
for current, _dirs, names in os.walk(root):
|
|
for name in names:
|
|
if name.startswith(".lm-examples-complete") or name == ".download_progress.json":
|
|
continue
|
|
path = Path(current) / name
|
|
try:
|
|
size += path.stat().st_size
|
|
files += 1
|
|
except OSError:
|
|
pass
|
|
return files, size
|
|
|
|
|
|
def _count_log_events(log_path: Path) -> dict[str, int]:
|
|
text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
|
|
return {
|
|
"rate_limited": text.count("Rate limited (429)"),
|
|
"not_found": text.count("File not found"),
|
|
"errors": text.count("ERROR"),
|
|
"probe_steps": text.count("Probing media concurrency above base"),
|
|
}
|
|
|
|
|
|
def _terminate(proc: subprocess.Popen) -> None:
|
|
if proc.poll() is not None:
|
|
return
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
|
|
|
|
def run_once(
|
|
tree: Path,
|
|
workdir: Path,
|
|
selection: dict[str, Any],
|
|
mode: str,
|
|
port: int,
|
|
) -> dict[str, Any]:
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
run_dir = workdir / "runs" / f"{mode}-{stamp}-{os.getpid()}"
|
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
_log(f"[{mode}] preparing {run_dir}")
|
|
|
|
shutil.copytree(workdir / "cache", run_dir / "cache", dirs_exist_ok=True)
|
|
shutil.copytree(workdir / "sidecars", run_dir / "sidecars", dirs_exist_ok=True)
|
|
|
|
examples_path = workdir / "examples"
|
|
if examples_path.exists():
|
|
shutil.rmtree(examples_path)
|
|
examples_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
settings = _build_settings(run_dir, mode, examples_path)
|
|
_write_json(run_dir / "settings.json", settings)
|
|
|
|
log_path = run_dir / "server.log"
|
|
log_handle = log_path.open("w", encoding="utf-8")
|
|
env = os.environ.copy()
|
|
env["LORA_MANAGER_SETTINGS_DIR"] = str(run_dir)
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
command = [
|
|
sys.executable,
|
|
"standalone.py",
|
|
"--port",
|
|
str(port),
|
|
"--settings-path",
|
|
str(run_dir),
|
|
]
|
|
_log(f"[{mode}] starting {tree} on :{port}")
|
|
proc = subprocess.Popen(
|
|
command,
|
|
cwd=str(tree),
|
|
stdout=log_handle,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
started = time.monotonic()
|
|
try:
|
|
_wait_for_server(base_url, proc)
|
|
ready = time.monotonic()
|
|
model_hashes = [model["sha256"] for model in selection["models"]]
|
|
response = _http_json(
|
|
base_url + "/api/lm/download-example-images",
|
|
{
|
|
"model_types": ["lora", "checkpoint", "embedding"],
|
|
"model_hashes": model_hashes,
|
|
"delay": 0,
|
|
"optimize": True,
|
|
},
|
|
)
|
|
if not response.get("success"):
|
|
raise RuntimeError(f"download rejected: {response}")
|
|
|
|
download_start = time.monotonic()
|
|
deadline = download_start + 2700
|
|
status: dict[str, Any] = {}
|
|
while time.monotonic() < deadline:
|
|
payload = _http_json(base_url + "/api/lm/example-images-status")
|
|
status = payload.get("status") or {}
|
|
state = status.get("status")
|
|
if not payload.get("is_downloading") and state in {
|
|
"completed",
|
|
"stopped",
|
|
"error",
|
|
}:
|
|
break
|
|
time.sleep(0.5)
|
|
else:
|
|
raise TimeoutError("download did not finish within 2700s")
|
|
elapsed = time.monotonic() - download_start
|
|
files, size = _media_stats(examples_path)
|
|
logs = _count_log_events(log_path)
|
|
return {
|
|
"mode": mode,
|
|
"tree": str(tree),
|
|
"commit": subprocess.check_output(
|
|
["git", "-C", str(tree), "rev-parse", "--short", "HEAD"],
|
|
text=True,
|
|
).strip(),
|
|
"dirty": subprocess.run(
|
|
["git", "-C", str(tree), "diff", "--quiet"], check=False
|
|
).returncode
|
|
!= 0,
|
|
"model_count": selection["model_count"],
|
|
"total_images": selection["total_images"],
|
|
"elapsed_seconds": round(elapsed, 3),
|
|
"startup_seconds": round(ready - started, 3),
|
|
"status": status.get("status"),
|
|
"total": status.get("total"),
|
|
"completed": status.get("completed"),
|
|
"failed_count": len(status.get("failed_models") or []),
|
|
"files": files,
|
|
"bytes": size,
|
|
**logs,
|
|
"run_dir": str(run_dir),
|
|
}
|
|
finally:
|
|
_terminate(proc)
|
|
log_handle.close()
|
|
|
|
|
|
def _median(values: list[float]) -> float:
|
|
ordered = sorted(values)
|
|
middle = len(ordered) // 2
|
|
if len(ordered) % 2:
|
|
return ordered[middle]
|
|
return (ordered[middle - 1] + ordered[middle]) / 2
|
|
|
|
|
|
def compare(args: argparse.Namespace) -> None:
|
|
workdir = Path(args.workdir).resolve()
|
|
selection_path = workdir / "selection.json"
|
|
if not selection_path.is_file():
|
|
prepare(args)
|
|
selection = _read_json(selection_path)
|
|
|
|
old_tree = Path(args.old_tree).resolve()
|
|
new_tree = Path(args.new_tree).resolve()
|
|
if not (old_tree / "standalone.py").is_file():
|
|
_log(f"creating old worktree at {old_tree} from {args.old_ref}")
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(REPO_ROOT),
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
str(old_tree),
|
|
args.old_ref,
|
|
],
|
|
check=True,
|
|
)
|
|
if not (old_tree / "standalone.py").is_file():
|
|
raise FileNotFoundError(f"old tree missing standalone.py: {old_tree}")
|
|
if not (new_tree / "standalone.py").is_file():
|
|
raise FileNotFoundError(f"new tree missing standalone.py: {new_tree}")
|
|
|
|
plans = [
|
|
("serial", old_tree),
|
|
("new-serial", new_tree),
|
|
("pipeline", new_tree),
|
|
("aimd", new_tree),
|
|
]
|
|
results: list[dict[str, Any]] = []
|
|
port = args.port
|
|
for repeat in range(args.repeats):
|
|
order = plans if repeat % 2 == 0 else list(reversed(plans))
|
|
for mode, tree in order:
|
|
_log(f"=== repeat {repeat + 1}/{args.repeats} mode={mode} ===")
|
|
result = run_once(tree, workdir, selection, mode, port)
|
|
results.append(result)
|
|
port += 1
|
|
_log(
|
|
f"[{mode}] {result['elapsed_seconds']:.1f}s "
|
|
f"{result['files']} files {result['bytes'] / 1_000_000:.1f} MB "
|
|
f"completed={result['completed']}/{result['total']} "
|
|
f"429={result['rate_limited']}"
|
|
)
|
|
|
|
summary: dict[str, Any] = {"selection": selection, "results": results, "modes": {}}
|
|
for mode in MODES:
|
|
rows = [row for row in results if row["mode"] == mode]
|
|
if not rows:
|
|
continue
|
|
elapsed = _median([row["elapsed_seconds"] for row in rows])
|
|
files = _median([float(row["files"]) for row in rows])
|
|
size = _median([float(row["bytes"]) for row in rows])
|
|
summary["modes"][mode] = {
|
|
"runs": len(rows),
|
|
"median_elapsed_seconds": round(elapsed, 3),
|
|
"median_files": int(files),
|
|
"median_bytes": int(size),
|
|
"rate_limited": sum(row["rate_limited"] for row in rows),
|
|
"not_found": sum(row["not_found"] for row in rows),
|
|
"probe_steps": sum(row.get("probe_steps", 0) for row in rows),
|
|
}
|
|
|
|
old_baseline = summary["modes"].get("serial", {}).get("median_elapsed_seconds")
|
|
new_baseline = summary["modes"].get("new-serial", {}).get("median_elapsed_seconds")
|
|
for mode, data in summary["modes"].items():
|
|
if old_baseline:
|
|
data["speedup_vs_old_serial"] = round(
|
|
old_baseline / data["median_elapsed_seconds"], 3
|
|
)
|
|
if new_baseline:
|
|
data["speedup_vs_new_serial"] = round(
|
|
new_baseline / data["median_elapsed_seconds"], 3
|
|
)
|
|
|
|
output = workdir / "results.json"
|
|
_write_json(output, summary)
|
|
_log("")
|
|
_log("mode median_s speedup files MB 429 404 probe")
|
|
for mode in MODES:
|
|
data = summary["modes"].get(mode)
|
|
if not data:
|
|
continue
|
|
_log(
|
|
f"{mode:<10} {data['median_elapsed_seconds']:>9.1f} "
|
|
f"{data.get('speedup_vs_new_serial', 0):>7.3f} "
|
|
f"{data['median_files']:>6} "
|
|
f"{data['median_bytes'] / 1_000_000:>7.1f} "
|
|
f"{data['rate_limited']:>5} {data['not_found']:>4} "
|
|
f"{data.get('probe_steps', 0):>6}"
|
|
)
|
|
_log(f"results -> {output}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--workdir", default=str(DEFAULT_WORKDIR))
|
|
parser.add_argument("--count", type=int, default=20, help="models to select")
|
|
parser.add_argument("--images", type=int, default=180, help="max selected images")
|
|
parser.add_argument("--min-images", type=int, default=4)
|
|
parser.add_argument("--max-images", type=int, default=10)
|
|
parser.add_argument("--full", action="store_true", help="select all example-capable models")
|
|
parser.add_argument("--repeats", type=int, default=2)
|
|
parser.add_argument("--port", type=int, default=18200)
|
|
parser.add_argument("--old-tree", default=str(DEFAULT_WORKDIR / "old-tree"))
|
|
parser.add_argument("--old-ref", default="9ed75e8f")
|
|
parser.add_argument("--new-tree", default=str(REPO_ROOT))
|
|
parser.add_argument("--tree", default=str(REPO_ROOT))
|
|
parser.add_argument("--mode", choices=sorted(MODES), default="serial")
|
|
parser.add_argument(
|
|
"command",
|
|
choices=["prepare", "run", "compare"],
|
|
nargs="?",
|
|
default="compare",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.command == "prepare":
|
|
prepare(args)
|
|
elif args.command == "run":
|
|
workdir = Path(args.workdir).resolve()
|
|
selection = _read_json(workdir / "selection.json")
|
|
result = run_once(
|
|
Path(args.tree).resolve(), workdir, selection, args.mode, args.port
|
|
)
|
|
_write_json(workdir / f"run-{args.mode}.json", result)
|
|
_log(json.dumps(result, indent=2))
|
|
else:
|
|
compare(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|