https://github.com/python/cpython/commit/64bad2354042fa75ca2b4a8594de6e252142509e
commit: 64bad2354042fa75ca2b4a8594de6e252142509e
branch: main
author: Pablo Galindo Salgado <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-10-05T17:56:52+01:00
summary:

gh-154062: Stop normalizing differential flamegraph duration (#154082)

* gh-154062: Stop normalizing differential flamegraph duration

* gh-154032: Scale elided flamegraph values

* gh-154062: Keep elided metadata in raw sample units

files:
A Misc/NEWS.d/next/Library/2026-07-19-11-00-00.gh-issue-154062.N5ks3A.rst
M Lib/profiling/sampling/stack_collector.py
M Lib/test/test_profiling/test_sampling_profiler/mocks.py
M Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

diff --git a/Lib/profiling/sampling/stack_collector.py 
b/Lib/profiling/sampling/stack_collector.py
index e420bb6d2e9b87..72a8cae8807890 100644
--- a/Lib/profiling/sampling/stack_collector.py
+++ b/Lib/profiling/sampling/stack_collector.py
@@ -676,16 +676,16 @@ def _convert_to_flamegraph_format(self):
         current_stats = self._aggregate_path_samples(self._root)
         baseline_stats = 
self._aggregate_path_samples(self._baseline_collector._root)
 
-        # Scale baseline values to make them comparable, accounting for both
-        # sample count differences and sample interval differences.
+        # Express baseline samples in units of the current sample interval.
+        # Do not normalize by total profile duration: doing so makes unchanged
+        # functions appear different when another function becomes faster or
+        # slower.
         baseline_total = self._baseline_collector._total_samples
-        if baseline_total > 0 and self._total_samples > 0:
-            current_time = self._total_samples * self.sample_interval_usec
-            baseline_time = baseline_total * 
self._baseline_collector.sample_interval_usec
-            scale = current_time / baseline_time
-        elif baseline_total > 0:
-            # Current profile is empty - use interval-based scale for elided 
display
-            scale = self.sample_interval_usec / 
self._baseline_collector.sample_interval_usec
+        if baseline_total > 0:
+            scale = (
+                self._baseline_collector.sample_interval_usec
+                / self.sample_interval_usec
+            )
         else:
             scale = 1.0
 
@@ -902,6 +902,10 @@ def _add_elided_metadata(self, node, baseline_stats, 
scale, path):
         else:
             node["diff_pct"] = 0.0
 
+        # Scale geometry after computing metadata from raw baseline counts.
+        node["value"] = node.get("value", 0) * scale
+        node["self"] = node.get("self", 0) * scale
+
         if "children" in node and node["children"]:
             for child in node["children"]:
                 self._add_elided_metadata(child, baseline_stats, scale, 
current_path)
diff --git a/Lib/test/test_profiling/test_sampling_profiler/mocks.py 
b/Lib/test/test_profiling/test_sampling_profiler/mocks.py
index 6ac2d08e898d81..128870ffd4d5e4 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/mocks.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/mocks.py
@@ -93,7 +93,9 @@ def __repr__(self):
         return f"MockAwaitedInfo(thread_id={self.thread_id}, 
awaited_by={len(self.awaited_by)} tasks)"
 
 
-def make_diff_collector_with_mock_baseline(baseline_samples):
+def make_diff_collector_with_mock_baseline(
+    baseline_samples, *, baseline_interval=1000, current_interval=1000
+):
     """Create a DiffFlamegraphCollector with baseline injected directly,
     skipping the binary round-trip that _load_baseline normally does."""
     from profiling.sampling.stack_collector import (
@@ -101,12 +103,14 @@ def 
make_diff_collector_with_mock_baseline(baseline_samples):
         FlamegraphCollector,
     )
 
-    baseline = FlamegraphCollector(1000)
+    baseline = FlamegraphCollector(baseline_interval)
     for sample in baseline_samples:
         baseline.collect(sample)
 
     # Path is unused since we inject _baseline_collector directly;
     # use __file__ as a dummy path that passes the existence check.
-    diff = DiffFlamegraphCollector(1000, baseline_binary_path=__file__)
+    diff = DiffFlamegraphCollector(
+        current_interval, baseline_binary_path=__file__
+    )
     diff._baseline_collector = baseline
     return diff
diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py 
b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
index eb58c29dd361d3..36713ff1314ff9 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
@@ -1663,7 +1663,8 @@ def test_diff_flamegraph_changed_functions(self):
             ])
         ]
 
-        # Baseline: 2 samples, current: 4, scale = 2.0
+        # Baseline: 2 samples, current: 4. Profiles are compared in absolute
+        # time rather than normalized to the same total duration.
         diff = make_diff_collector_with_mock_baseline(
             [hot_leaf_sample, cold_leaf_sample]
         )
@@ -1673,7 +1674,7 @@ def test_diff_flamegraph_changed_functions(self):
 
         data = diff._convert_to_flamegraph_format()
         strings = data.get("strings", [])
-        self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
+        self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
 
         children = data.get("children", [])
         hot_node = find_child_by_name(children, strings, "hot_leaf")
@@ -1681,17 +1682,17 @@ def test_diff_flamegraph_changed_functions(self):
         self.assertIsNotNone(hot_node)
         self.assertIsNotNone(cold_node)
 
-        # hot_leaf regressed (+50%)
-        self.assertAlmostEqual(hot_node["baseline"], 2.0)
+        # hot_leaf regressed (+200%)
+        self.assertAlmostEqual(hot_node["baseline"], 1.0)
         self.assertEqual(hot_node["self_time"], 3)
-        self.assertAlmostEqual(hot_node["diff"], 1.0)
-        self.assertAlmostEqual(hot_node["diff_pct"], 50.0)
+        self.assertAlmostEqual(hot_node["diff"], 2.0)
+        self.assertAlmostEqual(hot_node["diff_pct"], 200.0)
 
-        # cold_leaf improved (-50%)
-        self.assertAlmostEqual(cold_node["baseline"], 2.0)
+        # cold_leaf is unchanged
+        self.assertAlmostEqual(cold_node["baseline"], 1.0)
         self.assertEqual(cold_node["self_time"], 1)
-        self.assertAlmostEqual(cold_node["diff"], -1.0)
-        self.assertAlmostEqual(cold_node["diff_pct"], -50.0)
+        self.assertAlmostEqual(cold_node["diff"], 0.0)
+        self.assertAlmostEqual(cold_node["diff_pct"], 0.0)
 
     def test_diff_flamegraph_rejects_mismatched_profiling_modes(self):
         from profiling.sampling.binary_collector import BinaryCollector
@@ -1737,8 +1738,8 @@ def 
test_diff_flamegraph_rejects_mismatched_capture_config(self):
         with self.assertRaisesRegex(ValueError, "all_threads"):
             diff._convert_to_flamegraph_format()
 
-    def test_diff_flamegraph_scale_factor(self):
-        """Scale factor adjusts when sample counts differ."""
+    def test_diff_flamegraph_does_not_normalize_duration(self):
+        """A longer current run is compared in absolute time."""
         baseline_frames = [
             MockInterpreterInfo(0, [
                 MockThreadInfo(1, [
@@ -1753,15 +1754,74 @@ def test_diff_flamegraph_scale_factor(self):
             diff.collect(baseline_frames)
 
         data = diff._convert_to_flamegraph_format()
-        self.assertAlmostEqual(data["stats"]["baseline_scale"], 4.0)
+        self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
 
         children = data.get("children", [])
         self.assertEqual(len(children), 1)
         func1_node = children[0]
         self.assertEqual(func1_node["self_time"], 4)
-        self.assertAlmostEqual(func1_node["baseline"], 4.0)
-        self.assertAlmostEqual(func1_node["diff"], 0.0)
-        self.assertAlmostEqual(func1_node["diff_pct"], 0.0)
+        self.assertAlmostEqual(func1_node["baseline"], 1.0)
+        self.assertAlmostEqual(func1_node["diff"], 3.0)
+        self.assertAlmostEqual(func1_node["diff_pct"], 300.0)
+
+    def test_diff_flamegraph_scale_factor_uses_sample_intervals(self):
+        """Baseline samples are converted to current sample units."""
+        frames = [
+            MockInterpreterInfo(0, [
+                MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1")])
+            ])
+        ]
+
+        diff = make_diff_collector_with_mock_baseline(
+            [frames] * 10,
+            baseline_interval=1000,
+            current_interval=10000,
+        )
+        diff.collect(frames)
+
+        data = diff._convert_to_flamegraph_format()
+        self.assertAlmostEqual(data["stats"]["baseline_scale"], 0.1)
+        self.assertAlmostEqual(data["baseline"], 1.0)
+        self.assertEqual(data["self_time"], 1)
+        self.assertAlmostEqual(data["diff"], 0.0)
+        self.assertAlmostEqual(data["diff_pct"], 0.0)
+
+    def test_diff_flamegraph_elided_values_use_current_interval(self):
+        """Elided geometry and metadata use the same sample units."""
+        baseline_frames = [
+            MockInterpreterInfo(0, [
+                MockThreadInfo(1, [
+                    MockFrameInfo("file.py", 10, "old_func"),
+                    MockFrameInfo("file.py", 30, "parent"),
+                ])
+            ])
+        ]
+        current_frames = [
+            MockInterpreterInfo(0, [
+                MockThreadInfo(1, [
+                    MockFrameInfo("file.py", 20, "new_func"),
+                    MockFrameInfo("file.py", 30, "parent"),
+                ])
+            ])
+        ]
+
+        diff = make_diff_collector_with_mock_baseline(
+            [baseline_frames] * 10,
+            baseline_interval=1000,
+            current_interval=10000,
+        )
+        diff.collect(current_frames)
+
+        data = diff._convert_to_flamegraph_format()
+        elided = data["stats"]["elided_flamegraph"]
+        self.assertAlmostEqual(elided["value"], 1.0)
+        self.assertEqual(elided["self"], 0)
+        self.assertAlmostEqual(elided["baseline_total"], 1.0)
+        child, = elided["children"]
+        self.assertAlmostEqual(child["value"], 1.0)
+        self.assertAlmostEqual(child["self"], 1.0)
+        self.assertAlmostEqual(child["baseline"], 1.0)
+        self.assertAlmostEqual(child["diff"], -1.0)
 
     def test_diff_flamegraph_elided_stacks(self):
         """Paths in baseline but not current produce elided stacks."""
@@ -2064,7 +2124,11 @@ def test_diff_flamegraph_empty_current(self):
             ])
         ]
 
-        diff = make_diff_collector_with_mock_baseline([baseline_frames])
+        diff = make_diff_collector_with_mock_baseline(
+            [baseline_frames] * 10,
+            baseline_interval=1000,
+            current_interval=10000,
+        )
         # Don't collect anything in current
 
         data = diff._convert_to_flamegraph_format()
@@ -2074,6 +2138,11 @@ def test_diff_flamegraph_empty_current(self):
         self.assertTrue(data["stats"]["is_differential"])
         # All baseline paths should be elided since current is empty
         self.assertGreater(data["stats"]["elided_count"], 0)
+        self.assertAlmostEqual(data["stats"]["baseline_scale"], 0.1)
+        elided = data["stats"]["elided_flamegraph"]
+        self.assertAlmostEqual(elided["value"], 1.0)
+        self.assertAlmostEqual(elided["baseline"], 1.0)
+        self.assertAlmostEqual(elided["diff"], -1.0)
 
     def test_diff_flamegraph_empty_baseline(self):
         """Empty baseline with non-empty current uses scale=1.0 fallback."""
@@ -2240,7 +2309,8 @@ def test_diff_flamegraph_load_baseline(self):
             make_frame("file.py", 20, "caller"),
         ])])]
 
-        # Baseline: 2 samples, current: 4, scale = 2.0
+        # Baseline: 2 samples, current: 4. Profiles are compared in absolute
+        # time rather than normalized to the same total duration.
         bin_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False)
         self.addCleanup(close_and_unlink, bin_file)
 
@@ -2270,7 +2340,7 @@ def test_diff_flamegraph_load_baseline(self):
         strings = data.get("strings", [])
 
         self.assertTrue(data["stats"]["is_differential"])
-        self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0)
+        self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0)
 
         children = data.get("children", [])
         hot_node = find_child_by_name(children, strings, "hot_leaf")
@@ -2278,17 +2348,17 @@ def test_diff_flamegraph_load_baseline(self):
         self.assertIsNotNone(hot_node)
         self.assertIsNotNone(cold_node)
 
-        # hot_leaf regressed (+50%)
-        self.assertAlmostEqual(hot_node["baseline"], 2.0)
+        # hot_leaf regressed (+200%)
+        self.assertAlmostEqual(hot_node["baseline"], 1.0)
         self.assertEqual(hot_node["self_time"], 3)
-        self.assertAlmostEqual(hot_node["diff"], 1.0)
-        self.assertAlmostEqual(hot_node["diff_pct"], 50.0)
+        self.assertAlmostEqual(hot_node["diff"], 2.0)
+        self.assertAlmostEqual(hot_node["diff_pct"], 200.0)
 
-        # cold_leaf improved (-50%)
-        self.assertAlmostEqual(cold_node["baseline"], 2.0)
+        # cold_leaf is unchanged
+        self.assertAlmostEqual(cold_node["baseline"], 1.0)
         self.assertEqual(cold_node["self_time"], 1)
-        self.assertAlmostEqual(cold_node["diff"], -1.0)
-        self.assertAlmostEqual(cold_node["diff_pct"], -50.0)
+        self.assertAlmostEqual(cold_node["diff"], 0.0)
+        self.assertAlmostEqual(cold_node["diff_pct"], 0.0)
 
     def test_jsonl_collector_export_exact_output(self):
         jsonl_out = tempfile.NamedTemporaryFile(delete=False)
diff --git 
a/Misc/NEWS.d/next/Library/2026-07-19-11-00-00.gh-issue-154062.N5ks3A.rst 
b/Misc/NEWS.d/next/Library/2026-07-19-11-00-00.gh-issue-154062.N5ks3A.rst
new file mode 100644
index 00000000000000..7c4469aba9bc7e
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-19-11-00-00.gh-issue-154062.N5ks3A.rst
@@ -0,0 +1,2 @@
+Stop normalizing differential flamegraph baselines to the duration of the
+current profile.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to