mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-25 18:00:32 +01:00
307 lines
13 KiB
Python
307 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Summarize CPU profiling recordings.
|
|
Usage: python tools/net_report.py RECORDING [RECORDING2 ...] [options]."""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
FORMAT = 1
|
|
SUMMARY_LINE_LIMIT = 300
|
|
# (the summary file always uses this, so its length does not depend on --top)
|
|
SUMMARY_TOP = 40
|
|
REQUIRED_HEADER = ("part", "last_part", "role", "own_machine", "build", "platform", "map",
|
|
"players", "start_utc", "start_s", "duration_s",
|
|
"stop_reason")
|
|
PER_TICK_PREFIXES = ("game_tick", "network_distributed_tick")
|
|
|
|
COLUMNS_GLOSSARY = """\
|
|
scope CPU scope name (a profile section or frame timer); nested names use "."
|
|
count times the scope ran in the recording
|
|
total_ms summed inclusive time (children included), milliseconds
|
|
mean_ms total_ms / count
|
|
max_ms longest single run; aggregate sections: largest summed time in one frame
|
|
per_frame count / frames recorded
|
|
per_tick_ms total_ms / ticks recorded (game_tick.* and network_distributed_tick* only)
|
|
frame frame number in the recording
|
|
at_s seconds from the start of the recording to that frame
|
|
frame_ms that frame's duration
|
|
ticks game ticks that frame ran
|
|
longest scopes (ms) the frame's three scopes, at any depth, with the most time of their own (children not counted), name and that time"""
|
|
|
|
|
|
class ReportError(Exception):
|
|
pass
|
|
|
|
|
|
# ---------- reading a recording
|
|
|
|
|
|
def part_paths(recording):
|
|
"""the recording's name, its part files by number, and the warnings"""
|
|
path = Path(recording)
|
|
match = re.match(r"^(.*)\.part(\d+)\.json$", path.name)
|
|
base = path.parent / (match.group(1) if match else re.sub(r"\.json$", "", path.name))
|
|
parts = {}
|
|
for candidate in base.parent.glob(base.name + ".part*.json"):
|
|
number = re.match(r"^.*\.part(\d+)\.json$", candidate.name)
|
|
if number and candidate.name.startswith(base.name + ".part"):
|
|
parts[int(number.group(1))] = candidate
|
|
if not parts:
|
|
raise ReportError(f"no parts of {base}")
|
|
warnings = [f"part {number} of {base.name} is missing" for number in range(1, max(parts) + 1)
|
|
if number not in parts]
|
|
return base, [parts[number] for number in sorted(parts)], warnings
|
|
|
|
|
|
def read_head(path):
|
|
"""the header and cpu tables, read before the event array"""
|
|
with open(path, "r", encoding="utf-8") as file:
|
|
return read_head_stream(file, path)
|
|
|
|
|
|
def read_head_stream(file, path):
|
|
text = ""
|
|
marker = '"traceEvents"'
|
|
while marker not in text:
|
|
chunk = file.read(65536)
|
|
if not chunk:
|
|
raise ReportError(f"{path}: has no traceEvents")
|
|
text += chunk
|
|
text = text.split(marker, 1)[0].rstrip().rstrip(",") + "}"
|
|
try:
|
|
head = json.loads(text)
|
|
except json.JSONDecodeError as error:
|
|
raise ReportError(f"{path}: not a recording part ({error})") from None
|
|
if not isinstance(head, dict):
|
|
raise ReportError(f"{path}: not a recording part")
|
|
for key in ("halo", "cpu_summary"):
|
|
if not isinstance(head.get(key), dict):
|
|
raise ReportError(f'{path}: has no "{key}"')
|
|
_validate_head_part(head, path)
|
|
return [head]
|
|
|
|
|
|
def _validate_head_part(head, path):
|
|
where = str(path)
|
|
for key in ("halo", "cpu_summary"):
|
|
if not isinstance(head.get(key), dict):
|
|
raise ReportError(f'{where}: has no "{key}"')
|
|
header = head["halo"].get("header")
|
|
if not isinstance(header, dict):
|
|
raise ReportError(f'{where}: has no "header" in "halo"')
|
|
for key in REQUIRED_HEADER:
|
|
if key not in header:
|
|
raise ReportError(f'{where}: the header has no "{key}"')
|
|
|
|
|
|
def table_dicts(table):
|
|
columns = table["columns"]
|
|
return [dict(zip(columns, row)) for row in table["rows"]]
|
|
|
|
|
|
class Recording:
|
|
"""a recording's parts with their CPU summaries merged"""
|
|
|
|
def __init__(self, recording):
|
|
self.base, paths, self.warnings = part_paths(recording)
|
|
heads = [head for part in paths for head in read_head(part)]
|
|
self.name = self.base.name
|
|
self.headers = [head["halo"]["header"] for head in heads]
|
|
self.parts = [header["part"] for header in self.headers]
|
|
self.complete = any(header["last_part"] for header in self.headers)
|
|
if not self.complete:
|
|
self.warnings.append(f"{self.name} has no last part: the recording is incomplete")
|
|
self.header = self.headers[0]
|
|
self.cpu = self.merge_cpu([head["cpu_summary"] for head in heads])
|
|
|
|
@staticmethod
|
|
def merge_cpu(summaries):
|
|
names = {}
|
|
frames = sum(summary["frames"] for summary in summaries)
|
|
ticks = sum(summary["ticks"] for summary in summaries)
|
|
worst = []
|
|
for summary in summaries:
|
|
for row in table_dicts(summary):
|
|
totals = names.setdefault(row["name"], {"count": 0, "total_ms": 0.0, "max_ms": 0.0})
|
|
totals["count"] += row["count"]
|
|
totals["total_ms"] += row["total_ms"]
|
|
totals["max_ms"] = max(totals["max_ms"], row["max_ms"])
|
|
worst.extend(table_dicts(summary["worst_frames"]))
|
|
for name, totals in names.items():
|
|
totals["mean_ms"] = totals["total_ms"] / totals["count"] if totals["count"] else 0.0
|
|
totals["per_frame"] = totals["count"] / frames if frames else 0.0
|
|
totals["per_tick_ms"] = (totals["total_ms"] / ticks if ticks and name.startswith(PER_TICK_PREFIXES)
|
|
else None)
|
|
worst.sort(key=lambda row: -row["frame_ms"])
|
|
return {"frames": frames, "ticks": ticks, "names": names, "worst": worst[:10]}
|
|
|
|
def select(self, start=None, end=None):
|
|
"""Elapsed seconds from the recorded part headers, clipped to a window."""
|
|
seconds = 0.0
|
|
for header in self.headers:
|
|
begin = header["start_s"]
|
|
finish = begin + header["duration_s"]
|
|
seconds += max(0.0, min(finish, end if end is not None else finish) -
|
|
max(begin, start if start is not None else begin))
|
|
return seconds
|
|
|
|
|
|
# ---------- the tables
|
|
|
|
|
|
def cpu_table(recording):
|
|
rows = [[name, totals["count"], totals["total_ms"], totals["mean_ms"], totals["max_ms"], totals["per_frame"],
|
|
totals["per_tick_ms"]] for name, totals in recording.cpu["names"].items()]
|
|
rows.sort(key=lambda row: (-row[2], row[0]))
|
|
return ["scope", "count", "total_ms", "mean_ms", "max_ms", "per_frame", "per_tick_ms"], rows
|
|
|
|
|
|
def worst_frame_table(recording):
|
|
rows = [[row["frame"], row["at_s"], row["frame_ms"], row["ticks"],
|
|
", ".join(f"{name} {ms:.1f}" for name, ms in row["longest"])] for row in recording.cpu["worst"]]
|
|
return ["frame", "at_s", "frame_ms", "ticks", "longest scopes (ms)"], rows
|
|
|
|
|
|
# ---------- text
|
|
|
|
|
|
NUMBER_FORMATS = {
|
|
"count": "{:.0f}", "total_ms": "{:.1f}", "mean_ms": "{:.3f}", "max_ms": "{:.3f}", "per_frame": "{:.2f}",
|
|
"per_tick_ms": "{:.3f}", "frame": "{:.0f}", "at_s": "{:.2f}", "frame_ms": "{:.2f}", "ticks": "{:.0f}",
|
|
}
|
|
TEXT_WIDTH = {"scope": 40, "longest scopes (ms)": 72}
|
|
|
|
|
|
def cell(column, value):
|
|
if value is None:
|
|
return "-"
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool) and column in NUMBER_FORMATS:
|
|
return NUMBER_FORMATS[column].format(value)
|
|
text = str(value)
|
|
width = TEXT_WIDTH.get(column)
|
|
if width and len(text) > width:
|
|
text = text[:width - 1] + "~"
|
|
return text
|
|
|
|
|
|
def format_table(columns, rows, limit=None):
|
|
"""aligned lines: names left, numbers right; past the limit a line of
|
|
how many more"""
|
|
shown = rows if limit is None else rows[:limit]
|
|
if not shown:
|
|
return ["(none)"]
|
|
cells = [[cell(column, value) for column, value in zip(columns, row)] for row in shown]
|
|
numeric = [column in NUMBER_FORMATS for column in columns]
|
|
widths = [max([len(column)] + [len(line[index]) for line in cells]) for index, column in enumerate(columns)]
|
|
|
|
def line(values):
|
|
parts = [value.rjust(width) if is_number else value.ljust(width)
|
|
for value, width, is_number in zip(values, widths, numeric)]
|
|
return " ".join(parts).rstrip()
|
|
|
|
lines = [line(columns)] + [line(values) for values in cells]
|
|
if limit is not None and len(rows) > limit:
|
|
lines.append(f"... {len(rows) - limit} more rows (net_report.py --top N)")
|
|
return lines
|
|
|
|
|
|
def problems(recording):
|
|
found = list(recording.warnings)
|
|
counters = (("unbalanced_scopes", "unbalanced scopes"), ("deep_scopes", "deep scopes"),
|
|
("foreign_scopes", "foreign scopes"), ("dropped_scopes", "dropped scopes"))
|
|
for key, label in counters:
|
|
total = sum(header.get(key, 0) for header in recording.headers)
|
|
if total:
|
|
found.append(f"{label} {total}")
|
|
wait = sum(header.get("writer_wait_ms", 0.0) for header in recording.headers[-1:])
|
|
if wait:
|
|
found.append(f"writer waited {wait:.0f} ms")
|
|
return found
|
|
|
|
|
|
def summary_lines(recording, start, end, top):
|
|
seconds = recording.select(start, end)
|
|
windowed = start is not None or end is not None
|
|
whole = recording.select()
|
|
header, last = recording.header, recording.headers[-1]
|
|
parts = f"parts {min(recording.parts)}-{max(recording.parts)}" if len(recording.parts) > 1 else "part 1"
|
|
state = "complete" if recording.complete and not any(
|
|
"missing" in warning for warning in recording.warnings) else "incomplete"
|
|
found = problems(recording)
|
|
own = header["own_machine"] if header["own_machine"] >= 0 else "-"
|
|
lines = [
|
|
f"# halo profile summary, format {FORMAT}",
|
|
f"# recording: {recording.name}, {parts} ({state})",
|
|
f"# build: {header['build']}, platform: {header['platform']}, role: {header['role']}, own_machine: {own}",
|
|
f"# map: {header['map']}, players: {header['players']} at start, "
|
|
f"{max(item.get('players_most', item['players']) for item in recording.headers)} most",
|
|
f"# recorded: {header['start_utc']} UTC, {whole:.1f} s, {recording.cpu['ticks']} ticks, "
|
|
f"{recording.cpu['frames']} frames, stopped: {last['stop_reason'] or '-'}",
|
|
*([f"# window: {seconds:.1f} s selected (seconds {start if start is not None else 0:g} to "
|
|
f"{f'{end:g}' if end is not None else 'end'}); the cpu tables, ticks and frames cover the whole recording"]
|
|
if windowed else []),
|
|
f"# problems: {'; '.join(found) if found else 'none'}",
|
|
"# units: ms = milliseconds", "", "## columns",
|
|
]
|
|
note = ", whole recording" if windowed else ""
|
|
sections = [(f"cpu scopes (top {min(top, 40)} by total_ms{note})", cpu_table(recording), min(top, 40)),
|
|
(f"worst frames (10{note})", worst_frame_table(recording), 10)]
|
|
printed = {column for _, (columns, rows), _ in sections if rows for column in columns}
|
|
lines.extend(line for line in COLUMNS_GLOSSARY.splitlines() if re.split(r"\s{2,}", line, maxsplit=1)[0] in printed)
|
|
for title, table, limit in sections:
|
|
lines.extend(["", f"## {title}", *format_table(*table, limit)])
|
|
return lines
|
|
|
|
|
|
def write_csv(recording, directory):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
for name, (columns, rows) in {"cpu": cpu_table(recording), "worst_frames": worst_frame_table(recording)}.items():
|
|
with open(directory / f"{recording.name}.{name}.csv", "w", newline="", encoding="ascii", errors="replace") as file:
|
|
writer = csv.writer(file)
|
|
writer.writerow(columns)
|
|
writer.writerows([[NUMBER_FORMATS[column].format(value)
|
|
if column in NUMBER_FORMATS and isinstance(value, (int, float)) else
|
|
("-" if value is None else value) for column, value in zip(columns, row)] for row in rows])
|
|
|
|
|
|
def main(arguments=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("recordings", nargs="+", metavar="RECORDING")
|
|
parser.add_argument("--from", dest="start", type=float, help="from this second of the recording")
|
|
parser.add_argument("--to", dest="end", type=float, help="to this second")
|
|
parser.add_argument("--top", type=int, default=40, help="CPU rows (default 40)")
|
|
parser.add_argument("--csv", type=Path, metavar="DIR", help="every table as CSV in DIR")
|
|
parser.add_argument("--no-summary", action="store_true", help="do not write <name>.summary.txt")
|
|
options = parser.parse_args(arguments)
|
|
try:
|
|
recordings = [Recording(name) for name in options.recordings]
|
|
except (ReportError, OSError) as error:
|
|
print(f"net_report: {error}", file=sys.stderr)
|
|
return 1
|
|
for recording in recordings:
|
|
try:
|
|
lines = summary_lines(recording, options.start, options.end, options.top)
|
|
file_lines = lines if options.top == SUMMARY_TOP else summary_lines(
|
|
recording, options.start, options.end, SUMMARY_TOP)
|
|
except (KeyError, TypeError, IndexError, ValueError) as error:
|
|
print(f"net_report: {recording.name}: a table is not as the writer wrote it ({error!r})", file=sys.stderr)
|
|
return 1
|
|
for warning in recording.warnings:
|
|
print(f"warning: {warning}", file=sys.stderr)
|
|
print("\n".join(lines))
|
|
print()
|
|
if not options.no_summary:
|
|
path = recording.base.parent / f"{recording.name}.summary.txt"
|
|
path.write_text("\n".join(file_lines) + "\n", encoding="ascii", errors="replace")
|
|
if options.csv:
|
|
write_csv(recording, options.csv)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|