Skip to content

Commit 516358c

Browse files
committed
[3.15] gh-154062: Stop normalizing differential flamegraph duration (GH-154082)
1 parent d625ecb commit 516358c

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
@@ -676,16 +676,16 @@ def _convert_to_flamegraph_format(self):
676676
current_stats = self._aggregate_path_samples(self._root)
677677
baseline_stats = self._aggregate_path_samples(self._baseline_collector._root)
678678

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

@@ -902,6 +902,10 @@ def _add_elided_metadata(self, node, baseline_stats, scale, path):
902902
else:
903903
node["diff_pct"] = 0.0
904904

905+
# Scale geometry after computing metadata from raw baseline counts.
906+
node["value"] = node.get("value", 0) * scale
907+
node["self"] = node.get("self", 0) * scale
908+
905909
if "children" in node and node["children"]:
906910
for child in node["children"]:
907911
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
@@ -1663,7 +1663,8 @@ def test_diff_flamegraph_changed_functions(self):
16631663
])
16641664
]
16651665

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

16741675
data = diff._convert_to_flamegraph_format()
16751676
strings = data.get("strings", [])
1676-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
1677+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
16771678

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

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

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

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

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

17551756
data = diff._convert_to_flamegraph_format()
1756-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 4.0)
1757+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
17571758

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

17661826
def test_diff_flamegraph_elided_stacks(self):
17671827
"""Paths in baseline but not current produce elided stacks."""
@@ -2064,7 +2124,11 @@ def test_diff_flamegraph_empty_current(self):
20642124
])
20652125
]
20662126

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

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

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

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

@@ -2270,25 +2340,25 @@ def test_diff_flamegraph_load_baseline(self):
22702340
strings = data.get("strings", [])
22712341

22722342
self.assertTrue(data["stats"]["is_differential"])
2273-
self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
2343+
self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
22742344

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

2281-
# hot_leaf regressed (+50%)
2282-
self.assertAlmostEqual(hot_node["baseline"], 2.0)
2351+
# hot_leaf regressed (+200%)
2352+
self.assertAlmostEqual(hot_node["baseline"], 1.0)
22832353
self.assertEqual(hot_node["self_time"], 3)
2284-
self.assertAlmostEqual(hot_node["diff"], 1.0)
2285-
self.assertAlmostEqual(hot_node["diff_pct"], 50.0)
2354+
self.assertAlmostEqual(hot_node["diff"], 2.0)
2355+
self.assertAlmostEqual(hot_node["diff_pct"], 200.0)
22862356

2287-
# cold_leaf improved (-50%)
2288-
self.assertAlmostEqual(cold_node["baseline"], 2.0)
2357+
# cold_leaf is unchanged
2358+
self.assertAlmostEqual(cold_node["baseline"], 1.0)
22892359
self.assertEqual(cold_node["self_time"], 1)
2290-
self.assertAlmostEqual(cold_node["diff"], -1.0)
2291-
self.assertAlmostEqual(cold_node["diff_pct"], -50.0)
2360+
self.assertAlmostEqual(cold_node["diff"], 0.0)
2361+
self.assertAlmostEqual(cold_node["diff_pct"], 0.0)
22922362

22932363
def test_jsonl_collector_export_exact_output(self):
22942364
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)