6976515791
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>
229 lines
8.8 KiB
Python
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())
|