Skip to content

Commit cfaf610

Browse files
[3.15] gh-154085: Avoid duplicating diff line values (GH-154099) (#156385)
gh-154085: Avoid duplicating diff line values (GH-154099) (cherry picked from commit 53381bc) Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com>
1 parent 27c8f1f commit cfaf610

3 files changed

Lines changed: 164 additions & 6 deletions

File tree

Lib/profiling/sampling/stack_collector.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -675,9 +675,33 @@ def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scal
675675
current_data = current_stats.get(path_key, {"total": 0, "self": 0})
676676
baseline_data = baseline_stats.get(path_key, {"total": 0, "self": 0})
677677

678-
current_self = current_data["self"]
679-
baseline_self = baseline_data["self"] * scale
680-
baseline_total = baseline_data["total"] * scale
678+
current_self = node.get("self", 0)
679+
current_total = node.get("value", 0)
680+
681+
current_nonself = current_total - current_self
682+
aggregate_nonself = current_data["total"] - current_data["self"]
683+
684+
# Allocate self and descendant samples separately. Line-number
685+
# changes can split one function path into several rendered nodes,
686+
# and using independent weights for self and inclusive totals could
687+
# otherwise assign a node more self samples than total samples.
688+
self_weight = self._sample_weight(
689+
current_self,
690+
current_data["self"],
691+
current_total,
692+
current_data["total"],
693+
)
694+
nonself_weight = self._sample_weight(
695+
current_nonself,
696+
aggregate_nonself,
697+
current_total,
698+
current_data["total"],
699+
)
700+
baseline_self = baseline_data["self"] * scale * self_weight
701+
baseline_nonself = (
702+
baseline_data["total"] - baseline_data["self"]
703+
) * scale * nonself_weight
704+
baseline_total = baseline_self + baseline_nonself
681705

682706
diff = current_self - baseline_self
683707
if baseline_self > 0:
@@ -697,6 +721,14 @@ def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scal
697721
for child in node["children"]:
698722
self._add_diff_data_to_node(child, path_key, current_stats, baseline_stats, scale)
699723

724+
@staticmethod
725+
def _sample_weight(value, aggregate, fallback_value, fallback_aggregate):
726+
if aggregate > 0:
727+
return value / aggregate
728+
if fallback_aggregate > 0:
729+
return fallback_value / fallback_aggregate
730+
return 0
731+
700732
def _is_promoted_root(self, data):
701733
"""Check if the data represents a promoted root node."""
702734
return "filename" in data and "funcname" in data
@@ -783,6 +815,9 @@ def _extract_elided_nodes(self, node, path):
783815
# elided nodes keep their original value to preserve self-samples
784816
if elided_children and not is_elided:
785817
node["value"] = total_value
818+
node["self"] = 0
819+
node.pop("opcodes", None)
820+
node.pop("thread_opcodes", None)
786821

787822
# Keep this node if it's elided or has elided descendants
788823
return is_elided or bool(node.get("children"))
@@ -798,9 +833,13 @@ def _add_elided_metadata(self, node, baseline_stats, scale, path):
798833
baseline_self = 0
799834
baseline_total = 0
800835
if func_key and current_path in baseline_stats:
801-
baseline_data = baseline_stats[current_path]
802-
baseline_self = baseline_data["self"] * scale
803-
baseline_total = baseline_data["total"] * scale
836+
baseline_total = node.get("value", 0) * scale
837+
838+
# Matched nodes are retained only as structural ancestors. Their
839+
# own samples are still present in the current profile and must
840+
# not be reported as disappeared.
841+
if current_path in self._elided_paths:
842+
baseline_self = node.get("self", 0) * scale
804843

805844
node["baseline"] = baseline_self
806845
node["baseline_total"] = baseline_total

Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,6 +1840,123 @@ def test_diff_flamegraph_function_matched_despite_line_change(self):
18401840
self.assertAlmostEqual(child["diff"], 0.0, places=1)
18411841
self.assertAlmostEqual(child["diff_pct"], 0.0, places=1)
18421842

1843+
def test_diff_flamegraph_does_not_duplicate_line_values(self):
1844+
"""Function aggregates are apportioned across line nodes."""
1845+
def sample(line):
1846+
return [
1847+
MockInterpreterInfo(0, [
1848+
MockThreadInfo(1, [
1849+
MockFrameInfo("file.py", line, "func"),
1850+
MockFrameInfo("file.py", 1, "caller"),
1851+
])
1852+
])
1853+
]
1854+
1855+
diff = make_diff_collector_with_mock_baseline(
1856+
[sample(10), sample(20)]
1857+
)
1858+
diff.collect(sample(10))
1859+
diff.collect(sample(20))
1860+
1861+
data = diff._convert_to_flamegraph_format()
1862+
children = data["children"]
1863+
self.assertEqual(sum(node["self"] for node in children), 2)
1864+
self.assertEqual(sum(node["self_time"] for node in children), 2)
1865+
self.assertEqual(sum(node["baseline"] for node in children), 2)
1866+
for node in children:
1867+
self.assertEqual(node["self"], 1)
1868+
self.assertEqual(node["self_time"], 1)
1869+
self.assertAlmostEqual(node["baseline"], 1.0)
1870+
self.assertAlmostEqual(node["diff"], 0.0)
1871+
1872+
def test_diff_flamegraph_line_totals_include_allocated_self(self):
1873+
"""A line's baseline self time cannot exceed its inclusive time."""
1874+
def sample(*frames):
1875+
return [
1876+
MockInterpreterInfo(0, [MockThreadInfo(1, list(frames))])
1877+
]
1878+
1879+
target_10 = MockFrameInfo("file.py", 10, "target")
1880+
target_20 = MockFrameInfo("file.py", 20, "target")
1881+
child = MockFrameInfo("file.py", 30, "child")
1882+
1883+
diff = make_diff_collector_with_mock_baseline(
1884+
[sample(target_10)] * 100
1885+
)
1886+
for _ in range(10):
1887+
diff.collect(sample(target_10))
1888+
for _ in range(90):
1889+
diff.collect(sample(child, target_20))
1890+
1891+
data = diff._convert_to_flamegraph_format()
1892+
nodes = data["children"]
1893+
self.assertEqual(sum(node["baseline"] for node in nodes), 100)
1894+
self.assertEqual(sum(node["baseline_total"] for node in nodes), 100)
1895+
for node in nodes:
1896+
self.assertGreaterEqual(node["baseline"], 0)
1897+
self.assertLessEqual(node["baseline"], node["baseline_total"])
1898+
1899+
def test_diff_flamegraph_does_not_duplicate_elided_line_values(self):
1900+
"""Elided metadata uses each rendered line node's samples."""
1901+
def sample(line, funcname="old_func"):
1902+
return [
1903+
MockInterpreterInfo(0, [
1904+
MockThreadInfo(1, [
1905+
MockFrameInfo("file.py", line, funcname),
1906+
MockFrameInfo("file.py", 1, "caller"),
1907+
])
1908+
])
1909+
]
1910+
1911+
diff = make_diff_collector_with_mock_baseline(
1912+
[sample(10), sample(20)]
1913+
)
1914+
diff.collect(sample(30, "new_func"))
1915+
1916+
data = diff._convert_to_flamegraph_format()
1917+
elided = data["stats"]["elided_flamegraph"]
1918+
children = elided["children"]
1919+
scale = data["stats"]["baseline_scale"]
1920+
self.assertEqual(sum(node["self"] for node in children), 2)
1921+
self.assertEqual(sum(node["baseline"] for node in children), 2 * scale)
1922+
for node in children:
1923+
self.assertEqual(node["self"], 1)
1924+
self.assertAlmostEqual(node["baseline"], scale)
1925+
self.assertAlmostEqual(node["diff"], -scale)
1926+
1927+
def test_diff_flamegraph_elided_ancestors_have_no_lost_self_time(self):
1928+
"""Matched ancestors only carry inclusive elided geometry."""
1929+
root = MockFrameInfo("file.py", 10, "root")
1930+
common = MockFrameInfo("file.py", 20, "common", opcode=100)
1931+
old = MockFrameInfo("file.py", 30, "old")
1932+
1933+
common_sample = [
1934+
MockInterpreterInfo(0, [MockThreadInfo(1, [common, root])])
1935+
]
1936+
old_sample = [
1937+
MockInterpreterInfo(0, [MockThreadInfo(1, [old, common, root])])
1938+
]
1939+
1940+
diff = make_diff_collector_with_mock_baseline(
1941+
[common_sample] * 3 + [old_sample]
1942+
)
1943+
diff.collect(common_sample)
1944+
1945+
data = diff._convert_to_flamegraph_format()
1946+
elided_root = data["stats"]["elided_flamegraph"]
1947+
common_node = elided_root["children"][0]
1948+
old_node = common_node["children"][0]
1949+
1950+
for ancestor in (elided_root, common_node):
1951+
self.assertEqual(ancestor["self"], 0)
1952+
self.assertEqual(ancestor["baseline"], 0)
1953+
self.assertNotIn("opcodes", ancestor)
1954+
self.assertLessEqual(
1955+
ancestor["baseline"], ancestor["baseline_total"]
1956+
)
1957+
self.assertEqual(old_node["self"], 1)
1958+
self.assertEqual(old_node["baseline"], old_node["baseline_total"])
1959+
18431960
def test_diff_flamegraph_empty_current(self):
18441961
"""Empty current profile still produces differential metadata and elided paths."""
18451962
baseline_frames = [
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Prevent differential flamegraphs from duplicating self time across line
2+
nodes for the same function.

0 commit comments

Comments
 (0)