From 8ee49632b059b7c98e57a0f9155f133f0c85bc85 Mon Sep 17 00:00:00 2001 From: xTRACKGOD <135815363+xTRACKGOD@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:17:58 +0530 Subject: [PATCH] fix(tools): detect host and title changes in interval digests --- docs/dev/BUILD.md | 2 +- tools/tests/test_trace_digest.py | 66 +++++++++++++++++++++++++++++++- tools/trace_digest.py | 5 ++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index 8384d87e1f..cbe6d48000 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -246,7 +246,7 @@ The switch combines with `APS5_TRACE_FRAMES` or `APS5_TRACE_SECONDS`. The interv Set `APS5_TRACE_BENCH=1` together with `APS5_TRACE_SUMMARY_EVERY` to also keep every interval's frame times for a benchmark report. At each interval the presenter hands the kept intervals to a writer thread, which formats and compresses the file and rewrites `aps5-bench--.json.gz` in `APS5_TRACE_DIR` or the working directory: one JSON object with `format` (`aps5-bench/1`), the identity fields of the summary line (`commit`, `dirty`, `env`, `gpu`, `driver`, `os`, `cpu`, `cpu_threads`, `memory_mib` and, with `param.json`, `title_id`, `title` and `content_version`), `intervals_dropped` and `intervals`. Each interval has `start_s`, `end_s`, `first_present`, `resolution`, `gpu_busy_ms` (the interval's total) and `frame_us`, the whole microseconds between consecutive presents. The file keeps the last 240 intervals; `intervals_dropped` counts older ones it no longer holds. Any other value, or the switch without `APS5_TRACE_SUMMARY_EVERY`, fails the first present. The file is written to `.tmp` and then moved over the previous one, so a crash or kill during a rewrite leaves the last complete report. Only the newest report waits for the writer: one handed over while an older one is still pending replaces it. The frame times cost 4 bytes per present in memory. -`python tools/trace_digest.py aps5-summary.jsonl` prints the interval lines of such a file as one row per interval (frame p50, p90, p99, max, GPU busy per present, the busiest queue worker and the largest phase) and a text sparkline of the frame p99 and of the GPU busy time; lines of window captures in the file are skipped, and a change of commit, environment, GPU, driver or OS between intervals is reported. `--json` prints the rows as JSON. +`python tools/trace_digest.py aps5-summary.jsonl` prints the interval lines of such a file as one row per interval (frame p50, p90, p99, max, GPU busy per present, the busiest queue worker and the largest phase) and a text sparkline of the frame p99 and of the GPU busy time; lines of window captures in the file are skipped, and changes to build, environment, host, resolution or title metadata between intervals are reported. `--json` prints the rows, the first interval's identity and the indices of identity changes as JSON. With `APS5_TRACE_SUMMARY_EVERY` set, the recorder writes a timestamp pair per batch for the whole run and every `PerformanceTimer` scope adds its time to the thread's totals, two clock reads per scope. A thread's totals are read while it runs, so a scope that straddles an interval boundary is counted in one interval or the other. diff --git a/tools/tests/test_trace_digest.py b/tools/tests/test_trace_digest.py index 8099ca9eee..9e5f26c1bc 100644 --- a/tools/tests/test_trace_digest.py +++ b/tools/tests/test_trace_digest.py @@ -652,7 +652,71 @@ class IntervalDigestTests(unittest.TestCase): 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("commit, environment, GPU, driver or OS changed at interval 3, 5", trace_digest.markdown_intervals(result).splitlines()) + 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]), ".+@") diff --git a/tools/trace_digest.py b/tools/trace_digest.py index 59c9fb3bf9..7998b670bb 100644 --- a/tools/trace_digest.py +++ b/tools/trace_digest.py @@ -308,7 +308,8 @@ def digest_intervals(lines): "top_phase": {"name": largest[0], "ms_per_frame": largest[1]} if largest else None, "trace_dropped": line.get("trace_dropped", False)}) first = lines[0] if lines else {} - keys = ("commit", "dirty", "env", "gpu", "driver", "os", "resolution") + keys = ("commit", "dirty", "env", "gpu", "driver", "os", "resolution", + "cpu", "cpu_threads", "memory_mib", "title_id", "title", "content_version") changed = [index for index in range(1, len(lines)) if any(lines[index].get(key) != lines[index - 1].get(key) for key in keys)] return {"intervals": rows, "identity": {key: first.get(key) for key in keys}, "identity_changed_at": changed} @@ -322,7 +323,7 @@ def markdown_intervals(result): f"{identity['gpu']}, {identity['driver']}, {identity['os']}, {identity['resolution']}, commit {identity['commit']}" f"{' (dirty)' if identity.get('dirty') else ''}"] if result["identity_changed_at"]: - lines.append("commit, environment, GPU, driver or OS changed at interval " + ", ".join(str(index + 1) for index in result["identity_changed_at"])) + lines.append("build, environment, host, resolution or title metadata changed at interval " + ", ".join(str(index + 1) for index in result["identity_changed_at"])) lines += ["", "| # | seconds | presents | p50 | p90 | p99 | max | GPU busy | busiest worker | largest phase |", "|---|---|---|---|---|---|---|---|---|---|"] for index, row in enumerate(rows, 1): frame = row["frame_ms"]