diff --git a/.gitignore b/.gitignore index 6f124f4..58e0080 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ airdef-mod/target/ __pycache__/ /out/ .DS_Store +run-home-bench/ diff --git a/tools/hd_bench.py b/tools/hd_bench.py new file mode 100644 index 0000000..b46b585 --- /dev/null +++ b/tools/hd_bench.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет. + + python3 tools/hd_bench.py --list # сцены и их параметры + python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/ + python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре) + python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off + python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after + +Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef, +игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем). +Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр +(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1. +Итог: report.md и results.json в папке вывода. + +Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов. +Только стандартная библиотека; при установленном Pillow сравнение быстрее. +""" +import argparse +import datetime +import json +import os +import re +import shutil +import struct +import subprocess +import sys +import time +import zlib +from dataclasses import asdict, dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BENCH = ROOT / "run-home-bench" +SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт +LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home) +HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересоздаётся каждый раз +CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl" +SAVES = ROOT / "run-home" / "saves" +OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем +DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 +DIFF_GAIN = 6 # усиление разницы в diff-картинках +TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$") + + +@dataclass +class Shot: + name: str + world: str + time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь + pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`) + note: str + + +# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток +# перед сравнением и поправь здесь, если кадр не то, что написано в note. +SHOTS = [ + Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0), + "полигон с техникой, полдень, белые материалы"), + Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0), + "река на Hills, полдень: вода, преломление, блики"), + Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0), + "река на Hills, закат: тинт неба и воды"), + Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0), + "река на Hills, ночь: луна, туман"), + Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0), + "лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"), + Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0), + "небо и облака, полдень, взгляд вверх"), +] + + +class BenchError(RuntimeError): + pass + + +def die(msg, code=2): + print(f"hd_bench: {msg}", file=sys.stderr) + sys.exit(code) + + +# --- процессы и игра ------------------------------------------------------------------------- + +def pgrep(pattern): + return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0 + + +def ctl_ok(*args): + r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args], + capture_output=True, text=True, timeout=20) + return r.returncode == 0 + + +def bench_running(): + """Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету.""" + if pgrep(str(SOCK.relative_to(ROOT))): + return True + if SOCK.exists(): + if ctl_ok("status"): + return True + SOCK.unlink() # мёртвый сокет от прошлого прогона + return False + + +def ctl(*args, timeout=30.0): + cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60) + except subprocess.TimeoutExpired as e: + raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e + if r.returncode != 0: + raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: " + f"{(r.stdout or r.stderr).strip()[:300]}") + return r.stdout.strip() + + +def game_env(extra, timing): + env = dict(os.environ) + env.update({ + "HORIZON_HOME": str(HOME), + "HORIZON_NO_AUDIO": "1", + "HORIZON_LAUNCHER_NO_SELF_UPDATE": "1", + }) + if timing: + env["HORIZON_SHADER_PACK_TIMING"] = "1" + env.update(extra) + return env + + +def prepare_home(): + if HOME.exists(): + shutil.rmtree(HOME) + (HOME / "saves").mkdir(parents=True) + for world in sorted({s.world for s in SHOTS}): + src = SAVES / world + if not src.is_dir(): + die(f"нет мира {src}") + shutil.copytree(src, HOME / "saves" / world, + ignore=shutil.ignore_patterns("session.lock", "*.lock")) + + +def install_mod(env): + subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"], + env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT) + + +def launch(world, env, game, resources): + if SOCK.exists(): + SOCK.unlink() + cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait", + "--game", str(game), "--resources", str(resources)] + r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600) + if r.returncode != 0: + raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}") + ctl("command", "gamemode creative") + ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd + ctl("type", "/airdef shader hd") + ctl("key", "return", "tap") + time.sleep(0.5) + ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск) + + +def stop_game(): + try: + ctl("quit", timeout=20) + except BenchError: + pass + for _ in range(100): + if not ctl_ok("status"): + break + time.sleep(0.2) + if SOCK.exists() and not ctl_ok("status"): + SOCK.unlink() + + +# --- кадры: PNG без Pillow и с ним ------------------------------------------------------------- + +def _paeth(a, b, c): + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + return a if pa <= pb and pa <= pc else (b if pb <= pc else c) + + +def read_png_rgb(path): + """(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра).""" + data = Path(path).read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise BenchError(f"{path}: не PNG") + pos, idat, w = 8, [], 0 + while pos < len(data): + (ln,) = struct.unpack(">I", data[pos:pos + 4]) + kind = data[pos + 4:pos + 8] + body = data[pos + 8:pos + 8 + ln] + pos += 12 + ln + if kind == b"IHDR": + w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + idat.append(body) + elif kind == b"IEND": + break + if depth != 8 or interlace or ctype not in (2, 6): + raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования") + bpp = 3 if ctype == 2 else 4 + stride = w * bpp + raw = zlib.decompress(b"".join(idat)) + out = bytearray(w * h * 3) + prev = bytearray(stride) + p = 0 + for y in range(h): + ft = raw[p] + line = raw[p + 1:p + 1 + stride] + p += 1 + stride + cur = bytearray(stride) + for i in range(stride): + x = line[i] + a = cur[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + if ft == 1: + x += a + elif ft == 2: + x += b + elif ft == 3: + x += (a + b) >> 1 + elif ft == 4: + x += _paeth(a, b, c) + cur[i] = x & 255 + base = y * w * 3 + if bpp == 3: + out[base:base + w * 3] = cur + else: + out[base:base + w * 3:3] = cur[0::4] + out[base + 1:base + w * 3:3] = cur[1::4] + out[base + 2:base + w * 3:3] = cur[2::4] + prev = cur + return w, h, bytes(out) + + +def write_png_rgb(path, w, h, rgb): + raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h)) + + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) + + Path(path).write_bytes(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"")) + + +try: # необязательно: быстрее декодирование и сравнение + from PIL import Image, ImageChops, ImageStat +except ImportError: # pragma: no cover + Image = None + + +def compare_frames(a, b, diff_out=None): + """Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %.""" + if Image is not None: + A = Image.open(a).convert("RGB") + B = Image.open(b).convert("RGB") + if A.size != B.size: + raise BenchError(f"размеры кадров разные: {A.size} и {B.size}") + d = ImageChops.difference(A, B) + mean = sum(ImageStat.Stat(d).mean) / 3 + r, g, bl = d.split() + mx = ImageChops.lighter(ImageChops.lighter(r, g), bl) + hist = mx.histogram() + total = A.size[0] * A.size[1] + pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total + if diff_out: + d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out) + return {"mean": mean, "pct": pct, "size": list(A.size)} + wa, ha, pa = read_png_rgb(a) + wb, hb, pb = read_png_rgb(b) + if (wa, ha) != (wb, hb): + raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}") + total_abs = 0 + over = 0 + diff = bytearray(len(pa)) if diff_out else None + for i in range(0, len(pa), 3): + dr = abs(pa[i] - pb[i]) + dg = abs(pa[i + 1] - pb[i + 1]) + db = abs(pa[i + 2] - pb[i + 2]) + total_abs += dr + dg + db + if max(dr, dg, db) > DIFF_LEVEL: + over += 1 + if diff is not None: + diff[i] = min(255, dr * DIFF_GAIN) + diff[i + 1] = min(255, dg * DIFF_GAIN) + diff[i + 2] = min(255, db * DIFF_GAIN) + if diff_out: + write_png_rgb(diff_out, wa, ha, bytes(diff)) + npx = wa * ha + return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]} + + +# --- тайминги ----------------------------------------------------------------------------------- + +def read_from(path, offset): + try: + with open(path, "rb") as f: + f.seek(offset) + return f.read().decode("utf-8", "replace") + except FileNotFoundError: + return "" + + +def parse_pass_reports(text): + reports = [] + for line in text.splitlines(): + m = TIMING_RE.search(line) + if not m: + continue + passes = {} + for part in m.group(1).split(", "): + name, _, ms = part.rpartition(" ") + try: + passes[name] = float(ms) + except ValueError: + pass + reports.append((passes, float(m.group(2)))) + return reports + + +def average_passes(reports): + if not reports: + return {}, None + sums, counts = {}, {} + for passes, _ in reports: + for name, ms in passes.items(): + sums[name] = sums.get(name, 0.0) + ms + counts[name] = counts.get(name, 0) + 1 + avg = {n: sums[n] / counts[n] for n in sums} + total = sum(avg.values()) + return avg, total + + +def bench_gpu_avg(reply): + try: + return float(json.loads(reply)["gpu"]["avg_ms"]) + except (ValueError, KeyError, TypeError): + return None + + +# --- сцена ------------------------------------------------------------------------------------ + +def run_shot(shot, out, settle, bench_secs, timing): + x, y, z, yaw, pitch = shot.pose + ctl("command", f"time set {shot.time}") + ctl("command", "weather clear") + ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}") + ctl("wait-ready", 120, timeout=150) + time.sleep(settle) + first = out / f"{shot.name}.png" + ctl("screenshot", str(first.resolve())) + time.sleep(0.5) + second = out / f"{shot.name}_still2.png" + ctl("screenshot", str(second.resolve())) + result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {}, + "pass_total_ms": None, "bench_json": None} + offset = LOG.stat().st_size if LOG.exists() else 0 + reply = ctl("bench", bench_secs, timeout=bench_secs + 60) + (out / f"{shot.name}_bench.json").write_text(reply + "\n") + result["bench_json"] = f"{shot.name}_bench.json" + result["gpu_avg_ms"] = bench_gpu_avg(reply) + if timing: + time.sleep(1.0) # последний отчёт о проходах + reports = parse_pass_reports(read_from(LOG, offset)) + result["passes"], result["pass_total_ms"] = average_passes(reports) + if not reports: + result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)" + return result + + +def group_by_world(shots): + order = [] + for s in shots: + if s.world not in order: + order.append(s.world) + return [(w, [s for s in shots if s.world == w]) for w in order] + + +def git_rev(): + r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], + capture_output=True, text=True) + return r.stdout.strip() or "?" + + +def judge(entry, args): + reasons = [] + if entry.get("error"): + reasons.append("ошибка: " + entry["error"]) + gpu = entry.get("gpu_avg_ms") + if gpu is not None and gpu > args.budget_ms: + reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}") + if gpu is None and not entry.get("error"): + reasons.append("нет GPU-таймингов (bench)") + fl = entry.get("flicker") + if fl and fl["pct"] > args.max_flicker: + reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%") + base = entry.get("vs_baseline") + if base: + if base.get("error"): + reasons.append(base["error"]) + else: + if base["mean"] > args.max_mean: + reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}") + if base["pct"] > args.max_pct: + reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%") + return reasons + + +def write_report(out, entries, args, env_extra, size, baseline): + lines = [] + add = lines.append + add(f"# HD-полигон: {out.name}") + add("") + add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}") + add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?") + add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию")) + add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%") + if baseline: + add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)") + add("") + add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |") + add("|---|---|---|---|---|---|---|---|---|") + for e in entries: + gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-" + fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-" + b = e.get("vs_baseline") or {} + bm = f"{b['mean']:.2f}" if "mean" in b else "-" + bp = f"{b['pct']:.2f}" if "pct" in b else "-" + status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"]) + budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-") + add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |") + passes = [] + for e in entries: + for p in e.get("passes", {}): + if p not in passes: + passes.append(p) + if passes: + add("") + add("## Проходы GPU, среднее по отчётам лога, мс") + add("") + add("| проход | " + " | ".join(e["name"] for e in entries) + " |") + add("|---|" + "---|" * len(entries)) + for p in passes: + add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |") + totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries] + add("| **итого** | " + " | ".join(totals) + " |") + add("") + add("## Кадры") + for e in entries: + add("") + add(f"### {e['name']}: {e['note']}") + add("") + add(f"![{e['name']}]({e['frame']})") + if e.get("diff_image"): + add("") + add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})") + if e.get("warning"): + add("") + add(f"> {e['warning']}") + if e.get("error"): + add("") + add(f"> ошибка: {e['error']}") + (out / "report.md").write_text("\n".join(lines) + "\n") + + +def parse_args(argv): + p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.") + p.add_argument("--list", action="store_true", help="показать сцены и выйти") + p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)") + p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)") + p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров") + p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0") + p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)") + p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)") + p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)") + p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255") + p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%") + p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%") + p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING") + p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)") + p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3") + return p.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + if args.list: + for s in SHOTS: + print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}") + return 0 + shots = [s for s in SHOTS if not args.only or s.name in args.only] + unknown = set(args.only) - {s.name for s in SHOTS} + if unknown: + die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)") + env_extra = {} + for item in args.env: + if "=" not in item: + die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}") + k, v = item.split("=", 1) + env_extra[k] = v + if bench_running(): + die("уже запущена игра на run-home-bench/c.sock: один прогон за раз") + if pgrep(OTHER_SOCK): + if len(shots) > 1: + die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста") + print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой", + file=sys.stderr) + out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + out = out.resolve() + out.mkdir(parents=True, exist_ok=True) + sys.path.insert(0, str(ROOT / "tools")) + import airdef # tools/airdef.py: ресурсы и путь к игре + + game = Path(args.game) if args.game else airdef.built_game() + resources = Path(args.resources) if args.resources else airdef.resources() + timing = not args.no_pass_timing + env = game_env(env_extra, timing) + prepare_home() + install_mod(env) + + entries = [] + baseline = Path(args.baseline).resolve() if args.baseline else None + try: + for world, group in group_by_world(shots): + try: + launch(world, env, game, resources) + except BenchError as e: + for s in group: + entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]}) + stop_game() + continue + for s in group: + entry = {**asdict(s), "frame": f"{s.name}.png"} + try: + entry.update(run_shot(s, out, args.settle, args.bench_secs, timing)) + fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png") + entry["flicker"] = fl + except BenchError as e: + entry["error"] = str(e) + if baseline and not entry.get("error"): + base_png = baseline / f"{s.name}.png" + if base_png.exists(): + try: + diff_name = f"{s.name}_diff.png" + m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name) + entry["vs_baseline"] = m + entry["diff_image"] = diff_name + except BenchError as e: + entry["vs_baseline"] = {"error": str(e)} + else: + entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"} + entry["fail"] = judge(entry, args) + entries.append(entry) + stop_game() + finally: + stop_game() + + size = None + for e in entries: + if e.get("vs_baseline") and "size" in e["vs_baseline"]: + size = e["vs_baseline"]["size"] + break + if size is None and entries and entries[0].get("flicker"): + size = entries[0]["flicker"]["size"] + results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, + "baseline": str(baseline) if baseline else None, "shots": entries} + (out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n") + write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None) + + failed = [e for e in entries if e["fail"]] + for e in entries: + print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс " + f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}") + for r in e["fail"]: + print(f" FAIL: {r}") + print(f"отчёт: {out / 'report.md'}") + return 1 if failed else 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except BenchError as e: + die(str(e), code=2)