Files
AnyPS5/tools/tests/test_trace_digest.py
T
Dean 9b5e3368de Merge pull request #10367 from Bram910/fix/trace-summary-jsonl
fix(tools): validate trace summary JSONL records
2026-10-26 11:04:31 +13:00

922 lines
54 KiB
Python

import contextlib
import gzip
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import trace_digest
def complete(tid, name, ts, dur):
return {"name": name, "ph": "X", "ts": ts, "dur": dur, "pid": 7, "tid": tid}
EVENTS = [
{"name": "process_name", "ph": "M", "ts": 0, "pid": 7, "tid": 0, "args": {"name": "AnyPS5"}},
{"name": "thread_name", "ph": "M", "ts": 0, "pid": 7, "tid": 1, "args": {"name": "queue worker 0x0"}},
{"name": "thread_name", "ph": "M", "ts": 0, "pid": 7, "tid": 2, "args": {"name": "presenter"}},
complete(1, "Driver.WorkerWait", 0, 4000),
complete(1, "Driver.Packet", 4000, 5000),
complete(1, "Driver.Packet.memory_wait", 5000, 2000),
complete(1, "Driver.Packet", 12000, 16000),
complete(1, "Driver.Packet.draw", 13000, 7000),
complete(2, "Driver.Present", 0, 2000),
complete(2, "Driver.Present.inflight_wait", 500, 1000),
complete(2, "Driver.Present", 10000, 16000),
complete(2, "Driver.Present.inflight_wait", 10500, 14500),
{"name": "queue worker 0x0 folded", "ph": "C", "ts": 0, "pid": 7, "tid": 1, "args": {"ms": 1.5}},
{"name": "present", "ph": "i", "ts": 0, "pid": 7, "tid": 2, "s": "g", "args": {"present": 600}},
{"name": "present", "ph": "i", "ts": 10000, "pid": 7, "tid": 2, "s": "g", "args": {"present": 601}},
{"name": "present", "ph": "i", "ts": 30000, "pid": 7, "tid": 2, "s": "g", "args": {"present": 602}},
]
SUMMARY = {"commit": "abc", "dirty": False, "env": {"APS5_TRACE_FRAMES": "600:3"}, "gpu": "GPU", "driver": "Driver 1", "os": "OS 1",
"resolution": "1920x1080", "window": {"spec": "frames 600:3", "first_present": 600, "ms": 40.0}, "presents": 3,
"frame_ms": {"p50": 10.0, "p90": 20.0, "p99": 20.0, "max": 20.0}, "gpu_busy_ms_per_present": 5.0,
"worker_busy_pct": {"queue worker 0x0": 50.0}, "top_phases_ms_per_frame": {"Driver.Packet": 7.0}, "trace_dropped": False,
"trace": "aps5-trace-abc-7.json.gz"}
STACKS = "# samples=5 depth=64 truncated=1\n# module a.prx 0x1000 0x1\nntdll.dll+0x1;a.prx+0x10;a.prx+0x20 3\nntdll.dll+0x1;a.prx+0x10;a.prx+0x30 1\n[truncated];a.prx+0x40;a.prx+0x30 1\n"
class TraceDigestTests(unittest.TestCase):
def test_summary_jsonl_record_shapes_are_validated(self):
path = Path(self.directory.name) / "invalid-summary.jsonl"
for record in (None, True, False, 7, 1.5, "", "interval_start_s", [], ["interval_start_s"]):
for mode in ("capture", "intervals"):
with self.subTest(record=record, mode=mode):
path.write_text("{}\n\n" + json.dumps(record) + "\n", encoding="utf-8")
with self.assertRaises(ValueError) as raised:
if mode == "capture":
trace_digest.load_summary(path, self.trace)
else:
trace_digest.load_intervals(path)
self.assertEqual(str(raised.exception), f"{path}:3: summary record must be an object")
def test_summary_jsonl_syntax_errors_name_the_source_line(self):
path = Path(self.directory.name) / "invalid-summary.jsonl"
for record in ("{", '{"trace": }', "{} trailing"):
for mode in ("capture", "intervals"):
with self.subTest(record=record, mode=mode):
path.write_text("{}\n\n" + record + "\n", encoding="utf-8")
with self.assertRaises(ValueError) as raised:
if mode == "capture":
trace_digest.load_summary(path, self.trace)
else:
trace_digest.load_intervals(path)
self.assertTrue(str(raised.exception).startswith(f"{path}:3: invalid JSON: "), str(raised.exception))
def test_cli_rejects_invalid_summary_records_without_partial_output(self):
summary = self.trace.with_name("aps5-summary.jsonl")
explicit = summary.with_name("explicit-\u03a9.jsonl")
invalid = [json.dumps(record) for record in (None, True, 7, "interval_start_s", [], ["interval_start_s"])]
for record in [*invalid, "{", "{} trailing"]:
data = (json.dumps(SUMMARY) + "\n\n" + record + "\n").encode("utf-8")
summary.write_bytes(data)
explicit.write_bytes(data)
for arguments, path in (([str(self.trace)], summary), ([str(self.trace), "--summary", str(explicit)], explicit),
([str(summary)], summary)):
for output in ([], ["--json"]):
with self.subTest(record=record, arguments=arguments, output=output):
result = subprocess.run([sys.executable, trace_digest.__file__, *arguments, *output],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertTrue(result.stderr.startswith(f"FAIL: {path}:3: "), result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertEqual(result.stdout, "")
self.assertEqual(path.read_bytes(), data)
def test_mixed_summary_jsonl_objects_preserve_selection_and_intervals(self):
path = self.trace.with_name("aps5-summary.jsonl")
expected = self.json_output(self.trace)
interval = interval_line(0.0, 30.0, 20.0, 5.0)
records = [{}, dict(SUMMARY, trace="other.json.gz"), SUMMARY, interval, {"unrelated": "\u03a9"}]
text = "\r\n \t\r\n" + "\r\n\r\n".join(json.dumps(record) for record in records) + "\r\n"
path.write_text(text, encoding="utf-8-sig")
self.assertEqual(self.json_output(self.trace), expected)
self.assertEqual(trace_digest.load_intervals(path), [interval])
records.extend([dict(SUMMARY, commit="latest"), dict(SUMMARY, trace=self.trace.with_suffix("").name, commit="alternate")])
path.write_text("\n".join(json.dumps(record) for record in records), encoding="utf-8")
self.assertEqual(trace_digest.load_summary(path, self.trace)["commit"], "latest")
self.assertEqual(trace_digest.load_summary(path, self.trace.with_suffix(""))["commit"], "alternate")
def test_empty_summary_jsonl_files_remain_valid(self):
path = self.trace.with_name("aps5-summary.jsonl")
for text in ("", "\n \t\r\n"):
for encoding in ("utf-8", "utf-8-sig"):
with self.subTest(text=text, encoding=encoding):
path.write_text(text, encoding=encoding)
self.assertIsNone(trace_digest.load_summary(path, self.trace))
self.assertEqual(trace_digest.load_intervals(path), [])
def test_unicode_separators_inside_summary_strings_are_not_record_boundaries(self):
path = self.trace.with_name("aps5-summary.jsonl")
for separator in ("\u0085", "\u2028", "\u2029"):
with self.subTest(separator=separator):
gpu = f"GPU{separator}name"
summary = dict(SUMMARY, gpu=gpu)
interval = interval_line(0.0, 30.0, 20.0, 5.0, gpu=gpu)
path.write_text("\n".join(json.dumps(record, ensure_ascii=False) for record in (summary, interval)), encoding="utf-8")
self.assertEqual(trace_digest.load_summary(path, self.trace), summary)
self.assertEqual(trace_digest.load_intervals(path), [interval])
def test_cli_rejects_invalid_event_container_shapes_without_partial_output(self):
cases = [(value, "trace must be an event array or object") for value in (None, True, 7, "trace")]
cases += [({"traceEvents": value}, "traceEvents must be an array")
for value in (None, False, 0, "", {}, "events")]
cases += [([value], "trace event 0 must be an object") for value in (None, True, 7, "event", [])]
cases.append(({"traceEvents": [{}, None]}, "trace event 1 must be an object"))
for document, message in cases:
for compressed in (False, True):
with self.subTest(document=document, compressed=compressed):
path = Path(self.directory.name) / ("invalid.json.gz" if compressed else "invalid.json")
data = json.dumps(document).encode("utf-8")
path.write_bytes(gzip.compress(data) if compressed else data)
result = subprocess.run([sys.executable, trace_digest.__file__, str(path), "--json"],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("FAIL: " + message, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertEqual(result.stdout, "")
def test_cli_reports_unreadable_inputs_without_partial_output(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
compressed = gzip.compress(b'{"traceEvents": []}')
files = {"invalid.json": b"{", "broken.json.gz": b"not gzip", "intervals.jsonl": b"{\n",
"truncated.json.gz": compressed[:-4],
"corrupt.json.gz": compressed[:10] + b"\xff" * 4 + compressed[14:]}
for name, data in files.items():
(root / name).write_bytes(data)
for name in ("missing.json", *files):
with self.subTest(name=name):
result = subprocess.run([sys.executable, trace_digest.__file__, str(root / name)],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertTrue(result.stderr.startswith("FAIL: "), result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertEqual(result.stdout, "")
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
root = Path(self.directory.name)
self.trace = root / "aps5-trace-abc-7.json.gz"
with gzip.open(self.trace, "wt", encoding="utf-8") as handle:
json.dump({"traceEvents": EVENTS}, handle)
(root / "aps5-summary.jsonl").write_text(json.dumps(dict(SUMMARY, trace="other.json.gz")) + "\n" + json.dumps(SUMMARY) + "\n", encoding="utf-8")
self.stacks = root / "worker.collapsed.txt"
self.stacks.write_text(STACKS, encoding="utf-8")
def tearDown(self):
self.directory.cleanup()
def test_folded_only_threads_are_reported_in_json_and_markdown(self):
baseline = {thread["thread"]: thread for thread in self.result()["threads"]}
for name, total in (("shader compiler", 6.0), ("tid 3", 6.0), ("shader compiler", 0.0)):
with self.subTest(name=name, total=total):
events = EVENTS + [
{"name": "thread_name", "ph": "M", "ts": 0, "pid": 7, "tid": 3, "args": {"name": name}},
{"name": "thread_name", "ph": "M", "ts": 0, "pid": 7, "tid": 4, "args": {"name": "idle"}},
{"name": f"{name} folded", "ph": "C", "ts": 0, "pid": 7, "tid": 3, "args": {"ms": total / 2}},
{"name": f"{name} folded", "ph": "C", "ts": 20000, "pid": 7, "tid": 3, "args": {"ms": total / 2}},
]
with gzip.open(self.trace, "wt", encoding="utf-8") as handle:
json.dump({"traceEvents": events}, handle)
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.trace), "--json"]), 0)
result = json.loads(output.getvalue())
threads = {thread["thread"]: thread for thread in result["threads"]}
self.assertEqual(set(threads), set(baseline) | {name})
self.assertEqual(threads[name]["folded_ms_per_frame"], total / 3)
self.assertEqual(threads[name]["busy_ms_per_frame"], 0)
self.assertEqual(threads[name]["wait_ms_per_frame"], 0)
self.assertEqual(threads[name]["top"], [])
for thread_name, thread in baseline.items():
self.assertEqual(threads[thread_name], thread)
output = io.StringIO()
with contextlib.redirect_stdout(output):
trace_digest.main([str(self.trace)])
self.assertIn(f"| {name} | 0.0 (0 %) | 0.0 (0 %) |", output.getvalue())
def test_folded_only_thread_without_frame_boundaries(self):
trace = trace_digest.Trace({"traceEvents": [
{"name": "compiler folded", "ph": "C", "ts": 0, "tid": 3, "args": {"ms": 1.5}},
]})
thread = trace_digest.digest(trace)["threads"][0]
self.assertEqual(thread["thread"], "tid 3")
self.assertEqual(thread["folded_ms_per_frame"], 1.5)
self.assertEqual(thread["untraced_ms_per_frame"], 0)
def test_trace_accepts_event_array_and_empty_document(self):
trace_from_array = trace_digest.Trace(EVENTS)
self.assertEqual(len(trace_from_array.presents), 3)
empty_dict = trace_digest.Trace({})
self.assertEqual(empty_dict.presents, [])
empty_list = trace_digest.Trace([])
self.assertEqual(empty_list.presents, [])
empty_events = trace_digest.Trace({"traceEvents": []})
self.assertEqual(empty_events.presents, [])
def result(self):
return trace_digest.digest(trace_digest.load_trace(self.trace), trace_digest.load_summary(None, self.trace),
trace_digest.load_stacks(self.stacks), worst=2, top=3)
def json_output(self, trace, *arguments):
result = subprocess.run([sys.executable, trace_digest.__file__, str(trace), "--json", *arguments],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)
def test_utf8_bom_plain_and_compressed_traces_preserve_the_digest(self):
events = [dict(event, args={"name": "worker \u03a9"}) if event["name"] == "thread_name" and event["tid"] == 1
else event for event in EVENTS]
text = json.dumps({"traceEvents": events}, ensure_ascii=False)
self.trace.write_bytes(gzip.compress(text.encode("utf-8")))
expected = self.json_output(self.trace)
for trace in (self.trace, self.trace.with_suffix("")):
for encoding in ("utf-8", "utf-8-sig"):
with self.subTest(trace=trace.name, encoding=encoding):
data = text.encode(encoding)
trace.write_bytes(gzip.compress(data) if trace.suffix == ".gz" else data)
self.assertEqual(self.json_output(trace), expected)
def test_utf8_bom_summary_preserves_the_digest(self):
expected = self.json_output(self.trace)
summary = self.trace.with_name("aps5-summary.jsonl")
summary.write_text(summary.read_text(encoding="utf-8"), encoding="utf-8-sig")
self.assertEqual(self.json_output(self.trace), expected)
def test_utf8_bom_stacks_preserve_comments_and_frame_names(self):
for text in (STACKS, "root;module\u03a9+0x1 2\n"):
with self.subTest(comments=text.startswith("#")):
self.stacks.write_text(text, encoding="utf-8")
expected = self.json_output(self.trace, "--stacks", str(self.stacks))
expected_stacks = trace_digest.load_stacks(self.stacks)
self.stacks.write_text(text, encoding="utf-8-sig")
self.assertEqual(self.json_output(self.trace, "--stacks", str(self.stacks)), expected)
self.assertEqual(trace_digest.load_stacks(self.stacks), expected_stacks)
def test_summary_is_the_line_of_this_trace(self):
self.assertEqual(self.result()["summary"]["trace"], "aps5-trace-abc-7.json.gz")
def test_decompressing_trace_preserves_the_complete_command_line_digest(self):
uncompressed = self.trace.with_suffix("")
uncompressed.write_bytes(gzip.decompress(self.trace.read_bytes()))
results = []
for trace in (self.trace, uncompressed):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(trace), "--json"]), 0)
results.append(json.loads(output.getvalue()))
self.assertEqual((results[0]["frames"], results[0]["window_ms"]), (3, 40.0))
self.assertEqual(results[1], results[0])
def test_compressed_trace_can_use_a_summary_named_for_its_plain_copy(self):
summary = dict(SUMMARY, trace=self.trace.with_suffix("").name)
path = self.trace.with_name("named-summary.jsonl")
path.write_text(json.dumps(summary) + "\n", encoding="utf-8")
self.assertEqual(trace_digest.load_summary(path, self.trace), summary)
def test_exact_trace_filename_wins_over_a_later_compression_alias(self):
uncompressed = self.trace.with_suffix("")
exact = dict(SUMMARY, trace=uncompressed.name, commit="exact")
alias = dict(SUMMARY, commit="alias")
path = self.trace.with_name("aps5-summary.jsonl")
path.write_text(json.dumps(exact) + "\n" + json.dumps(alias) + "\n", encoding="utf-8")
self.assertEqual(trace_digest.load_summary(None, uncompressed), exact)
self.assertEqual(trace_digest.load_summary(None, self.trace), alias)
def test_decompressed_trace_uses_the_latest_matching_alias_and_skips_other_traces(self):
uncompressed = self.trace.with_suffix("")
latest = dict(SUMMARY, commit="latest")
unrelated = dict(SUMMARY, trace="other.json.gz", commit="other")
path = self.trace.with_name("aps5-summary.jsonl")
path.write_text("\n".join(json.dumps(line) for line in (SUMMARY, latest, unrelated)) + "\n", encoding="utf-8")
self.assertEqual(trace_digest.load_summary(None, uncompressed), latest)
def test_explicit_summary_selects_the_matching_trace(self):
summary = Path(self.directory.name) / "relocated-summary.jsonl"
other = dict(SUMMARY, trace="other.json.gz", presents=99)
summary.write_text(json.dumps(SUMMARY) + "\n" + json.dumps(other) + "\n", encoding="utf-8")
selected = trace_digest.load_summary(summary, self.trace)
self.assertEqual(selected["trace"], self.trace.name)
self.assertEqual(selected["presents"], 3)
def test_explicit_summary_without_matching_trace_is_not_used(self):
summary = Path(self.directory.name) / "unrelated-summary.jsonl"
summary.write_text(json.dumps(dict(SUMMARY, trace="other.json.gz")) + "\n", encoding="utf-8")
self.assertIsNone(trace_digest.load_summary(summary, self.trace))
def test_frames_end_at_the_window(self):
result = self.result()
self.assertEqual(result["frames"], 3)
self.assertAlmostEqual(result["window_ms"], 40.0)
def test_command_line_prints_non_ascii_names_on_a_legacy_console(self):
events = [dict(event, args={"name": "メイン"}) if event["name"] == "thread_name" and event["tid"] == 1 else event for event in EVENTS]
with gzip.open(self.trace, "wt", encoding="utf-8") as handle:
json.dump({"traceEvents": events}, handle)
tool = Path(trace_digest.__file__).resolve()
result = subprocess.run([sys.executable, str(tool), str(self.trace)], capture_output=True,
env=dict(os.environ, PYTHONIOENCODING="cp1252"), timeout=60)
self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", "replace"))
self.assertIn("メイン", result.stdout.decode("utf-8"))
def test_slowest_frame_lists_its_slices(self):
slowest = self.result()["slowest_frames"]
self.assertEqual([frame["present"] for frame in slowest], [601, 600])
self.assertAlmostEqual(slowest[0]["ms"], 20.0)
largest = {(entry["name"], entry["thread"]): entry["ms"] for entry in slowest[0]["slices"]}
self.assertAlmostEqual(largest[("Driver.Packet", "queue worker 0x0")], 16.0)
self.assertAlmostEqual(largest[("Driver.Present", "presenter")], 16.0)
self.assertAlmostEqual(largest[("Driver.Present.inflight_wait", "presenter")], 14.5)
def test_markdown_thread_names_cannot_split_table_cells_or_rows(self):
name = "worker|<gpu>\nrow & more"
phase = "phase|<tag>\r\nnext"
trace = trace_digest.Trace({"traceEvents": [
{"ph": "M", "name": "thread_name", "tid": 1, "args": {"name": name}},
complete(1, phase, 0, 1000),
]})
result = trace_digest.digest(trace)
self.assertEqual(result["threads"][0]["thread"], name)
self.assertEqual(result["threads"][0]["top"][0]["name"], phase)
rows = [line for line in trace_digest.markdown(result).splitlines() if line.startswith("|")]
self.assertEqual(len(rows), 3)
self.assertTrue(all(line.count("|") == 7 for line in rows), rows)
self.assertIn("worker&#124;&lt;gpu&gt;<br>row &amp; more", rows[2])
self.assertIn("phase&#124;&lt;tag&gt;<br>next", rows[2])
def test_threads_split_busy_and_wait(self):
threads = {thread["thread"]: thread for thread in self.result()["threads"]}
worker = threads["queue worker 0x0"]
self.assertAlmostEqual(worker["wait_ms_per_frame"], 6.0 / 3)
self.assertAlmostEqual(worker["busy_ms_per_frame"], 19.0 / 3)
self.assertAlmostEqual(worker["untraced_ms_per_frame"], 15.0 / 3)
self.assertAlmostEqual(worker["folded_ms_per_frame"], 1.5 / 3)
self.assertEqual(worker["top"][0]["name"], "Driver.Packet")
presenter = threads["presenter"]
self.assertAlmostEqual(presenter["wait_ms_per_frame"], 15.5 / 3)
self.assertAlmostEqual(presenter["busy_ms_per_frame"], 2.5 / 3)
def test_thread_totals_only_count_time_inside_the_frame_window(self):
events = [
complete(1, "Driver.Packet", -5000, 20000),
complete(1, "GpuMutex.wait.draw", -2000, 4000),
complete(1, "Driver.Packet.after", 12000, 1000),
complete(2, "Driver.WorkerWait", -10000, 2000),
{"name": "present", "ph": "i", "ts": 0, "tid": 1, "args": {"present": 600}},
{"name": "present", "ph": "i", "ts": 10000, "tid": 1, "args": {"present": 601}},
]
result = trace_digest.digest(trace_digest.Trace(events))
active, outside = result["threads"]
self.assertEqual(active["busy_ms_per_frame"], 8.0)
self.assertEqual(active["wait_ms_per_frame"], 2.0)
self.assertEqual(active["busy_pct"], 80.0)
self.assertEqual(active["wait_pct"], 20.0)
self.assertEqual(active["top"], [{"name": "Driver.Packet", "ms_per_frame": 10.0}])
self.assertEqual(outside["busy_ms_per_frame"], 0.0)
self.assertEqual(outside["wait_ms_per_frame"], 0.0)
self.assertEqual(outside["untraced_ms_per_frame"], 10.0)
self.assertEqual(outside["top"], [])
self.assertEqual(result["top_slices_ms_per_frame"], [
{"name": "Driver.Packet", "ms": 10.0},
{"name": "GpuMutex.wait.draw", "ms": 2.0},
])
def test_thread_totals_use_the_summary_end_after_the_last_present(self):
trace = trace_digest.Trace([
complete(1, "Driver.Packet", 1000, 20000),
{"name": "present", "ph": "i", "ts": 0, "tid": 1, "args": {"present": 600}},
])
result = trace_digest.digest(trace, {"window": {"ms": 10.0}})
thread = result["threads"][0]
self.assertEqual(result["frames"], 1)
self.assertEqual(thread["busy_ms_per_frame"], 9.0)
self.assertEqual(thread["busy_pct"], 90.0)
self.assertEqual(thread["untraced_ms_per_frame"], 1.0)
def test_largest_slices_per_frame(self):
largest = self.result()["top_slices_ms_per_frame"]
self.assertEqual(largest[0]["name"], "Driver.Packet")
self.assertAlmostEqual(largest[0]["ms"], 21.0 / 3)
def test_longest_waits(self):
waits = self.result()["longest_waits"]
self.assertEqual((waits[0]["name"], waits[0]["thread"], waits[0]["present"]), ("Driver.Present.inflight_wait", "presenter", 601))
self.assertAlmostEqual(waits[0]["ms"], 14.5)
self.assertEqual(waits[1]["name"], "Driver.WorkerWait")
def test_longest_waits_omit_slices_outside_capture_in_cli(self):
events = [EVENTS[1], *EVENTS[-3:],
complete(1, "Inside.wait", 1000, 2000),
complete(3, "Before.wait", -100000, 90000),
complete(4, "After.wait", 40000, 90000),
complete(5, "EndsAtStart.wait", -1000, 1000),
complete(6, "StartsAtEnd.wait", 40000, 1000)]
plain = self.trace.with_suffix("")
plain.write_text(json.dumps({"traceEvents": events}), encoding="utf-8")
self.trace.write_bytes(gzip.compress(plain.read_bytes()))
for path in (self.trace, plain):
for output in ([], ["--json"]):
with self.subTest(path=path.name, output=output):
result = subprocess.run([sys.executable, trace_digest.__file__, str(path), "--top", "1", *output],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual((result.returncode, result.stderr), (0, ""))
if output:
report = json.loads(result.stdout)
self.assertEqual(report["longest_waits"], [
{"name": "Inside.wait", "thread": "queue worker 0x0", "ms": 2.0, "at_ms": 1.0, "present": 600}])
else:
self.assertIn("- Inside.wait (queue worker 0x0) 2.0 ms at 1 ms, present 600", result.stdout)
for name in ("Before.wait", "After.wait", "EndsAtStart.wait", "StartsAtEnd.wait"):
self.assertNotIn(name, result.stdout)
def test_longest_waits_rank_only_the_overlap_with_capture(self):
events = [dict(EVENTS[-3], ts=10000), dict(EVENTS[-2], ts=30000),
complete(1, "Leading.wait", -100000, 111000),
complete(2, "Inside.wait", 12000, 4000),
complete(3, "Trailing.wait", 28000, 90000),
complete(4, "Spanning.wait", -100000, 230000),
complete(4, "Nested.wait", 11000, 1000)]
report = trace_digest.digest(trace_digest.Trace(events), top=4)
self.assertEqual(report["longest_waits"], [
{"name": "Spanning.wait", "thread": "tid 4", "ms": 20.0, "at_ms": 10.0, "present": 600},
{"name": "Inside.wait", "thread": "tid 2", "ms": 4.0, "at_ms": 12.0, "present": 600},
{"name": "Trailing.wait", "thread": "tid 3", "ms": 2.0, "at_ms": 28.0, "present": 600},
{"name": "Leading.wait", "thread": "tid 1", "ms": 1.0, "at_ms": 10.0, "present": 600}])
threads = {thread["thread"]: thread for thread in report["threads"]}
for wait in report["longest_waits"]:
self.assertEqual(wait["ms"], threads[wait["thread"]]["wait_ms_per_frame"])
def test_longest_waits_clip_to_summary_end(self):
trace = trace_digest.Trace([EVENTS[-3],
complete(1, "Trailing.wait", 8000, 20000),
complete(2, "Inside.wait", 1000, 3000)])
report = trace_digest.digest(trace, {"window": {"ms": 10.0}}, top=1)
self.assertEqual(report["longest_waits"], [
{"name": "Inside.wait", "thread": "tid 2", "ms": 3.0, "at_ms": 1.0, "present": 600}])
def test_longest_waits_without_frames_keep_the_full_slices(self):
for duration in (0, 3000):
with self.subTest(duration=duration):
trace = trace_digest.Trace([complete(1, "Worker.wait", -1000, duration)])
self.assertEqual(trace_digest.digest(trace)["longest_waits"], [
{"name": "Worker.wait", "thread": "tid 1", "ms": duration / 1000.0, "at_ms": -1.0, "present": None}])
def test_wait_subscope_without_parent_is_not_busy(self):
events = [EVENTS[1], complete(1, "Driver.WorkerWait.dequeue_order", 0, 4000),
complete(1, "Driver.Packet", 4000, 5000), EVENTS[-3], EVENTS[-2]]
result = trace_digest.digest(trace_digest.Trace({"traceEvents": events}))
worker = result["threads"][0]
self.assertAlmostEqual(worker["wait_pct"], 40.0)
self.assertAlmostEqual(worker["busy_pct"], 50.0)
self.assertEqual(result["longest_waits"][0]["name"], "Driver.WorkerWait.dequeue_order")
self.assertEqual(worker["top"][0]["name"], "Driver.Packet")
def test_nested_wait_subscope_does_not_double_count(self):
events = [EVENTS[1], complete(1, "Driver.WorkerWait", 0, 4000),
complete(1, "Driver.WorkerWait.dequeue_order", 1000, 2000),
complete(1, "Driver.Packet", 4000, 5000), EVENTS[-3], EVENTS[-2]]
result = trace_digest.digest(trace_digest.Trace({"traceEvents": events}))
worker = result["threads"][0]
self.assertAlmostEqual(worker["wait_pct"], 40.0)
self.assertAlmostEqual(worker["busy_pct"], 50.0)
self.assertEqual(len(result["longest_waits"]), 1)
def test_gpu_mutex_waits_with_site_names(self):
for site in ("other", "dispatch", "indirect", "draw", "hook", "wait", "label", "flush", "present", "fill", "copy", "end", "try"):
with self.subTest(site=site):
name = f"GpuMutex.wait.{site}"
events = [
complete(1, "Driver.Packet", 0, 10000),
complete(1, name, 1000, 4000),
complete(1, "Driver.Packet.memory_wait", 2000, 1000),
{"name": "present", "ph": "i", "ts": 0, "tid": 1, "args": {"present": 600}},
{"name": "present", "ph": "i", "ts": 10000, "tid": 1, "args": {"present": 601}},
]
result = trace_digest.digest(trace_digest.Trace({"traceEvents": events}))
thread = result["threads"][0]
self.assertAlmostEqual(thread["wait_ms_per_frame"], 4.0)
self.assertAlmostEqual(thread["busy_ms_per_frame"], 6.0)
self.assertAlmostEqual(thread["wait_pct"], 40.0)
self.assertAlmostEqual(thread["busy_pct"], 60.0)
self.assertEqual(result["longest_waits"], [
{"name": name, "thread": "tid 1", "ms": 4.0, "at_ms": 1.0, "present": 600},
])
self.assertIn(f"- {name} (tid 1) 4.0 ms", trace_digest.markdown(result))
def test_top_level_gpu_mutex_wait_is_not_busy_work(self):
events = [
complete(1, "GpuMutex.wait.draw", 0, 4000),
complete(1, "GpuMutex.hold.draw", 4000, 6000),
{"name": "present", "ph": "i", "ts": 0, "tid": 1, "args": {"present": 600}},
{"name": "present", "ph": "i", "ts": 10000, "tid": 1, "args": {"present": 601}},
]
result = trace_digest.digest(trace_digest.Trace({"traceEvents": events}))
thread = result["threads"][0]
self.assertAlmostEqual(thread["busy_ms_per_frame"], 6.0)
self.assertAlmostEqual(thread["wait_ms_per_frame"], 4.0)
self.assertEqual(thread["top"], [{"name": "GpuMutex.hold.draw", "ms_per_frame": 6.0}])
def test_invalid_stack_weights_fail_without_a_digest(self):
for row in ("worker;draw -1", "worker;draw 0", "worker;draw nope", "worker;;draw 1", " 1"):
with self.subTest(row=row):
self.stacks.write_text("# header\n" + row + "\n", encoding="utf-8")
output, errors = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
with self.assertRaises(SystemExit) as exit:
trace_digest.main([str(self.trace), "--stacks", str(self.stacks), "--json"])
self.assertEqual(exit.exception.code, 2)
self.assertEqual(output.getvalue(), "")
self.assertIn(str(self.stacks) + ":2:", errors.getvalue())
self.assertNotIn("Traceback", errors.getvalue())
def test_stacks_self_and_total(self):
stacks = self.result()["stacks"]
self.assertEqual(stacks["samples"], 5)
self.assertAlmostEqual(stacks["truncated_pct"], 20.0)
self.assertEqual(stacks["self"][0], {"frame": "a.prx+0x20", "pct": 60.0})
total = {entry["frame"]: entry["pct"] for entry in stacks["total"]}
self.assertEqual(stacks["common_frames"], 2)
self.assertNotIn("a.prx+0x10", total)
self.assertEqual(total, {"a.prx+0x20": 60.0, "a.prx+0x30": 40.0, "a.prx+0x40": 20.0})
def test_largest_gpu_batches_with_their_recording(self):
def tagged(tid, name, ts, dur, batch):
return dict(complete(tid, name, ts, dur), args={"batch": batch})
events = EVENTS + [
{"name": "thread_name", "ph": "M", "ts": 0, "pid": 7, "tid": 3, "args": {"name": "gpu"}},
tagged(1, "Driver.Packet.dispatch", 4500, 2000, 11),
tagged(1, "Driver.Packet.dispatch", 13000, 3000, 12),
tagged(3, "batch", 6000, 9000, 11),
tagged(3, "draw", 7000, 4000, 11),
tagged(3, "batch", 16000, 3000, 12),
]
with gzip.open(self.trace, "wt", encoding="utf-8") as handle:
json.dump({"traceEvents": events}, handle)
batches = self.result()["largest_gpu_batches"]
self.assertEqual([batch["batch"] for batch in batches], [11, 12])
self.assertAlmostEqual(batches[0]["ms"], 9.0)
self.assertEqual(batches[0]["present"], 600)
self.assertEqual(batches[0]["gpu_ranges"], [{"name": "draw", "ms": 4.0}])
self.assertEqual(batches[0]["recorded_by"], [{"name": "Driver.Packet.dispatch", "thread": "queue worker 0x0", "ms": 2.0}])
self.assertEqual(batches[1]["present"], 601)
output = io.StringIO()
with contextlib.redirect_stdout(output):
trace_digest.main([str(self.trace)])
self.assertIn("**Largest GPU batches**", output.getvalue())
def test_no_batch_section_without_batch_ids(self):
self.assertNotIn("largest_gpu_batches", self.result())
def test_negative_list_limits_are_rejected_and_zero_is_supported(self):
for option in ("--frames", "--top"):
with self.subTest(option=option):
output, errors = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
with self.assertRaises(SystemExit) as exit:
trace_digest.main([str(self.trace), option, "-1", "--json"])
self.assertEqual(exit.exception.code, 2)
self.assertEqual(output.getvalue(), "")
self.assertIn(option, errors.getvalue())
self.assertIn("nonnegative", errors.getvalue())
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.trace), "--frames", "0", "--top", "0", "--json"]), 0)
result = json.loads(output.getvalue())
self.assertEqual(result["slowest_frames"], [])
self.assertEqual(result["top_slices_ms_per_frame"], [])
self.assertEqual(result["longest_waits"], [])
def test_command_line_markdown_and_json(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.trace), "--stacks", str(self.stacks)]), 0)
text = output.getvalue()
for heading in ("**Slowest frames**", "**Threads**", "**Largest slices**", "**Longest waits**", "**Worker stacks**"):
self.assertIn(heading, text)
self.assertLess(len(text.encode("utf-8")), 5000)
output = io.StringIO()
with contextlib.redirect_stdout(output):
trace_digest.main([str(self.trace), "--json"])
self.assertEqual(json.loads(output.getvalue())["frames"], 3)
def test_fractional_adjacent_slices_remain_separate_in_command_output(self):
for first, second in (("Driver.Packet", "Driver.Completion"),
("Driver.WorkerWait", "Driver.CompletionWait")):
with self.subTest(first=first):
events = [EVENTS[1], EVENTS[-3],
complete(1, first, 10000.1, 20000.2),
complete(1, second, 30000.3, 20000.0)]
with gzip.open(self.trace, "wt", encoding="utf-8") as handle:
json.dump({"traceEvents": events}, handle)
summary = dict(SUMMARY, window=dict(SUMMARY["window"], ms=50.0003))
self.trace.with_name("aps5-summary.jsonl").write_text(json.dumps(summary), encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.trace), "--json"]), 0)
result = json.loads(output.getvalue())
thread = next(thread for thread in result["threads"] if thread["thread"] == "queue worker 0x0")
field = "wait" if trace_digest.is_wait(first) else "busy"
self.assertAlmostEqual(thread[field + "_ms_per_frame"], 40.0002)
self.assertAlmostEqual(thread[field + "_pct"], 100 * 40.0002 / 50.0003)
self.assertAlmostEqual(thread["untraced_ms_per_frame"], 10.0001)
if field == "busy":
self.assertEqual({entry["name"] for entry in thread["top"]}, {first, second})
else:
self.assertEqual({entry["name"] for entry in result["longest_waits"]}, {first, second})
def test_one_nanosecond_nested_wait_remains_nested(self):
trace = trace_digest.Trace({"traceEvents": [
complete(1, "Driver.WorkerWait", 10000.1, 20000.2),
complete(1, "Driver.InnerWait", 30000.299, 0.001),
complete(1, "Driver.Completion", 30000.3, 20000.0)]})
self.assertEqual([(entry[3], entry[4]) for entry in trace.slices[1]],
[(0, False), (1, True), (0, False)])
def test_missing_summary_file_is_an_error(self):
missing = Path(self.directory.name) / "missing.jsonl"
error = io.StringIO()
with contextlib.redirect_stderr(error), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(SystemExit) as raised:
trace_digest.main([str(self.trace), "--summary", str(missing)])
self.assertEqual(raised.exception.code, 2)
self.assertIn("summary file not found", error.getvalue())
def test_thread_filter_keeps_frame_bounds_and_capture_summary(self):
command = [sys.executable, trace_digest.__file__, str(self.trace)]
result = subprocess.run([*command, "--thread", "queue worker 0x0", "--json"], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
report = json.loads(result.stdout)
self.assertEqual(report["frames"], 3)
self.assertEqual(report["summary"], SUMMARY)
self.assertEqual(report["thread_filter"], ["queue worker 0x0"])
self.assertEqual([thread["thread"] for thread in report["threads"]], ["queue worker 0x0"])
self.assertTrue(all("Present" not in entry["name"] for entry in report["top_slices_ms_per_frame"]))
self.assertTrue(all(wait["thread"] == "queue worker 0x0" for wait in report["longest_waits"]))
result = subprocess.run([*command, "--thread", "presenter", "--thread", "queue worker 0x0", "--json"],
capture_output=True, text=True)
unfiltered = trace_digest.digest(trace_digest.load_trace(self.trace), SUMMARY)
report = json.loads(result.stdout)
del report["thread_filter"]
self.assertEqual(report, unfiltered)
result = subprocess.run([*command, "--thread", "presenter"], capture_output=True, text=True)
self.assertIn("Thread filter:", result.stdout)
self.assertIn("capture-wide", result.stdout)
result = subprocess.run([*command, "--thread", "misspelled"], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertIn("unknown thread", result.stderr)
self.assertEqual(result.stdout, "")
result = subprocess.run([sys.executable, trace_digest.__file__, str(self.trace.with_suffix(".jsonl")),
"--thread", "presenter"], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertIn("event trace", result.stderr)
def interval_line(start, end, p99, gpu_ms, **changes):
line = dict(SUMMARY, window={"spec": "summary_every 30", "first_present": 0, "ms": (end - start) * 1000.0}, interval_start_s=start, interval_end_s=end,
presents=1800, frame_ms={"p50": 16.6, "p90": 18.0, "p99": p99, "max": p99 + 5.0}, gpu_busy_ms_per_present=gpu_ms, trace="")
line.update(changes)
return line
class IntervalDigestTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.path = Path(self.directory.name) / "aps5-summary.jsonl"
lines = [SUMMARY, interval_line(0.0, 30.0, 20.0, 5.0), interval_line(30.0, 60.5, 40.0, 10.0, trace_dropped=True),
interval_line(60.5, 90.5, 0.0, 0.0, worker_busy_pct={}, top_phases_ms_per_frame={}), interval_line(90.5, 120.5, 20.0, 5.0, gpu="Other GPU")]
self.path.write_text("\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8")
def tearDown(self):
self.directory.cleanup()
def test_window_lines_are_skipped(self):
self.assertEqual(len(trace_digest.load_intervals(self.path)), 4)
def test_utf8_bom_intervals_preserve_the_command_line_digest(self):
expected = trace_digest.digest_intervals(trace_digest.load_intervals(self.path))
self.path.write_text(self.path.read_text(encoding="utf-8"), encoding="utf-8-sig")
result = subprocess.run([sys.executable, trace_digest.__file__, str(self.path), "--json"],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), expected)
def test_rows(self):
result = trace_digest.digest_intervals(trace_digest.load_intervals(self.path))
first, second, third, _ = result["intervals"]
self.assertEqual((first["start_s"], first["end_s"], first["presents"]), (0.0, 30.0, 1800))
self.assertEqual(first["busiest_worker"], {"thread": "queue worker 0x0", "pct": 50.0})
self.assertEqual(first["top_phase"], {"name": "Driver.Packet", "ms_per_frame": 7.0})
self.assertTrue(second["trace_dropped"])
self.assertIsNone(third["busiest_worker"])
self.assertIsNone(third["top_phase"])
self.assertEqual(result["identity_changed_at"], [3])
def test_identity_changes_are_reported_where_they_happen(self):
lines = [interval_line(30.0 * index, 30.0 * (index + 1), 20.0, 5.0, commit=commit)
for index, commit in enumerate(("a", "a", "b", "b", "a"))]
result = trace_digest.digest_intervals(lines)
self.assertEqual(result["identity_changed_at"], [2, 4])
self.assertIn("build, environment, host, resolution or title metadata changed at interval 3, 5", trace_digest.markdown_intervals(result).splitlines())
def test_machine_and_title_identity_fields_are_retained_and_compared(self):
metadata = {"cpu": "CPU A", "cpu_threads": 16, "memory_mib": 32768,
"title_id": "PPSA00001", "title": "Title One", "content_version": "01.000.000"}
first = interval_line(0.0, 30.0, 20.0, 5.0, **metadata)
for key, value in (("cpu", "CPU B"), ("cpu_threads", 32), ("memory_mib", 65536),
("title_id", "PPSA00002"), ("title", "Title Two"), ("content_version", "02.000.000")):
with self.subTest(key=key):
changed = dict(first, **{key: value})
result = trace_digest.digest_intervals([first, first, changed, changed, first])
self.assertEqual({key: result["identity"].get(key) for key in metadata}, metadata)
self.assertEqual(result["identity_changed_at"], [2, 4])
self.assertIn("changed at interval 3, 5", trace_digest.markdown_intervals(result))
def test_optional_identity_appearance_disappearance_and_return(self):
absent = interval_line(0.0, 30.0, 20.0, 5.0)
for key, value in (("cpu", "CPU A"), ("cpu_threads", 0), ("memory_mib", 0),
("title_id", "PPSA00001"), ("title", "Title One"), ("content_version", "")):
with self.subTest(key=key, value=value):
present = dict(absent, **{key: value})
result = trace_digest.digest_intervals([absent, present, present, absent, present])
self.assertEqual(result["identity_changed_at"], [1, 3, 4])
self.assertIn(key, result["identity"])
self.assertIsNone(result["identity"][key])
self.assertIn("changed at interval 2, 4, 5", trace_digest.markdown_intervals(result))
def test_stable_or_absent_optional_identity_does_not_report_changes(self):
absent = interval_line(0.0, 30.0, 20.0, 5.0)
metadata = {"cpu": "CPU A", "cpu_threads": 0, "memory_mib": 0,
"title_id": "PPSA00001", "title": "Title One", "content_version": ""}
for line in (absent, dict(absent, **metadata)):
with self.subTest(line=line):
result = trace_digest.digest_intervals([line, line])
self.assertEqual(result["identity_changed_at"], [])
self.assertNotIn("changed at interval", trace_digest.markdown_intervals(result))
def test_resolution_changes_are_reported(self):
first = interval_line(0.0, 30.0, 20.0, 5.0)
result = trace_digest.digest_intervals([first, dict(first, resolution="1280x720")])
self.assertEqual(result["identity_changed_at"], [1])
self.assertIn("build, environment, host, resolution or title metadata changed at interval 2",
trace_digest.markdown_intervals(result))
def test_cli_retains_machine_and_title_identity_and_reports_changes(self):
metadata = {"cpu": "CPU A", "cpu_threads": 16, "memory_mib": 32768,
"title_id": "PPSA00001", "title": "Title One", "content_version": "01.000.000"}
first = interval_line(0.0, 30.0, 20.0, 5.0, **metadata)
second = interval_line(30.0, 60.0, 40.0, 10.0, **dict(metadata, content_version="02.000.000"))
self.path.write_text("\n".join(json.dumps(line) for line in (first, second)) + "\n", encoding="utf-8")
result = subprocess.run([sys.executable, trace_digest.__file__, str(self.path), "--json"],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
report = json.loads(result.stdout)
self.assertEqual({key: report["identity"].get(key) for key in metadata}, metadata)
self.assertEqual(report["identity_changed_at"], [1])
without_metadata = [{key: value for key, value in line.items() if key not in metadata} for line in (first, second)]
unchanged_metrics = trace_digest.digest_intervals(without_metadata)
self.assertEqual(report["intervals"], unchanged_metrics["intervals"])
result = subprocess.run([sys.executable, trace_digest.__file__, str(self.path)],
capture_output=True, text=True, encoding="utf-8", timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("build, environment, host, resolution or title metadata changed at interval 2", result.stdout)
expected = trace_digest.markdown_intervals(unchanged_metrics)
self.assertEqual(result.stdout[result.stdout.index("| # |"):], expected[expected.index("| # |"):])
def test_sparkline_scales_to_the_largest_value(self):
self.assertEqual(trace_digest.sparkline([0.0, 5.0, 10.0]), ".+@")
self.assertEqual(trace_digest.sparkline([0.0, 0.0]), "..")
self.assertEqual(trace_digest.sparkline([]), "")
def test_largest_phase_does_not_depend_on_json_key_order(self):
phases = [("Driver.Completion", 1.0), ("Driver.Packet", 7.0), ("Driver.Present", 2.0)]
for offset in range(len(phases)):
for sort_keys in (False, True):
with self.subTest(offset=offset, sort_keys=sort_keys):
line = interval_line(0.0, 30.0, 20.0, 5.0,
top_phases_ms_per_frame=dict(phases[offset:] + phases[:offset]))
self.path.write_text(json.dumps(line, sort_keys=sort_keys) + "\n", encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.path), "--json"]), 0)
result = json.loads(output.getvalue())
self.assertEqual(result["intervals"][0]["top_phase"],
{"name": "Driver.Packet", "ms_per_frame": 7.0})
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.path)]), 0)
self.assertIn("| Driver.Packet 7.0 |", output.getvalue())
def test_command_line_table_and_json(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.path)]), 0)
text = output.getvalue()
self.assertIn("**4 intervals**, 0.0 to 120.5 s, 7200 presents", text)
self.assertIn("| 2 | 30-60 (dropped) | 1800 | 16.6 | 18.0 | 40.0 | 45.0 | 10.0 | 50 % | Driver.Packet 7.0 |", text)
self.assertIn("p99 ms +@.+ 0-40.0", text)
self.assertIn("changed at interval 4", text)
output = io.StringIO()
with contextlib.redirect_stdout(output):
trace_digest.main([str(self.path), "--json"])
self.assertEqual(len(json.loads(output.getvalue())["intervals"]), 4)
def test_file_without_intervals(self):
self.path.write_text(json.dumps(SUMMARY) + "\n", encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
trace_digest.main([str(self.path)])
self.assertIn("no interval lines", output.getvalue())
def test_interval_phase_names_preserve_json_and_markdown_table_structure(self):
for name, escaped in (("Draw|<pass>&", "Draw&#124;&lt;pass&gt;&amp;"),
("Draw\r\npass", "Draw<br>pass"),
("Draw\rpass\nend", "Draw<br>pass<br>end")):
with self.subTest(name=name):
line = interval_line(0.0, 30.0, 20.0, 5.0, top_phases_ms_per_frame={name: 7.5})
self.path.write_text(json.dumps(line) + "\n", encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.path), "--json"]), 0)
row = json.loads(output.getvalue())["intervals"][0]
self.assertEqual(row["top_phase"], {"name": name, "ms_per_frame": 7.5})
self.assertEqual(row["frame_ms"], line["frame_ms"])
self.assertEqual(row["gpu_busy_ms_per_present"], 5.0)
self.assertEqual((row["start_s"], row["end_s"], row["presents"]), (0.0, 30.0, 1800))
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(trace_digest.main([str(self.path)]), 0)
rows = [line for line in output.getvalue().splitlines() if line.startswith("|")]
self.assertEqual(len(rows), 3)
self.assertTrue(all(line.count("|") == 11 for line in rows), rows)
cells = [cell.strip() for cell in rows[2].split("|")[1:-1]]
self.assertEqual(cells, ["1", "0-30", "1800", "16.6", "18.0", "20.0", "25.0", "5.0", "50 %", escaped + " 7.5"])
def test_bom_encoded_stacks_load(self):
with tempfile.TemporaryDirectory() as directory:
stacks_file = Path(directory) / "stacks.txt"
stacks_file.write_bytes(b"\xef\xbb\xbf# samples=1 depth=1 truncated=0\nmodule.prx+0x10 1\n")
loaded = trace_digest.load_stacks(stacks_file)
self.assertEqual(loaded, [(["module.prx+0x10"], 1)])
def test_bom_encoded_intervals_load(self):
with tempfile.TemporaryDirectory() as directory:
intervals_file = Path(directory) / "intervals.jsonl"
intervals_file.write_bytes(b"\xef\xbb\xbf" + json.dumps({"interval_start_s": 0.0, "interval_end_s": 10.0, "presents": 600}).encode("utf-8") + b"\n")
loaded = trace_digest.load_intervals(intervals_file)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0]["interval_start_s"], 0.0)
def test_bom_encoded_summary_loads(self):
with tempfile.TemporaryDirectory() as directory:
summary_file = Path(directory) / "aps5-summary.jsonl"
summary_file.write_bytes(b"\xef\xbb\xbf" + json.dumps(SUMMARY).encode("utf-8") + b"\n")
loaded = trace_digest.load_summary(str(summary_file), SUMMARY["trace"])
self.assertEqual(loaded["commit"], SUMMARY["commit"])
def test_main_reconfigures_streams(self):
class Stream:
def __init__(self):
self.encoding = None
self.errors = None
def reconfigure(self, encoding=None, errors=None):
self.encoding = encoding
self.errors = errors
def write(self, text):
pass
def flush(self):
pass
out, err = Stream(), Stream()
with mock.patch.object(trace_digest.sys, "stdout", out), mock.patch.object(trace_digest.sys, "stderr", err):
try:
trace_digest.main([])
except SystemExit:
pass
self.assertEqual(out.encoding, "utf-8")
self.assertEqual(out.errors, "backslashreplace")
self.assertEqual(err.encoding, "utf-8")
self.assertEqual(err.errors, "backslashreplace")
if __name__ == "__main__":
unittest.main()