From 6058ac832f630bc8b4b5154920a6dbfb014e4614 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sun, 19 Jul 2026 11:46:12 +0100 Subject: [PATCH 1/2] gh-154086: Fix flamegraph thread sample counts --- .../sampling/_flamegraph_assets/flamegraph.js | 55 +++++++++---------- Lib/profiling/sampling/stack_collector.py | 36 +++++++++++- .../test_sampling_profiler/test_collectors.py | 21 +++++++ ...-07-19-13-00-00.gh-issue-154086.MJKrQV.rst | 2 + 4 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-13-00-00.gh-issue-154086.MJKrQV.rst diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 840acf2c27d1201..461ebf4bcf25b88 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -99,13 +99,23 @@ function getDisplayName(moduleName, filename) { return filename; } -function selectFlamegraphData() { - const baseData = isShowingElided ? elidedFlamegraphData : normalData; +function selectFlamegraphData(selectedThreadId = null) { + let baseData = isShowingElided ? elidedFlamegraphData : normalData; + + if (selectedThreadId !== null) { + baseData = filterDataByThread(baseData, selectedThreadId); + } if (!isInverted) { return baseData; } + // Thread-filtered trees have different values, so invert them after filtering + // instead of using the cached all-thread tree. + if (selectedThreadId !== null) { + return generateInvertedFlamegraph(baseData); + } + if (isShowingElided) { if (!invertedElidedData) { invertedElidedData = generateInvertedFlamegraph(baseData); @@ -120,12 +130,11 @@ function selectFlamegraphData() { } function updateFlamegraphView() { - const selectedData = selectFlamegraphData(); const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null; - const filteredData = selectedThreadId !== null ? filterDataByThread(selectedData, selectedThreadId) : selectedData; - const tooltip = createPythonTooltip(filteredData); - const chart = createFlamegraph(tooltip, filteredData.value, filteredData); - renderFlamegraph(chart, filteredData); + const selectedData = selectFlamegraphData(selectedThreadId); + const tooltip = createPythonTooltip(selectedData); + const chart = createFlamegraph(tooltip, selectedData.value, selectedData); + renderFlamegraph(chart, selectedData); populateThreadStats(selectedData, selectedThreadId); } @@ -1209,7 +1218,7 @@ function initThreadFilter(data) { const threadFilter = document.getElementById('thread-filter'); const threadSection = document.getElementById('thread-section'); - if (!threadFilter || !data.threads) return; + if (!threadFilter || !data.threads || data.stats?.is_differential) return; threadFilter.innerHTML = ''; @@ -1238,11 +1247,17 @@ function filterByThread() { function filterDataByThread(data, threadId) { function filterNode(node) { - if (!node.threads || !node.threads.includes(threadId)) { + const threadValues = node.thread_values?.[threadId]; + if (!threadValues) { return null; } - const filteredNode = { ...node, children: [] }; + const filteredNode = { + ...node, + value: threadValues[0], + self: threadValues[1], + children: [] + }; if (node.children && Array.isArray(node.children)) { filteredNode.children = node.children @@ -1253,25 +1268,7 @@ function filterDataByThread(data, threadId) { return filteredNode; } - function recalculateValue(node) { - if (!node.children || node.children.length === 0) { - return node.value || 0; - } - const childrenValue = node.children.reduce((sum, child) => sum + recalculateValue(child), 0); - node.value = Math.max(node.value || 0, childrenValue); - return node.value; - } - - const filteredRoot = { ...data, children: [] }; - - if (data.children && Array.isArray(data.children)) { - filteredRoot.children = data.children - .map(child => filterNode(child)) - .filter(child => child !== null); - } - - recalculateValue(filteredRoot); - return filteredRoot; + return filterNode(data); } // ============================================================================ diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index eb1a3fba93cf33b..1a3327c1b6989c4 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -71,7 +71,13 @@ class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.stats = {} - self._root = {"samples": 0, "children": {}, "threads": set()} + self._root = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + } self._total_samples = 0 self._sample_count = 0 # Track actual number of samples (not thread traces) self._func_intern = {} @@ -243,6 +249,15 @@ def convert_children(children, min_samples, path_info): "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))), + "thread_values": { + thread_id: [ + samples, + node["thread_self"].get(thread_id, 0), + ] + for thread_id, samples in sorted( + node["thread_samples"].items() + ) + }, } source = self._get_source_lines(func) @@ -340,6 +355,12 @@ def convert_children(children, min_samples, path_info): "per_thread_stats": per_thread_stats_with_pct }, "threads": sorted(list(self._all_threads)), + "thread_values": { + thread_id: [samples, 0] + for thread_id, samples in sorted( + self._root["thread_samples"].items() + ) + }, "strings": self._string_table.get_strings(), "opcode_mapping": opcode_mapping } @@ -356,6 +377,7 @@ def process_frames(self, frames, thread_id, weight=1): """ # Reverse to root->leaf order for tree building self._root["samples"] += weight + self._root["thread_samples"][thread_id] += weight self._total_samples += weight self._root["threads"].add(thread_id) self._all_threads.add(thread_id) @@ -368,9 +390,18 @@ def process_frames(self, frames, thread_id, weight=1): node = current["children"].get(func) if node is None: - node = {"samples": 0, "children": {}, "threads": set(), "opcodes": collections.Counter(), "self": 0} + node = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + "opcodes": collections.Counter(), + "self": 0, + } current["children"][func] = node node["samples"] += weight + node["thread_samples"][thread_id] += weight node["threads"].add(thread_id) if opcode is not None: @@ -380,6 +411,7 @@ def process_frames(self, frames, thread_id, weight=1): if current is not self._root: current["self"] += weight + current["thread_self"][thread_id] += weight def _get_source_lines(self, func): filename, lineno, _ = func 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 7746811014a9e2f..d372fd953a2c2d8 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -1336,6 +1336,27 @@ def test_flamegraph_collector_json_structure_includes_stats(self): self.assertIn("gc_pct", thread_data) self.assertIn("total", thread_data) + def test_flamegraph_nodes_include_per_thread_values(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + root = MockFrameInfo("app.py", 1, "main") + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker_a"), root], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "worker_b"), root], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [2, 0], 2: [3, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [2, 2]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [3, 3]}) + def test_flamegraph_collector_per_thread_gc_percentage(self): """Test that per-thread GC percentage uses total samples as denominator.""" collector = FlamegraphCollector(sample_interval_usec=1000) diff --git a/Misc/NEWS.d/next/Library/2026-07-19-13-00-00.gh-issue-154086.MJKrQV.rst b/Misc/NEWS.d/next/Library/2026-07-19-13-00-00.gh-issue-154086.MJKrQV.rst new file mode 100644 index 000000000000000..f28037d46520f6d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-13-00-00.gh-issue-154086.MJKrQV.rst @@ -0,0 +1,2 @@ +Store per-thread sample counts in Tachyon flamegraphs so filtering a thread +updates frame widths and totals. From 02c946207d4b1756974e0d1c51090fb5dc3bec2c Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sun, 19 Jul 2026 12:35:42 +0100 Subject: [PATCH 2/2] gh-154086: Make thread filtering exact --- .../sampling/_flamegraph_assets/flamegraph.js | 12 +++- Lib/profiling/sampling/stack_collector.py | 52 +++++++++++++--- .../test_sampling_profiler/test_collectors.py | 60 +++++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 461ebf4bcf25b88..f1cdf5142fa3949 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -946,7 +946,9 @@ function formatDuration(seconds) { function populateProfileSummary(data) { const stats = data.stats || {}; - const totalSamples = stats.total_samples || data.value || 0; + const totalSamples = currentThreadFilter !== 'all' + ? (data.value ?? 0) + : (stats.total_samples ?? data.value ?? 0); const duration = stats.duration_sec || 0; const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0); const errorRate = stats.error_rate || 0; @@ -1252,10 +1254,16 @@ function filterDataByThread(data, threadId) { return null; } + const { + thread_values: _threadValues, + thread_opcodes: threadOpcodes, + ...sharedNode + } = node; const filteredNode = { - ...node, + ...sharedNode, value: threadValues[0], self: threadValues[1], + opcodes: threadOpcodes?.[threadId] ?? {}, children: [] }; diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 1a3327c1b6989c4..ace0e1a12290131 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -226,7 +226,18 @@ def convert_children(children, min_samples, path_info): out = [] for func, node in children.items(): samples = node["samples"] - if samples < min_samples: + significant_for_thread = any( + thread_samples >= max( + 1, + int( + self._root["thread_samples"][thread_id] + * 0.001 + ), + ) + for thread_id, thread_samples + in node["thread_samples"].items() + ) + if samples < min_samples and not significant_for_thread: continue # Intern all string components for maximum efficiency @@ -270,6 +281,14 @@ def convert_children(children, min_samples, path_info): opcodes = node.get("opcodes", {}) if opcodes: child_entry["opcodes"] = dict(opcodes) + thread_opcodes = node.get("thread_opcodes") + if thread_opcodes: + child_entry["thread_opcodes"] = { + thread_id: dict(counts) + for thread_id, counts in sorted( + thread_opcodes.items() + ) + } # Recurse child_entry["children"] = convert_children( @@ -326,7 +345,25 @@ def convert_children(children, min_samples, path_info): opcode_mapping = get_opcode_mapping() # If we only have one root child, make it the root to avoid redundant level - if len(root_children) == 1: + root_thread_values = { + thread_id: [samples, 0] + for thread_id, samples in sorted( + self._root["thread_samples"].items() + ) + } + sole_root_covers_profile = ( + len(root_children) == 1 + and root_children[0]["value"] == total_samples + and { + thread_id: values[0] + for thread_id, values + in root_children[0]["thread_values"].items() + } == { + thread_id: values[0] + for thread_id, values in root_thread_values.items() + } + ) + if sole_root_covers_profile: main_child = root_children[0] # Update name and label to indicate it's the program root old_name = self._string_table.get_string(main_child["name"]) @@ -355,12 +392,7 @@ def convert_children(children, min_samples, path_info): "per_thread_stats": per_thread_stats_with_pct }, "threads": sorted(list(self._all_threads)), - "thread_values": { - thread_id: [samples, 0] - for thread_id, samples in sorted( - self._root["thread_samples"].items() - ) - }, + "thread_values": root_thread_values, "strings": self._string_table.get_strings(), "opcode_mapping": opcode_mapping } @@ -406,6 +438,10 @@ def process_frames(self, frames, thread_id, weight=1): if opcode is not None: node["opcodes"][opcode] += weight + thread_opcodes = node.setdefault("thread_opcodes", {}) + thread_opcodes.setdefault( + thread_id, collections.Counter() + )[opcode] += weight current = node 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 d372fd953a2c2d8..1aba1572cc89d38 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -1357,6 +1357,66 @@ def test_flamegraph_nodes_include_per_thread_values(self): self.assertEqual(children_by_line[10]["thread_values"], {1: [2, 2]}) self.assertEqual(children_by_line[20]["thread_values"], {2: [3, 3]}) + def test_flamegraph_pruning_preserves_low_volume_thread(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "busy")], + thread_id=1, + weight=1999, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "rare")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [1999, 0], 2: [1, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [1999, 1999]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [1, 1]}) + + def test_flamegraph_does_not_promote_incomplete_root(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 1, "busy")], + thread_id=1, + weight=2000, + ) + for line in range(2, 2002): + collector.process_frames( + [MockFrameInfo("app.py", line, f"fragment_{line}")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertNotIn("filename", data) + self.assertEqual(data["thread_values"], {1: [2000, 0], 2: [2000, 0]}) + self.assertEqual(len(data["children"]), 1) + self.assertEqual(data["children"][0]["thread_values"], {1: [2000, 2000]}) + + def test_flamegraph_nodes_include_per_thread_opcodes(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=100)], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=101)], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["opcodes"], {100: 2, 101: 3}) + self.assertEqual( + data["thread_opcodes"], + {1: {100: 2}, 2: {101: 3}}, + ) + def test_flamegraph_collector_per_thread_gc_percentage(self): """Test that per-thread GC percentage uses total samples as denominator.""" collector = FlamegraphCollector(sample_interval_usec=1000)