Skip to content

Commit 631ab6f

Browse files
authored
[3.15] gh-154062: Stop normalizing differential flamegraph duration (GH-154082) (#158871)
1 parent 8c7ac57 commit 631ab6f

4 files changed

Lines changed: 119 additions & 39 deletions

File tree

‎Lib/profiling/sampling/stack_collector.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -681,16 +681,16 @@ def _convert_to_flamegraph_format(self):
681681
current_stats = self._aggregate_path_samples(self._root)
682682
baseline_stats = self._aggregate_path_samples(self._baseline_collector._root)
683683

684-
# Scale baseline values to make them comparable, accounting for both
685-
# sample count differences and sample interval differences.
684+
# Express baseline samples in units of the current sample interval.
685+
# Do not normalize by total profile duration: doing so makes unchanged
686+
# functions appear different when another function becomes faster or
687+
# slower.
686688
baseline_total = self._baseline_collector._total_samples
687-
if baseline_total > 0 and self._total_samples > 0:
688-
current_time = self._total_samples * self.sample_interval_usec
689-
baseline_time = baseline_total * self._baseline_collector.sample_interval_usec
690-
scale = current_time / baseline_time
691-
elif baseline_total > 0:
692-
# Current profile is empty - use interval-based scale for elided display
693-
scale = self.sample_interval_usec / self._baseline_collector.sample_interval_usec
689+
if baseline_total > 0:
690+
scale = (
691+
self._baseline_collector.sample_interval_usec
692+
/ self.sample_interval_usec
693+
)
694694
else:
695695
scale = 1.0
696696

@@ -907,6 +907,10 @@ def _add_elided_metadata(self, node, baseline_stats, scale, path):
907907
else:
908908
node["diff_pct"] = 0.0
909909

910+
# Scale geometry after computing metadata from raw baseline counts.
911+
node["value"] = node.get("value", 0) * scale
912+
node["self"] = node.get("self", 0) * scale
913+
910914
if "children" in node and node["children"]:
911915
for child in node["children"]:
912916
self._add_elided_metadata(child, baseline_stats, scale, current_path)

‎Lib/test/test_profiling/test_sampling_profiler/mocks.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,24 @@ def __repr__(self):
9393
return f"MockAwaitedInfo(thread_id={self.thread_id}, awaited_by={len(self.awaited_by)} tasks)"
9494

9595

96-
def make_diff_collector_with_mock_baseline(baseline_samples):
96+
def make_diff_collector_with_mock_baseline(
97+
baseline_samples, *, baseline_interval=1000, current_interval=1000
98+
):
9799
"""Create a DiffFlamegraphCollector with baseline injected directly,
98100
skipping the binary round-trip that _load_baseline normally does."""
99101
from profiling.sampling.stack_collector import (
100102
DiffFlamegraphCollector,
101103
FlamegraphCollector,
102104
)
103105

104-
baseline = FlamegraphCollector(1000)
106+
baseline = FlamegraphCollector(baseline_interval)
105107
for sample in baseline_samples:
106108
baseline.collect(sample)
107109

108110
# Path is unused since we inject _baseline_collector directly;
109111
# use __file__ as a dummy path that passes the existence check.
110-
diff = DiffFlamegraphCollector(1000, baseline_binary_path=__file__)
112+
diff = DiffFlamegraphCollector(
113+
current_interval, baseline_binary_path=__file__
114+
)
111115
diff._baseline_collector = baseline
112116
return diff

‎Lib/test/test_profiling/test_sampling_profiler/test_collectors.py‎

Lines changed: 97 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,7 +1664,8 @@ def test_diff_flamegraph_changed_functions(self):
16641664
])
16651665
]
16661666

1667-
# Baseline: 2 samples, current: 4, scale = 2.0
1667+
# Baseline: 2 samples, current: 4. Profiles are compared in absolute
1668+
# time rather than normalized to the same total duration.
16681669
diff = make_diff_collector_with_mock_baseline(
16691670
[hot_leaf_sample, cold_leaf_sample]
16701671
)
@@ -1674,25 +1675,25 @@ def test_diff_flamegraph_changed_functions(self):
16741675

16751676
data = diff._convert_to_flamegraph_format()
16761677
strings = data.get("strings", [])
1677-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
1678+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
16781679

16791680
children = data.get("children", [])
16801681
hot_node = find_child_by_name(children, strings, "hot_leaf")
16811682
cold_node = find_child_by_name(children, strings, "cold_leaf")
16821683
self.assertIsNotNone(hot_node)
16831684
self.assertIsNotNone(cold_node)
16841685

1685-
# hot_leaf regressed (+50%)
1686-
self.assertAlmostEqual(hot_node["baseline"], 2.0)
1686+
# hot_leaf regressed (+200%)
1687+
self.assertAlmostEqual(hot_node["baseline"], 1.0)
16871688
self.assertEqual(hot_node["self_time"], 3)
1688-
self.assertAlmostEqual(hot_node["diff"], 1.0)
1689-
self.assertAlmostEqual(hot_node["diff_pct"], 50.0)
1689+
self.assertAlmostEqual(hot_node["diff"], 2.0)
1690+
self.assertAlmostEqual(hot_node["diff_pct"], 200.0)
16901691

1691-
# cold_leaf improved (-50%)
1692-
self.assertAlmostEqual(cold_node["baseline"], 2.0)
1692+
# cold_leaf is unchanged
1693+
self.assertAlmostEqual(cold_node["baseline"], 1.0)
16931694
self.assertEqual(cold_node["self_time"], 1)
1694-
self.assertAlmostEqual(cold_node["diff"], -1.0)
1695-
self.assertAlmostEqual(cold_node["diff_pct"], -50.0)
1695+
self.assertAlmostEqual(cold_node["diff"], 0.0)
1696+
self.assertAlmostEqual(cold_node["diff_pct"], 0.0)
16961697

16971698
def test_diff_flamegraph_rejects_mismatched_profiling_modes(self):
16981699
from profiling.sampling.binary_collector import BinaryCollector
@@ -1738,8 +1739,8 @@ def test_diff_flamegraph_rejects_mismatched_capture_config(self):
17381739
with self.assertRaisesRegex(ValueError, "all_threads"):
17391740
diff._convert_to_flamegraph_format()
17401741

1741-
def test_diff_flamegraph_scale_factor(self):
1742-
"""Scale factor adjusts when sample counts differ."""
1742+
def test_diff_flamegraph_does_not_normalize_duration(self):
1743+
"""A longer current run is compared in absolute time."""
17431744
baseline_frames = [
17441745
MockInterpreterInfo(0, [
17451746
MockThreadInfo(1, [
@@ -1754,15 +1755,74 @@ def test_diff_flamegraph_scale_factor(self):
17541755
diff.collect(baseline_frames)
17551756

17561757
data = diff._convert_to_flamegraph_format()
1757-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 4.0)
1758+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
17581759

17591760
children = data.get("children", [])
17601761
self.assertEqual(len(children), 1)
17611762
func1_node = children[0]
17621763
self.assertEqual(func1_node["self_time"], 4)
1763-
self.assertAlmostEqual(func1_node["baseline"], 4.0)
1764-
self.assertAlmostEqual(func1_node["diff"], 0.0)
1765-
self.assertAlmostEqual(func1_node["diff_pct"], 0.0)
1764+
self.assertAlmostEqual(func1_node["baseline"], 1.0)
1765+
self.assertAlmostEqual(func1_node["diff"], 3.0)
1766+
self.assertAlmostEqual(func1_node["diff_pct"], 300.0)
1767+
1768+
def test_diff_flamegraph_scale_factor_uses_sample_intervals(self):
1769+
"""Baseline samples are converted to current sample units."""
1770+
frames = [
1771+
MockInterpreterInfo(0, [
1772+
MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1")])
1773+
])
1774+
]
1775+
1776+
diff = make_diff_collector_with_mock_baseline(
1777+
[frames] * 10,
1778+
baseline_interval=1000,
1779+
current_interval=10000,
1780+
)
1781+
diff.collect(frames)
1782+
1783+
data = diff._convert_to_flamegraph_format()
1784+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 0.1)
1785+
self.assertAlmostEqual(data["baseline"], 1.0)
1786+
self.assertEqual(data["self_time"], 1)
1787+
self.assertAlmostEqual(data["diff"], 0.0)
1788+
self.assertAlmostEqual(data["diff_pct"], 0.0)
1789+
1790+
def test_diff_flamegraph_elided_values_use_current_interval(self):
1791+
"""Elided geometry and metadata use the same sample units."""
1792+
baseline_frames = [
1793+
MockInterpreterInfo(0, [
1794+
MockThreadInfo(1, [
1795+
MockFrameInfo("file.py", 10, "old_func"),
1796+
MockFrameInfo("file.py", 30, "parent"),
1797+
])
1798+
])
1799+
]
1800+
current_frames = [
1801+
MockInterpreterInfo(0, [
1802+
MockThreadInfo(1, [
1803+
MockFrameInfo("file.py", 20, "new_func"),
1804+
MockFrameInfo("file.py", 30, "parent"),
1805+
])
1806+
])
1807+
]
1808+
1809+
diff = make_diff_collector_with_mock_baseline(
1810+
[baseline_frames] * 10,
1811+
baseline_interval=1000,
1812+
current_interval=10000,
1813+
)
1814+
diff.collect(current_frames)
1815+
1816+
data = diff._convert_to_flamegraph_format()
1817+
elided = data["stats"]["elided_flamegraph"]
1818+
self.assertAlmostEqual(elided["value"], 1.0)
1819+
self.assertEqual(elided["self"], 0)
1820+
self.assertAlmostEqual(elided["baseline_total"], 1.0)
1821+
child, = elided["children"]
1822+
self.assertAlmostEqual(child["value"], 1.0)
1823+
self.assertAlmostEqual(child["self"], 1.0)
1824+
self.assertAlmostEqual(child["baseline"], 1.0)
1825+
self.assertAlmostEqual(child["diff"], -1.0)
17661826

17671827
def test_diff_flamegraph_elided_stacks(self):
17681828
"""Paths in baseline but not current produce elided stacks."""
@@ -2065,7 +2125,11 @@ def test_diff_flamegraph_empty_current(self):
20652125
])
20662126
]
20672127

2068-
diff = make_diff_collector_with_mock_baseline([baseline_frames])
2128+
diff = make_diff_collector_with_mock_baseline(
2129+
[baseline_frames] * 10,
2130+
baseline_interval=1000,
2131+
current_interval=10000,
2132+
)
20692133
# Don't collect anything in current
20702134

20712135
data = diff._convert_to_flamegraph_format()
@@ -2075,6 +2139,11 @@ def test_diff_flamegraph_empty_current(self):
20752139
self.assertTrue(data["stats"]["is_differential"])
20762140
# All baseline paths should be elided since current is empty
20772141
self.assertGreater(data["stats"]["elided_count"], 0)
2142+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 0.1)
2143+
elided = data["stats"]["elided_flamegraph"]
2144+
self.assertAlmostEqual(elided["value"], 1.0)
2145+
self.assertAlmostEqual(elided["baseline"], 1.0)
2146+
self.assertAlmostEqual(elided["diff"], -1.0)
20782147

20792148
def test_diff_flamegraph_empty_baseline(self):
20802149
"""Empty baseline with non-empty current uses scale=1.0 fallback."""
@@ -2241,7 +2310,8 @@ def test_diff_flamegraph_load_baseline(self):
22412310
make_frame("file.py", 20, "caller"),
22422311
])])]
22432312

2244-
# Baseline: 2 samples, current: 4, scale = 2.0
2313+
# Baseline: 2 samples, current: 4. Profiles are compared in absolute
2314+
# time rather than normalized to the same total duration.
22452315
bin_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False)
22462316
self.addCleanup(close_and_unlink, bin_file)
22472317

@@ -2271,25 +2341,25 @@ def test_diff_flamegraph_load_baseline(self):
22712341
strings = data.get("strings", [])
22722342

22732343
self.assertTrue(data["stats"]["is_differential"])
2274-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
2344+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
22752345

22762346
children = data.get("children", [])
22772347
hot_node = find_child_by_name(children, strings, "hot_leaf")
22782348
cold_node = find_child_by_name(children, strings, "cold_leaf")
22792349
self.assertIsNotNone(hot_node)
22802350
self.assertIsNotNone(cold_node)
22812351

2282-
# hot_leaf regressed (+50%)
2283-
self.assertAlmostEqual(hot_node["baseline"], 2.0)
2352+
# hot_leaf regressed (+200%)
2353+
self.assertAlmostEqual(hot_node["baseline"], 1.0)
22842354
self.assertEqual(hot_node["self_time"], 3)
2285-
self.assertAlmostEqual(hot_node["diff"], 1.0)
2286-
self.assertAlmostEqual(hot_node["diff_pct"], 50.0)
2355+
self.assertAlmostEqual(hot_node["diff"], 2.0)
2356+
self.assertAlmostEqual(hot_node["diff_pct"], 200.0)
22872357

2288-
# cold_leaf improved (-50%)
2289-
self.assertAlmostEqual(cold_node["baseline"], 2.0)
2358+
# cold_leaf is unchanged
2359+
self.assertAlmostEqual(cold_node["baseline"], 1.0)
22902360
self.assertEqual(cold_node["self_time"], 1)
2291-
self.assertAlmostEqual(cold_node["diff"], -1.0)
2292-
self.assertAlmostEqual(cold_node["diff_pct"], -50.0)
2361+
self.assertAlmostEqual(cold_node["diff"], 0.0)
2362+
self.assertAlmostEqual(cold_node["diff_pct"], 0.0)
22932363

22942364
def test_jsonl_collector_export_exact_output(self):
22952365
jsonl_out = tempfile.NamedTemporaryFile(delete=False)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Stop normalizing differential flamegraph baselines to the duration of the
2+
current profile.

0 commit comments

Comments
 (0)