Skip to content

Commit 044ab14

Browse files
pablogsalmiss-islington
authored andcommitted
gh-154086: Fix flamegraph thread sample counts (GH-154104)
(cherry picked from commit e4b22ad) Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com>
1 parent be93a4a commit 044ab14

4 files changed

Lines changed: 190 additions & 34 deletions

File tree

Lib/profiling/sampling/_flamegraph_assets/flamegraph.js

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,23 @@ function getDisplayName(moduleName, filename) {
9999
return filename;
100100
}
101101

102-
function selectFlamegraphData() {
103-
const baseData = isShowingElided ? elidedFlamegraphData : normalData;
102+
function selectFlamegraphData(selectedThreadId = null) {
103+
let baseData = isShowingElided ? elidedFlamegraphData : normalData;
104+
105+
if (selectedThreadId !== null) {
106+
baseData = filterDataByThread(baseData, selectedThreadId);
107+
}
104108

105109
if (!isInverted) {
106110
return baseData;
107111
}
108112

113+
// Thread-filtered trees have different values, so invert them after filtering
114+
// instead of using the cached all-thread tree.
115+
if (selectedThreadId !== null) {
116+
return generateInvertedFlamegraph(baseData);
117+
}
118+
109119
if (isShowingElided) {
110120
if (!invertedElidedData) {
111121
invertedElidedData = generateInvertedFlamegraph(baseData);
@@ -120,12 +130,11 @@ function selectFlamegraphData() {
120130
}
121131

122132
function updateFlamegraphView() {
123-
const selectedData = selectFlamegraphData();
124133
const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null;
125-
const filteredData = selectedThreadId !== null ? filterDataByThread(selectedData, selectedThreadId) : selectedData;
126-
const tooltip = createPythonTooltip(filteredData);
127-
const chart = createFlamegraph(tooltip, filteredData.value, filteredData);
128-
renderFlamegraph(chart, filteredData);
134+
const selectedData = selectFlamegraphData(selectedThreadId);
135+
const tooltip = createPythonTooltip(selectedData);
136+
const chart = createFlamegraph(tooltip, selectedData.value, selectedData);
137+
renderFlamegraph(chart, selectedData);
129138
populateThreadStats(selectedData, selectedThreadId);
130139
}
131140

@@ -937,7 +946,9 @@ function formatDuration(seconds) {
937946

938947
function populateProfileSummary(data) {
939948
const stats = data.stats || {};
940-
const totalSamples = stats.total_samples || data.value || 0;
949+
const totalSamples = currentThreadFilter !== 'all'
950+
? (data.value ?? 0)
951+
: (stats.total_samples ?? data.value ?? 0);
941952
const duration = stats.duration_sec || 0;
942953
const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0);
943954
const errorRate = stats.error_rate || 0;
@@ -1209,7 +1220,7 @@ function initThreadFilter(data) {
12091220
const threadFilter = document.getElementById('thread-filter');
12101221
const threadSection = document.getElementById('thread-section');
12111222

1212-
if (!threadFilter || !data.threads) return;
1223+
if (!threadFilter || !data.threads || data.stats?.is_differential) return;
12131224

12141225
threadFilter.innerHTML = '<option value="all">All Threads</option>';
12151226

@@ -1238,11 +1249,23 @@ function filterByThread() {
12381249

12391250
function filterDataByThread(data, threadId) {
12401251
function filterNode(node) {
1241-
if (!node.threads || !node.threads.includes(threadId)) {
1252+
const threadValues = node.thread_values?.[threadId];
1253+
if (!threadValues) {
12421254
return null;
12431255
}
12441256

1245-
const filteredNode = { ...node, children: [] };
1257+
const {
1258+
thread_values: _threadValues,
1259+
thread_opcodes: threadOpcodes,
1260+
...sharedNode
1261+
} = node;
1262+
const filteredNode = {
1263+
...sharedNode,
1264+
value: threadValues[0],
1265+
self: threadValues[1],
1266+
opcodes: threadOpcodes?.[threadId] ?? {},
1267+
children: []
1268+
};
12461269

12471270
if (node.children && Array.isArray(node.children)) {
12481271
filteredNode.children = node.children
@@ -1253,25 +1276,7 @@ function filterDataByThread(data, threadId) {
12531276
return filteredNode;
12541277
}
12551278

1256-
function recalculateValue(node) {
1257-
if (!node.children || node.children.length === 0) {
1258-
return node.value || 0;
1259-
}
1260-
const childrenValue = node.children.reduce((sum, child) => sum + recalculateValue(child), 0);
1261-
node.value = Math.max(node.value || 0, childrenValue);
1262-
return node.value;
1263-
}
1264-
1265-
const filteredRoot = { ...data, children: [] };
1266-
1267-
if (data.children && Array.isArray(data.children)) {
1268-
filteredRoot.children = data.children
1269-
.map(child => filterNode(child))
1270-
.filter(child => child !== null);
1271-
}
1272-
1273-
recalculateValue(filteredRoot);
1274-
return filteredRoot;
1279+
return filterNode(data);
12751280
}
12761281

12771282
// ============================================================================

Lib/profiling/sampling/stack_collector.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ class FlamegraphCollector(StackTraceCollector):
7171
def __init__(self, *args, **kwargs):
7272
super().__init__(*args, **kwargs)
7373
self.stats = {}
74-
self._root = {"samples": 0, "children": {}, "threads": set()}
74+
self._root = {
75+
"samples": 0,
76+
"children": {},
77+
"threads": set(),
78+
"thread_samples": collections.Counter(),
79+
"thread_self": collections.Counter(),
80+
}
7581
self._total_samples = 0
7682
self._sample_count = 0 # Track actual number of samples (not thread traces)
7783
self._func_intern = {}
@@ -220,7 +226,18 @@ def convert_children(children, min_samples, path_info):
220226
out = []
221227
for func, node in children.items():
222228
samples = node["samples"]
223-
if samples < min_samples:
229+
significant_for_thread = any(
230+
thread_samples >= max(
231+
1,
232+
int(
233+
self._root["thread_samples"][thread_id]
234+
* 0.001
235+
),
236+
)
237+
for thread_id, thread_samples
238+
in node["thread_samples"].items()
239+
)
240+
if samples < min_samples and not significant_for_thread:
224241
continue
225242

226243
# Intern all string components for maximum efficiency
@@ -243,6 +260,15 @@ def convert_children(children, min_samples, path_info):
243260
"lineno": func[1],
244261
"funcname": funcname_idx,
245262
"threads": sorted(list(node.get("threads", set()))),
263+
"thread_values": {
264+
thread_id: [
265+
samples,
266+
node["thread_self"].get(thread_id, 0),
267+
]
268+
for thread_id, samples in sorted(
269+
node["thread_samples"].items()
270+
)
271+
},
246272
}
247273

248274
source = self._get_source_lines(func)
@@ -255,6 +281,14 @@ def convert_children(children, min_samples, path_info):
255281
opcodes = node.get("opcodes", {})
256282
if opcodes:
257283
child_entry["opcodes"] = dict(opcodes)
284+
thread_opcodes = node.get("thread_opcodes")
285+
if thread_opcodes:
286+
child_entry["thread_opcodes"] = {
287+
thread_id: dict(counts)
288+
for thread_id, counts in sorted(
289+
thread_opcodes.items()
290+
)
291+
}
258292

259293
# Recurse
260294
child_entry["children"] = convert_children(
@@ -311,7 +345,25 @@ def convert_children(children, min_samples, path_info):
311345
opcode_mapping = get_opcode_mapping()
312346

313347
# If we only have one root child, make it the root to avoid redundant level
314-
if len(root_children) == 1:
348+
root_thread_values = {
349+
thread_id: [samples, 0]
350+
for thread_id, samples in sorted(
351+
self._root["thread_samples"].items()
352+
)
353+
}
354+
sole_root_covers_profile = (
355+
len(root_children) == 1
356+
and root_children[0]["value"] == total_samples
357+
and {
358+
thread_id: values[0]
359+
for thread_id, values
360+
in root_children[0]["thread_values"].items()
361+
} == {
362+
thread_id: values[0]
363+
for thread_id, values in root_thread_values.items()
364+
}
365+
)
366+
if sole_root_covers_profile:
315367
main_child = root_children[0]
316368
# Update name and label to indicate it's the program root
317369
old_name = self._string_table.get_string(main_child["name"])
@@ -340,6 +392,7 @@ def convert_children(children, min_samples, path_info):
340392
"per_thread_stats": per_thread_stats_with_pct
341393
},
342394
"threads": sorted(list(self._all_threads)),
395+
"thread_values": root_thread_values,
343396
"strings": self._string_table.get_strings(),
344397
"opcode_mapping": opcode_mapping
345398
}
@@ -356,6 +409,7 @@ def process_frames(self, frames, thread_id, weight=1):
356409
"""
357410
# Reverse to root->leaf order for tree building
358411
self._root["samples"] += weight
412+
self._root["thread_samples"][thread_id] += weight
359413
self._total_samples += weight
360414
self._root["threads"].add(thread_id)
361415
self._all_threads.add(thread_id)
@@ -368,18 +422,32 @@ def process_frames(self, frames, thread_id, weight=1):
368422

369423
node = current["children"].get(func)
370424
if node is None:
371-
node = {"samples": 0, "children": {}, "threads": set(), "opcodes": collections.Counter(), "self": 0}
425+
node = {
426+
"samples": 0,
427+
"children": {},
428+
"threads": set(),
429+
"thread_samples": collections.Counter(),
430+
"thread_self": collections.Counter(),
431+
"opcodes": collections.Counter(),
432+
"self": 0,
433+
}
372434
current["children"][func] = node
373435
node["samples"] += weight
436+
node["thread_samples"][thread_id] += weight
374437
node["threads"].add(thread_id)
375438

376439
if opcode is not None:
377440
node["opcodes"][opcode] += weight
441+
thread_opcodes = node.setdefault("thread_opcodes", {})
442+
thread_opcodes.setdefault(
443+
thread_id, collections.Counter()
444+
)[opcode] += weight
378445

379446
current = node
380447

381448
if current is not self._root:
382449
current["self"] += weight
450+
current["thread_self"][thread_id] += weight
383451

384452
def _get_source_lines(self, func):
385453
filename, lineno, _ = func

Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,87 @@ def test_flamegraph_collector_json_structure_includes_stats(self):
13361336
self.assertIn("gc_pct", thread_data)
13371337
self.assertIn("total", thread_data)
13381338

1339+
def test_flamegraph_nodes_include_per_thread_values(self):
1340+
collector = FlamegraphCollector(sample_interval_usec=1000)
1341+
root = MockFrameInfo("app.py", 1, "main")
1342+
collector.process_frames(
1343+
[MockFrameInfo("app.py", 10, "worker_a"), root],
1344+
thread_id=1,
1345+
weight=2,
1346+
)
1347+
collector.process_frames(
1348+
[MockFrameInfo("app.py", 20, "worker_b"), root],
1349+
thread_id=2,
1350+
weight=3,
1351+
)
1352+
1353+
data = collector._convert_to_flamegraph_format()
1354+
1355+
self.assertEqual(data["thread_values"], {1: [2, 0], 2: [3, 0]})
1356+
children_by_line = {child["lineno"]: child for child in data["children"]}
1357+
self.assertEqual(children_by_line[10]["thread_values"], {1: [2, 2]})
1358+
self.assertEqual(children_by_line[20]["thread_values"], {2: [3, 3]})
1359+
1360+
def test_flamegraph_pruning_preserves_low_volume_thread(self):
1361+
collector = FlamegraphCollector(sample_interval_usec=1000)
1362+
collector.process_frames(
1363+
[MockFrameInfo("app.py", 10, "busy")],
1364+
thread_id=1,
1365+
weight=1999,
1366+
)
1367+
collector.process_frames(
1368+
[MockFrameInfo("app.py", 20, "rare")],
1369+
thread_id=2,
1370+
)
1371+
1372+
data = collector._convert_to_flamegraph_format()
1373+
1374+
self.assertEqual(data["thread_values"], {1: [1999, 0], 2: [1, 0]})
1375+
children_by_line = {child["lineno"]: child for child in data["children"]}
1376+
self.assertEqual(children_by_line[10]["thread_values"], {1: [1999, 1999]})
1377+
self.assertEqual(children_by_line[20]["thread_values"], {2: [1, 1]})
1378+
1379+
def test_flamegraph_does_not_promote_incomplete_root(self):
1380+
collector = FlamegraphCollector(sample_interval_usec=1000)
1381+
collector.process_frames(
1382+
[MockFrameInfo("app.py", 1, "busy")],
1383+
thread_id=1,
1384+
weight=2000,
1385+
)
1386+
for line in range(2, 2002):
1387+
collector.process_frames(
1388+
[MockFrameInfo("app.py", line, f"fragment_{line}")],
1389+
thread_id=2,
1390+
)
1391+
1392+
data = collector._convert_to_flamegraph_format()
1393+
1394+
self.assertNotIn("filename", data)
1395+
self.assertEqual(data["thread_values"], {1: [2000, 0], 2: [2000, 0]})
1396+
self.assertEqual(len(data["children"]), 1)
1397+
self.assertEqual(data["children"][0]["thread_values"], {1: [2000, 2000]})
1398+
1399+
def test_flamegraph_nodes_include_per_thread_opcodes(self):
1400+
collector = FlamegraphCollector(sample_interval_usec=1000)
1401+
collector.process_frames(
1402+
[MockFrameInfo("app.py", 10, "worker", opcode=100)],
1403+
thread_id=1,
1404+
weight=2,
1405+
)
1406+
collector.process_frames(
1407+
[MockFrameInfo("app.py", 10, "worker", opcode=101)],
1408+
thread_id=2,
1409+
weight=3,
1410+
)
1411+
1412+
data = collector._convert_to_flamegraph_format()
1413+
1414+
self.assertEqual(data["opcodes"], {100: 2, 101: 3})
1415+
self.assertEqual(
1416+
data["thread_opcodes"],
1417+
{1: {100: 2}, 2: {101: 3}},
1418+
)
1419+
13391420
def test_flamegraph_collector_per_thread_gc_percentage(self):
13401421
"""Test that per-thread GC percentage uses total samples as denominator."""
13411422
collector = FlamegraphCollector(sample_interval_usec=1000)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Store per-thread sample counts in Tachyon flamegraphs so filtering a thread
2+
updates frame widths and totals.

0 commit comments

Comments
 (0)