Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions Lib/profiling/sampling/binary_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ def get_stats(self):
return self._reader.get_stats()


def _set_replay_stats(collector, info, sample_interval_usec):
"""Pass recorded statistics to a collector using the live sampling API."""
duration_sec = info.get("duration_sec")
sample_rate = info.get("sample_rate")
mode = info.get("mode")
if (
duration_sec is not None
and sample_rate is not None
and hasattr(collector, "set_stats")
):
collector.set_stats(
sample_interval_usec,
duration_sec,
sample_rate,
error_rate=info.get("error_rate"),
missed_samples=info.get("missed_samples"),
mode=mode,
)
elif hasattr(collector, "set_mode"):
collector.set_mode(mode)


def convert_binary_to_format(input_file, output_file, output_format,
sample_interval_usec=None, progress_callback=None):
"""Convert a binary profiling file to another format.
Expand Down Expand Up @@ -137,10 +159,7 @@ def convert_binary_to_format(input_file, output_file, output_format,

# Replay samples through collector
count = reader.replay_samples(collector, progress_callback)
if hasattr(collector, "set_replay_stats"):
collector.set_replay_stats(info)
if hasattr(collector, "set_mode"):
collector.set_mode(info.get("mode"))
_set_replay_stats(collector, info, interval)

# Export to target format
collector.export(output_file)
Expand Down
7 changes: 2 additions & 5 deletions Lib/profiling/sampling/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .gecko_collector import GeckoCollector
from .jsonl_collector import JsonlCollector
from .binary_collector import BinaryCollector
from .binary_reader import BinaryReader
from .binary_reader import BinaryReader, _set_replay_stats
from .constants import (
MICROSECONDS_PER_SECOND,
PROFILING_MODE_ALL,
Expand Down Expand Up @@ -793,10 +793,7 @@ def progress_callback(current, total):
)

count = reader.replay_samples(collector, progress_callback)
if hasattr(collector, "set_replay_stats"):
collector.set_replay_stats(info)
if hasattr(collector, "set_mode"):
collector.set_mode(info.get("mode"))
_set_replay_stats(collector, info, interval)
print()

if args.format == "pstats":
Expand Down
6 changes: 4 additions & 2 deletions Lib/profiling/sampling/heatmap_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,15 +752,17 @@ def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):
missed_samples_class = "good"

# Populate template
duration_sec = self.stats.get('duration_sec')
sample_rate = self.stats.get('sample_rate')
replacements = {
"<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.index_css}\n</style>",
"<!-- INLINE_JS -->": f"<script>\n{self._template_loader.index_js}\n</script>",
"<!-- PYTHON_LOGO -->": self._template_loader.logo_html,
"<!-- PYTHON_VERSION -->": f"{sys.version_info.major}.{sys.version_info.minor}",
"<!-- NUM_FILES -->": f"{len(file_stats):n}",
"<!-- TOTAL_SAMPLES -->": f"{self._total_samples:n}",
"<!-- DURATION -->": fmt(self.stats.get('duration_sec', 0)),
"<!-- SAMPLE_RATE -->": fmt(self.stats.get('sample_rate', 0)),
"<!-- DURATION -->": f"{fmt(duration_sec)} sec" if duration_sec is not None else "N/A",
"<!-- SAMPLE_RATE -->": fmt(sample_rate) if sample_rate is not None else "N/A",
"<!-- ERROR_RATE -->": error_rate_str,
"<!-- ERROR_RATE_WIDTH -->": str(error_rate_width),
"<!-- ERROR_RATE_CLASS -->": error_rate_class,
Expand Down
14 changes: 0 additions & 14 deletions Lib/profiling/sampling/stack_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,6 @@ def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
"mode": mode
}

def set_replay_stats(self, info):
"""Restore measured statistics stored in a binary profile."""
duration_sec = info.get("duration_sec")
sample_rate = info.get("sample_rate")
if duration_sec is None or sample_rate is None:
return
self.set_stats(
self.sample_interval_usec,
duration_sec,
sample_rate,
error_rate=info.get("error_rate"),
missed_samples=info.get("missed_samples"),
mode=self.stats.get("mode"),
)
def set_mode(self, mode):
self.stats["mode"] = mode

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import struct
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
from collections import defaultdict

from test.support import captured_stderr
from test.support import captured_stderr, captured_stdout, run_with_locale

try:
import _remote_debugging
Expand All @@ -28,6 +29,7 @@
)
from profiling.sampling.binary_collector import BinaryCollector
from profiling.sampling.binary_reader import BinaryReader, convert_binary_to_format
from profiling.sampling.cli import _replay_with_reader
from profiling.sampling.constants import PROFILING_MODE_CPU
from profiling.sampling.gecko_collector import GeckoCollector

Expand Down Expand Up @@ -1937,6 +1939,48 @@ def test_original_stats_extension_remains_readable(self):
self.assertIsNone(info["missed_samples"])


class TestBinaryReplayToHeatmap(unittest.TestCase):
@run_with_locale("LC_NUMERIC", "C")
def test_replay_with_and_without_persisted_stats(self):
frame = make_frame(__file__, 1, "hot_func")
for with_stats in (True, False):
with (
self.subTest(with_stats=with_stats),
tempfile.TemporaryDirectory() as tmp,
captured_stdout(),
):
bin_path = os.path.join(tmp, "profile.bin")
with BinaryCollector(bin_path, 2000, compression="none") as collector:
collector.collect([
make_interpreter(0, [make_thread(1, [frame])])
])
if with_stats:
collector.set_stats(
2000, 2.5, 0.4, error_rate=2.0, missed_samples=1.5
)
args = SimpleNamespace(
format="heatmap", input_file=bin_path,
outfile=tmp, diff_baseline=None,
)
with BinaryReader(bin_path) as reader:
_replay_with_reader(args, reader)
content = pathlib.Path(tmp, "index.html").read_text(
encoding="utf-8"
)
if with_stats:

@maurycy maurycy Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The with_stats=False branch doesn't assert anything.

Maybe we could assert what we expect for a file without the stats extension? I think that here it'd be 0.0 sec, 0.0, N/A, N/A. If that's the case, do we want to show 0.0 sec for old files, instead of N/A?

self.assertIn('<span class="stat-value">2.5 sec</span>', content)
self.assertIn('<span class="stat-value">0.4</span>', content)
self.assertIn('<span class="rate-value">2.0%</span>', content)
self.assertIn('<span class="rate-value">1.5%</span>', content)
else:
self.assertEqual(
content.count('<span class="stat-value">N/A</span>'), 2
)
self.assertEqual(
content.count('<span class="rate-value">N/A</span>'), 2
)


class TestBinaryReplayToJsonl(BinaryFormatTestBase):
"""Tests for binary -> JSONL replay via convert_binary_to_format."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
try:
import _remote_debugging # noqa: F401
from profiling.sampling import gecko_collector
from profiling.sampling.binary_reader import _set_replay_stats
from profiling.sampling.pstats_collector import PstatsCollector
from profiling.sampling.stack_collector import (
CollapsedStackCollector,
Expand Down Expand Up @@ -1218,12 +1219,12 @@ def test_flamegraph_collector_stats_accumulation(self):
def test_flamegraph_collector_restores_replay_stats(self):
"""Replay restores measured values from binary metadata."""
collector = FlamegraphCollector(1000)
collector.set_replay_stats({
_set_replay_stats(collector, {
"duration_sec": 1.25,
"sample_rate": 4.0,
"error_rate": 2.5,
"missed_samples": 1.5,
})
}, 1000)

self.assertEqual(collector.stats["duration_sec"], 1.25)
self.assertEqual(collector.stats["sample_rate"], 4.0)
Expand All @@ -1233,10 +1234,12 @@ def test_flamegraph_collector_restores_replay_stats(self):
def test_flamegraph_collector_leaves_legacy_replay_stats_unavailable(self):
collector = FlamegraphCollector(1000)
original_stats = collector.stats.copy()
collector.set_replay_stats({
original_stats["mode"] = PROFILING_MODE_CPU
_set_replay_stats(collector, {
"duration_sec": None,
"sample_rate": None,
})
"mode": PROFILING_MODE_CPU,
}, 1000)
self.assertEqual(collector.stats, original_stats)

def test_flamegraph_collector_per_thread_stats(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix zero duration and sample rate in heatmap replay output. Patch by Timofei
Ivankov.
Loading