Skip to content

Commit 7269027

Browse files
committed
gh-154060: Show replay duration and rate
1 parent 1604043 commit 7269027

6 files changed

Lines changed: 83 additions & 0 deletions

File tree

Lib/profiling/sampling/binary_reader.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ def convert_binary_to_format(input_file, output_file, output_format,
125125

126126
# Replay samples through collector
127127
count = reader.replay_samples(collector, progress_callback)
128+
if hasattr(collector, "set_replay_stats"):
129+
collector.set_replay_stats(info)
128130

129131
# Export to target format
130132
collector.export(output_file)

Lib/profiling/sampling/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,8 @@ def progress_callback(current, total):
776776
)
777777

778778
count = reader.replay_samples(collector, progress_callback)
779+
if hasattr(collector, "set_replay_stats"):
780+
collector.set_replay_stats(info)
779781
print()
780782

781783
if args.format == "pstats":

Lib/profiling/sampling/stack_collector.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def __init__(self, *args, **kwargs):
7878
self._string_table = StringTable()
7979
self._module_cache = {}
8080
self._all_threads = set()
81+
self._last_replay_timestamp_us = None
8182

8283
# Thread status statistics (similar to LiveStatsCollector)
8384
self.thread_status_counts = {
@@ -97,6 +98,13 @@ def collect(self, stack_frames, timestamps_us=None):
9798
"""Override to track thread status statistics before processing frames."""
9899
# Weight is number of timestamps (samples with identical stack)
99100
weight = len(timestamps_us) if timestamps_us else 1
101+
if timestamps_us:
102+
last_timestamp_us = max(timestamps_us)
103+
if (
104+
self._last_replay_timestamp_us is None
105+
or last_timestamp_us > self._last_replay_timestamp_us
106+
):
107+
self._last_replay_timestamp_us = last_timestamp_us
100108

101109
# Increment sample count by weight
102110
self._sample_count += weight
@@ -142,6 +150,27 @@ def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
142150
"mode": mode
143151
}
144152

153+
def set_replay_stats(self, info):
154+
"""Set the statistics that can be reconstructed during replay."""
155+
if self._last_replay_timestamp_us is None:
156+
return
157+
158+
interval = info["sample_interval_us"]
159+
if interval <= 0:
160+
return
161+
duration_us = max(
162+
interval,
163+
self._last_replay_timestamp_us - info["start_time_us"] + interval,
164+
)
165+
self.set_stats(
166+
interval,
167+
duration_us / 1_000_000,
168+
1_000_000 / interval,
169+
error_rate=None,
170+
missed_samples=None,
171+
mode=None,
172+
)
173+
145174
def export(self, filename):
146175
flamegraph_data = self._convert_to_flamegraph_format()
147176

Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,34 @@ def test_timestamp_preservation_with_rle(self):
15801580
self.assertEqual(ts_collector.all_timestamps, expected_timestamps)
15811581

15821582

1583+
class TestBinaryReplayToFlamegraph(BinaryFormatTestBase):
1584+
def test_replay_includes_reconstructed_stats(self):
1585+
frames = [
1586+
make_frame("hot.py", 99, "hot_func"),
1587+
make_frame("main.py", 1, "main"),
1588+
]
1589+
samples = [
1590+
[
1591+
make_interpreter(
1592+
0,
1593+
[make_thread(1, frames, THREAD_STATUS_HAS_GIL)],
1594+
)
1595+
]
1596+
for _ in range(5)
1597+
]
1598+
bin_path = self.create_binary_file(samples, interval=2000)
1599+
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as file:
1600+
html_path = file.name
1601+
self.temp_files.append(html_path)
1602+
1603+
convert_binary_to_format(bin_path, html_path, "flamegraph")
1604+
1605+
with open(html_path, encoding="utf-8") as file:
1606+
content = file.read()
1607+
self.assertIn('"duration_sec":', content)
1608+
self.assertIn('"sample_rate": 500.0', content)
1609+
1610+
15831611
class TestBinaryReplayToJsonl(BinaryFormatTestBase):
15841612
"""Tests for binary -> JSONL replay via convert_binary_to_format."""
15851613

Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,26 @@ def test_flamegraph_collector_stats_accumulation(self):
11561156
collector.collect(stack_frames_gc)
11571157
self.assertEqual(collector.samples_with_gc_frames, 2)
11581158

1159+
def test_flamegraph_collector_reconstructs_replay_stats(self):
1160+
"""Replay duration and configured rate come from binary metadata."""
1161+
collector = FlamegraphCollector(1000)
1162+
frames = [
1163+
MockInterpreterInfo(0, [
1164+
MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func")])
1165+
])
1166+
]
1167+
collector.collect(frames, timestamps_us=[1_001_000, 1_002_000])
1168+
1169+
collector.set_replay_stats({
1170+
"start_time_us": 1_000_000,
1171+
"sample_interval_us": 1000,
1172+
})
1173+
1174+
self.assertAlmostEqual(collector.stats["duration_sec"], 0.003)
1175+
self.assertAlmostEqual(collector.stats["sample_rate"], 1000.0)
1176+
self.assertIsNone(collector.stats["error_rate"])
1177+
self.assertIsNone(collector.stats["missed_samples"])
1178+
11591179
def test_flamegraph_collector_per_thread_stats(self):
11601180
"""Test per-thread statistics tracking in FlamegraphCollector."""
11611181
collector = FlamegraphCollector(sample_interval_usec=1000)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Show the reconstructed duration and configured sampling rate in replayed
2+
Tachyon flamegraphs.

0 commit comments

Comments
 (0)