#!/usr/bin/env python3 """Summarise an FPS journal recording, split wherever its tags change. Reads `fps-journal.csv` and, beside it, `gpu-passes.log` and `cpu-phases.csv` (perf/journal.rs). A segment is one recording session under one set of `# … tag key=value` lines: a crate on top (moonwell-gfx's `mwgfx`) re-tags whenever its settings change, so toggling a feature in a running recording yields one segment per state, and the table compares them side by side. Each segment drops its first `--settle` seconds (a toggle compiles pipelines and refills caches), then averages the per-second rows. Segments are labelled by the tag fields that differ from the first segment's, so `-shadows.enabled` reads "this segment had shadows off". Usage: scripts/journal-report.py [fps-journal.csv] [--settle 3] [--top 20] [--session ID] """ from __future__ import annotations import argparse import csv import pathlib import re import sys from collections import OrderedDict MARK = re.compile(r"^# session=(\S+) t=([\d.]+)(?: tag (\S+?)=(.*))?") GPU_LINE = re.compile(r"^session=(\S+) t=([\d.]+) GPU_PASSES frames=(\d+)(.*)$") class Segment: def __init__(self, session: str, tags: dict[str, str], start: float): self.session = session self.tags = dict(tags) self.start = start self.rows: list[dict[str, str]] = [] self.gpu: list[dict[str, float]] = [] self.cpu: dict[str, list[tuple[float, float, float]]] = OrderedDict() class Timeline: """Every file replays the same marks at the same `t`, so (session, mark t, tags) names one segment across all three.""" def __init__(self) -> None: self.segments: dict[tuple[str, str, str], Segment] = {} def segment(self, session: str, tags: dict[str, str], t: float) -> Segment: key = (session, f"{t:.3f}", repr(sorted(tags.items()))) if key not in self.segments: self.segments[key] = Segment(session, tags, t) return self.segments[key] def split_tags(value: str) -> dict[str, str]: """`a.b=1 a.c=x` into fields; a tag without `=` inside stays one field.""" out = {} for part in value.split(): k, sep, v = part.partition("=") out[k] = v if sep else "" return out def replay(path: pathlib.Path, timeline: Timeline, on_row, header_first: bool) -> None: """Walk one file: marks change the segment, everything else goes to `on_row`.""" if not path.exists(): return session, tags, seg, start = None, {}, None, 0.0 header = None with path.open(encoding="utf-8", errors="replace") as f: for line in f: line = line.rstrip("\n") m = MARK.match(line) if m: s, t = m.group(1), float(m.group(2)) if s != session: session, tags = s, {} if m.group(3): tags = {**tags, m.group(3): m.group(4)} start = t seg = None continue if not line or line.startswith("#"): continue if header_first and header is None: header = next(csv.reader([line])) continue if session is None: # Rows written before session markers existed: one anonymous session. session = "pre-session" if seg is None: seg = timeline.segment(session, tags, start) on_row(seg, line, header) def mean(xs: list[float]) -> float | None: return sum(xs) / len(xs) if xs else None def fmt(x: float | None, width: int = 8) -> str: return f"{x:{width}.2f}" if x is not None else " " * (width - 1) + "-" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("journal", nargs="?", default="fps-journal.csv") ap.add_argument("--settle", type=float, default=3.0) ap.add_argument("--top", type=int, default=20) ap.add_argument("--session", default=None) args = ap.parse_args() journal = pathlib.Path(args.journal) if not journal.exists(): print(f"no journal at {journal}", file=sys.stderr) return 1 timeline = Timeline() def fps_row(seg: Segment, line: str, header: list[str]) -> None: cells = next(csv.reader([line])) seg.rows.append(dict(zip(header, cells))) def gpu_row(seg: Segment, line: str, _header) -> None: m = GPU_LINE.match(line) if not m or int(m.group(3)) == 0: return passes = {"_t": float(m.group(2))} for part in m.group(4).split(): k, _, v = part.partition("=") try: passes[k] = float(v) except ValueError: pass seg.gpu.append(passes) def cpu_row(seg: Segment, line: str, header: list[str]) -> None: row = dict(zip(header, next(csv.reader([line])))) num = lambda k: float(row[k]) if row.get(k) else float("nan") seg.cpu.setdefault(row["phase"], []).append( (float(row["t"]), num("wall_ms"), num("process_ms"), num("main_ms")) ) replay(journal, timeline, fps_row, header_first=True) replay(journal.with_name("gpu-passes.log"), timeline, gpu_row, header_first=False) replay(journal.with_name("cpu-phases.csv"), timeline, cpu_row, header_first=True) segs = [s for s in timeline.segments.values() if s.rows or s.gpu] if args.session: segs = [s for s in segs if s.session == args.session] if not segs: print("no rows", file=sys.stderr) return 1 def settled(seg: Segment, t: float) -> bool: return t >= seg.start + args.settle base = {} for k, v in segs[0].tags.items(): for field, val in split_tags(v).items(): base[f"{k}:{field}"] = val def label(seg: Segment) -> str: fields = {} for k, v in seg.tags.items(): for field, val in split_tags(v).items(): fields[f"{k}:{field}"] = val diff = [] for name in sorted(set(base) | set(fields)): if base.get(name) != fields.get(name): short = name.split(":", 1)[1] val = fields.get(name, "?") if val == "true": diff.append(f"+{short}") elif val == "false": diff.append(f"-{short}") else: diff.append(f"{short}={val}") return " ".join(diff) or "(base)" print("segments:") for i, seg in enumerate(segs): rows = [r for r in seg.rows if settled(seg, float(r["t"]))] print(f" [{i}] session {seg.session} from t={seg.start:.1f}, {len(rows)} settled s: {label(seg)}") if segs[0].tags: print(" base tags:") for k, v in segs[0].tags.items(): print(f" {k} = {v}") cols = ["mean_ms", "p95_ms", "cpu_ms", "main_ms", "gpu_ms", "gpu_opaque", "gpu_static", "gpu_transp", "gpu_glow", "gpu_post", "gpu_ui", "gpu_other"] print("\nframe (ms, mean of settled seconds):") print(" seg " + "".join(f"{c:>11}" for c in cols)) for i, seg in enumerate(segs): rows = [r for r in seg.rows if settled(seg, float(r["t"]))] vals = [] for c in cols: xs = [float(r[c]) for r in rows if r.get(c)] vals.append(fmt(mean(xs), 11)) print(f" [{i}]" + "".join(vals)) gpu_means: list[dict[str, float]] = [] for seg in segs: lines = [g for g in seg.gpu if settled(seg, g["_t"])] names = {k for g in lines for k in g if k != "_t"} gpu_means.append({n: sum(g.get(n, 0.0) for g in lines) / len(lines) for n in names} if lines else {}) names = sorted({n for g in gpu_means for n in g}, key=lambda n: -max(g.get(n, 0.0) for g in gpu_means)) if names: print(f"\nGPU passes (ms per read frame, top {args.top} by worst segment):") print(" " + f"{'pass':<44}" + "".join(f"{f'[{i}]':>9}" for i in range(len(segs)))) for n in names[: args.top]: print(" " + f"{n[:44]:<44}" + "".join(fmt(g.get(n), 9) for g in gpu_means)) print(" " + f"{'(sum of listed + rest)':<44}" + "".join(fmt(sum(g.values()) if g else None, 9) for g in gpu_means)) phases = list(OrderedDict.fromkeys(p for s in segs for p in s.cpu)) if phases: print("\nCPU phases (ms per frame: wall / process / main):") print(" " + f"{'phase':<18}" + "".join(f"{f'[{i}]':>24}" for i in range(len(segs)))) for p in phases: cells = [] for seg in segs: rows = [r for r in seg.cpu.get(p, []) if settled(seg, r[0])] w = mean([r[1] for r in rows]) pr = mean([r[2] for r in rows if r[2] == r[2]]) mn = mean([r[3] for r in rows if r[3] == r[3]]) cells.append(f"{fmt(w, 7)} /{fmt(pr, 7)} /{fmt(mn, 6)}") print(" " + f"{p:<18}" + "".join(f"{c:>24}" for c in cells)) return 0 if __name__ == "__main__": sys.exit(main())