Files
sindoring 6976515791 Frame-cost recordings with GPU passes, CPU phases and tags; cheaper MoonWell AO
The FPS journal writes gpu-passes.log (every top-level GPU pass) and cpu-phases.csv (wall,
process and main-thread CPU per main schedule) beside fps-journal.csv, with a session marker
per recording. On Windows the phase clocks count thread cycles (QueryThreadCycleTime /
QueryProcessCycleTime at a TSC rate calibrated against Instant), since tick-sized process
times cannot split a frame. A crate on top labels recordings through FpsJournalTags; each tag
lands in all three files when recording starts and whenever it changes, and
scripts/journal-report.py summarises a recording per session and tag state side by side.

moonwell-gfx tags recordings with its settings (mwgfx) and gets cheaper at renderScale 2:
- the ambient occlusion reads a linear depth pyramid (ao_depth.wgsl, after McGuire's SAO),
  far samples off coarse levels: 2.2-2.4 ms -> ~1.1 ms at 12 MP, image unchanged (max 9/255);
- the water's scene copy is skipped while no view sees a liquid;
- shadow casters take their own visibility class, so the world camera stops listing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 18:13:00 +04:00

229 lines
8.8 KiB
Python

#!/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())