dot
vizie's NixOS desktop: Hyprland, CachyOS kernel and gaming tweaks
| 1 | #!/usr/bin/env python3 |
| 2 | """Compare MangoHud frame logs from CachyOS and NixOS (see MIGRATION.md). |
| 3 | |
| 4 | tools/bench-compare every log in ~/benchmarks |
| 5 | tools/bench-compare DIR|FILE ... the logs given |
| 6 | |
| 7 | Logs are grouped by the OS MangoHud wrote into each file's header, so logs |
| 8 | from both systems can sit in one folder. For each run it shows the average |
| 9 | FPS, the 1% and 0.1% lows (the FPS of the slowest 1% and 0.1% of frames) and |
| 10 | the longest frame; then each system's average over its runs, and how far |
| 11 | NixOS is from CachyOS. |
| 12 | |
| 13 | Record with log_interval=0 so every frame is logged; with sampled logs the |
| 14 | lows are only estimates. |
| 15 | """ |
| 16 | import csv |
| 17 | import statistics |
| 18 | import sys |
| 19 | from pathlib import Path |
| 20 | |
| 21 | |
| 22 | def read_log(path): |
| 23 | """Return (os name, frame times in ms) from one MangoHud CSV log.""" |
| 24 | rows = list(csv.reader(path.open(newline="", errors="replace"))) |
| 25 | os_name = "unknown" |
| 26 | frametimes = None |
| 27 | for i, row in enumerate(rows): |
| 28 | cols = [c.strip().lower() for c in row] |
| 29 | if cols[:1] == ["os"] and i + 1 < len(rows) and rows[i + 1]: |
| 30 | os_name = rows[i + 1][0].strip() or os_name |
| 31 | elif "frametime" in cols: |
| 32 | col = cols.index("frametime") |
| 33 | frametimes = [] |
| 34 | for data in rows[i + 1 :]: |
| 35 | try: |
| 36 | frametimes.append(float(data[col])) |
| 37 | except (IndexError, ValueError): |
| 38 | continue |
| 39 | break |
| 40 | if not frametimes: |
| 41 | return os_name, [] |
| 42 | # Older MangoHud versions log microseconds, newer ones milliseconds. |
| 43 | if statistics.median(frametimes) > 200: |
| 44 | frametimes = [t / 1000 for t in frametimes] |
| 45 | return os_name, [t for t in frametimes if t > 0] |
| 46 | |
| 47 | |
| 48 | def low(frametimes, fraction): |
| 49 | """FPS of the slowest `fraction` of frames (averaged, as MangoHud does).""" |
| 50 | worst = sorted(frametimes, reverse=True) |
| 51 | n = max(1, int(len(worst) * fraction)) |
| 52 | return 1000 / statistics.fmean(worst[:n]) |
| 53 | |
| 54 | |
| 55 | def stats(frametimes): |
| 56 | return { |
| 57 | "avg": 1000 / statistics.fmean(frametimes), |
| 58 | "1%": low(frametimes, 0.01), |
| 59 | "0.1%": low(frametimes, 0.001), |
| 60 | "max ms": max(frametimes), |
| 61 | } |
| 62 | |
| 63 | |
| 64 | def short_os(name): |
| 65 | lower = name.lower() |
| 66 | if "cachyos" in lower: |
| 67 | return "CachyOS" |
| 68 | if "nixos" in lower: |
| 69 | return "NixOS" |
| 70 | return name |
| 71 | |
| 72 | |
| 73 | def main(args): |
| 74 | targets = [Path(a) for a in args] or [Path.home() / "benchmarks"] |
| 75 | files = [] |
| 76 | for target in targets: |
| 77 | if target.is_dir(): |
| 78 | files += sorted(target.rglob("*.csv")) |
| 79 | elif target.exists(): |
| 80 | files.append(target) |
| 81 | else: |
| 82 | sys.exit(f"bench-compare: {target} doesn't exist") |
| 83 | files = [f for f in files if not f.stem.endswith("_summary")] |
| 84 | if not files: |
| 85 | sys.exit("bench-compare: no MangoHud logs found") |
| 86 | |
| 87 | keys = ["avg", "1%", "0.1%", "max ms"] |
| 88 | header = f"{'':38}" + "".join(f"{k:>9}" for k in keys) |
| 89 | groups = {} |
| 90 | print(header) |
| 91 | for f in files: |
| 92 | os_name, frametimes = read_log(f) |
| 93 | if len(frametimes) < 100: |
| 94 | print(f"{f.name[:38]:38} skipped: {len(frametimes)} frames") |
| 95 | continue |
| 96 | s = stats(frametimes) |
| 97 | system = short_os(os_name) |
| 98 | groups.setdefault(system, []).append(s) |
| 99 | label = f"{system} {f.name}"[:38] |
| 100 | print(f"{label:38}" + "".join(f"{s[k]:9.1f}" for k in keys)) |
| 101 | |
| 102 | print() |
| 103 | print(f"{'average of runs':38}" + "".join(f"{k:>9}" for k in keys)) |
| 104 | means = {} |
| 105 | for system, runs in groups.items(): |
| 106 | means[system] = {k: statistics.fmean(r[k] for r in runs) for k in keys} |
| 107 | label = f"{system} ({len(runs)} runs)" |
| 108 | print(f"{label:38}" + "".join(f"{means[system][k]:9.1f}" for k in keys)) |
| 109 | |
| 110 | if "CachyOS" in means and "NixOS" in means: |
| 111 | a, b = means["CachyOS"], means["NixOS"] |
| 112 | diff = "".join(f"{(b[k] - a[k]) / a[k] * 100:+8.1f}%" for k in keys) |
| 113 | print(f"{'NixOS vs CachyOS':38}{diff}") |
| 114 | print("(higher is better for FPS columns, lower is better for max ms)") |
| 115 | # Run-to-run noise on the same system, to judge whether a gap is real. |
| 116 | for system, runs in groups.items(): |
| 117 | if len(runs) > 1: |
| 118 | spread = statistics.pstdev(r["avg"] for r in runs) |
| 119 | print(f"{system}: average FPS varies ±{spread:.1f} between runs") |
| 120 | |
| 121 | |
| 122 | if __name__ == "__main__": |
| 123 | main(sys.argv[1:]) |