Skip to content

Commit bc7b511

Browse files
authored
gh-138122: Allow to filter by thread in tachyon's flamegraph (#139216)
1 parent 2462807 commit bc7b511

File tree

8 files changed

+231
-29
lines changed

8 files changed

+231
-29
lines changed

Lib/profiling/sampling/collector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ def _iter_all_frames(self, stack_frames, skip_idle=False):
3030
continue
3131
frames = thread_info.frame_info
3232
if frames:
33-
yield frames
33+
yield frames, thread_info.thread_id

Lib/profiling/sampling/flamegraph.css

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,65 @@ body {
227227
background: #ffcd02;
228228
}
229229

230+
.thread-filter-wrapper {
231+
display: none;
232+
align-items: center;
233+
margin-left: 16px;
234+
background: white;
235+
border-radius: 6px;
236+
padding: 4px 8px 4px 12px;
237+
border: 2px solid #3776ab;
238+
transition: all 0.2s ease;
239+
}
240+
241+
.thread-filter-wrapper:hover {
242+
border-color: #2d5aa0;
243+
box-shadow: 0 2px 6px rgba(55, 118, 171, 0.2);
244+
}
245+
246+
.thread-filter-label {
247+
color: #3776ab;
248+
font-size: 14px;
249+
font-weight: 600;
250+
margin-right: 8px;
251+
display: flex;
252+
align-items: center;
253+
}
254+
255+
.thread-filter-select {
256+
background: transparent;
257+
color: #2e3338;
258+
border: none;
259+
padding: 4px 24px 4px 4px;
260+
font-size: 14px;
261+
font-weight: 600;
262+
cursor: pointer;
263+
min-width: 120px;
264+
font-family: inherit;
265+
appearance: none;
266+
-webkit-appearance: none;
267+
-moz-appearance: none;
268+
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233776ab' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
269+
background-repeat: no-repeat;
270+
background-position: right 4px center;
271+
background-size: 16px;
272+
}
273+
274+
.thread-filter-select:focus {
275+
outline: none;
276+
}
277+
278+
.thread-filter-select:hover {
279+
color: #3776ab;
280+
}
281+
282+
.thread-filter-select option {
283+
padding: 8px;
284+
background: white;
285+
color: #2e3338;
286+
font-weight: normal;
287+
}
288+
230289
#chart {
231290
width: 100%;
232291
height: calc(100vh - 160px);

Lib/profiling/sampling/flamegraph.js

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}};
22

33
// Global string table for resolving string indices
44
let stringTable = [];
5+
let originalData = null;
6+
let currentThreadFilter = 'all';
57

68
// Function to resolve string indices to actual strings
79
function resolveString(index) {
@@ -374,6 +376,12 @@ function initFlamegraph() {
374376
processedData = resolveStringIndices(EMBEDDED_DATA);
375377
}
376378

379+
// Store original data for filtering
380+
originalData = processedData;
381+
382+
// Initialize thread filter dropdown
383+
initThreadFilter(processedData);
384+
377385
const tooltip = createPythonTooltip(processedData);
378386
const chart = createFlamegraph(tooltip, processedData.value);
379387
renderFlamegraph(chart, processedData);
@@ -395,10 +403,26 @@ function populateStats(data) {
395403
const functionMap = new Map();
396404

397405
function collectFunctions(node) {
398-
const filename = resolveString(node.filename);
399-
const funcname = resolveString(node.funcname);
406+
if (!node) return;
407+
408+
let filename = typeof node.filename === 'number' ? resolveString(node.filename) : node.filename;
409+
let funcname = typeof node.funcname === 'number' ? resolveString(node.funcname) : node.funcname;
410+
411+
if (!filename || !funcname) {
412+
const nameStr = typeof node.name === 'number' ? resolveString(node.name) : node.name;
413+
if (nameStr?.includes('(')) {
414+
const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/);
415+
if (match) {
416+
funcname = funcname || match[1];
417+
filename = filename || match[2];
418+
}
419+
}
420+
}
400421

401-
if (filename && funcname) {
422+
filename = filename || 'unknown';
423+
funcname = funcname || 'unknown';
424+
425+
if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
402426
// Calculate direct samples (this node's value minus children's values)
403427
let childrenValue = 0;
404428
if (node.children) {
@@ -447,15 +471,17 @@ function populateStats(data) {
447471
// Populate the 3 cards
448472
for (let i = 0; i < 3; i++) {
449473
const num = i + 1;
450-
if (i < hotSpots.length) {
474+
if (i < hotSpots.length && hotSpots[i]) {
451475
const hotspot = hotSpots[i];
452-
const basename = hotspot.filename.split('/').pop();
453-
let funcDisplay = hotspot.funcname;
476+
const filename = hotspot.filename || 'unknown';
477+
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
478+
const lineno = hotspot.lineno ?? '?';
479+
let funcDisplay = hotspot.funcname || 'unknown';
454480
if (funcDisplay.length > 35) {
455481
funcDisplay = funcDisplay.substring(0, 32) + '...';
456482
}
457483

458-
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${hotspot.lineno}`;
484+
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${lineno}`;
459485
document.getElementById(`hotspot-func-${num}`).textContent = funcDisplay;
460486
document.getElementById(`hotspot-detail-${num}`).textContent = `${hotspot.directPercent.toFixed(1)}% samples (${hotspot.directSamples.toLocaleString()})`;
461487
} else {
@@ -505,3 +531,102 @@ function clearSearch() {
505531
}
506532
}
507533

534+
function initThreadFilter(data) {
535+
const threadFilter = document.getElementById('thread-filter');
536+
const threadWrapper = document.querySelector('.thread-filter-wrapper');
537+
538+
if (!threadFilter || !data.threads) {
539+
return;
540+
}
541+
542+
// Clear existing options except "All Threads"
543+
threadFilter.innerHTML = '<option value="all">All Threads</option>';
544+
545+
// Add thread options
546+
const threads = data.threads || [];
547+
threads.forEach(threadId => {
548+
const option = document.createElement('option');
549+
option.value = threadId;
550+
option.textContent = `Thread ${threadId}`;
551+
threadFilter.appendChild(option);
552+
});
553+
554+
// Show filter if more than one thread
555+
if (threads.length > 1 && threadWrapper) {
556+
threadWrapper.style.display = 'inline-flex';
557+
}
558+
}
559+
560+
function filterByThread() {
561+
const threadFilter = document.getElementById('thread-filter');
562+
if (!threadFilter || !originalData) return;
563+
564+
const selectedThread = threadFilter.value;
565+
currentThreadFilter = selectedThread;
566+
567+
let filteredData;
568+
if (selectedThread === 'all') {
569+
// Show all data
570+
filteredData = originalData;
571+
} else {
572+
// Filter data by thread
573+
const threadId = parseInt(selectedThread);
574+
filteredData = filterDataByThread(originalData, threadId);
575+
576+
if (filteredData.strings) {
577+
stringTable = filteredData.strings;
578+
filteredData = resolveStringIndices(filteredData);
579+
}
580+
}
581+
582+
// Re-render flamegraph with filtered data
583+
const tooltip = createPythonTooltip(filteredData);
584+
const chart = createFlamegraph(tooltip, filteredData.value);
585+
renderFlamegraph(chart, filteredData);
586+
}
587+
588+
function filterDataByThread(data, threadId) {
589+
function filterNode(node) {
590+
if (!node.threads || !node.threads.includes(threadId)) {
591+
return null;
592+
}
593+
594+
const filteredNode = {
595+
...node,
596+
children: []
597+
};
598+
599+
if (node.children && Array.isArray(node.children)) {
600+
filteredNode.children = node.children
601+
.map(child => filterNode(child))
602+
.filter(child => child !== null);
603+
}
604+
605+
return filteredNode;
606+
}
607+
608+
const filteredRoot = {
609+
...data,
610+
children: []
611+
};
612+
613+
if (data.children && Array.isArray(data.children)) {
614+
filteredRoot.children = data.children
615+
.map(child => filterNode(child))
616+
.filter(child => child !== null);
617+
}
618+
619+
function recalculateValue(node) {
620+
if (!node.children || node.children.length === 0) {
621+
return node.value || 0;
622+
}
623+
const childrenValue = node.children.reduce((sum, child) => sum + recalculateValue(child), 0);
624+
node.value = Math.max(node.value || 0, childrenValue);
625+
return node.value;
626+
}
627+
628+
recalculateValue(filteredRoot);
629+
630+
return filteredRoot;
631+
}
632+

Lib/profiling/sampling/flamegraph_template.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ <h1>Tachyon Profiler Performance Flamegraph</h1>
6363
<button onclick="resetZoom()">🏠 Reset Zoom</button>
6464
<button onclick="exportSVG()" class="secondary">📁 Export SVG</button>
6565
<button onclick="toggleLegend()">🔥 Heat Map Legend</button>
66+
<div class="thread-filter-wrapper">
67+
<label class="thread-filter-label">🧵 Thread:</label>
68+
<select id="thread-filter" class="thread-filter-select" onchange="filterByThread()">
69+
<option value="all">All Threads</option>
70+
</select>
71+
</div>
6672
</div>
6773
</div>
6874

Lib/profiling/sampling/pstats_collector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _process_frames(self, frames):
4141
self.callers[callee][caller] += 1
4242

4343
def collect(self, stack_frames):
44-
for frames in self._iter_all_frames(stack_frames, skip_idle=self.skip_idle):
44+
for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=self.skip_idle):
4545
self._process_frames(frames)
4646

4747
def export(self, filename):

Lib/profiling/sampling/sample.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ def main():
754754
"--mode",
755755
choices=["wall", "cpu", "gil"],
756756
default="wall",
757-
help="Sampling mode: wall (all threads), cpu (only CPU-running threads), gil (only GIL-holding threads)",
757+
help="Sampling mode: wall (all threads), cpu (only CPU-running threads), gil (only GIL-holding threads) (default: wall)",
758758
)
759759

760760
# Output format selection

Lib/profiling/sampling/stack_collector.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ def __init__(self, *, skip_idle=False):
1515
self.skip_idle = skip_idle
1616

1717
def collect(self, stack_frames, skip_idle=False):
18-
for frames in self._iter_all_frames(stack_frames, skip_idle=skip_idle):
18+
for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=skip_idle):
1919
if not frames:
2020
continue
21-
self.process_frames(frames)
21+
self.process_frames(frames, thread_id)
2222

23-
def process_frames(self, frames):
23+
def process_frames(self, frames, thread_id):
2424
pass
2525

2626

@@ -29,17 +29,17 @@ def __init__(self, *args, **kwargs):
2929
super().__init__(*args, **kwargs)
3030
self.stack_counter = collections.Counter()
3131

32-
def process_frames(self, frames):
32+
def process_frames(self, frames, thread_id):
3333
call_tree = tuple(reversed(frames))
34-
self.stack_counter[call_tree] += 1
34+
self.stack_counter[(call_tree, thread_id)] += 1
3535

3636
def export(self, filename):
3737
lines = []
38-
for call_tree, count in self.stack_counter.items():
38+
for (call_tree, thread_id), count in self.stack_counter.items():
3939
stack_str = ";".join(
4040
f"{os.path.basename(f[0])}:{f[2]}:{f[1]}" for f in call_tree
4141
)
42-
lines.append((stack_str, count))
42+
lines.append((f"tid:{thread_id};{stack_str}", count))
4343

4444
lines.sort(key=lambda x: (-x[1], x[0]))
4545

@@ -53,10 +53,11 @@ class FlamegraphCollector(StackTraceCollector):
5353
def __init__(self, *args, **kwargs):
5454
super().__init__(*args, **kwargs)
5555
self.stats = {}
56-
self._root = {"samples": 0, "children": {}}
56+
self._root = {"samples": 0, "children": {}, "threads": set()}
5757
self._total_samples = 0
5858
self._func_intern = {}
5959
self._string_table = StringTable()
60+
self._all_threads = set()
6061

6162
def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None):
6263
"""Set profiling statistics to include in flamegraph data."""
@@ -111,6 +112,7 @@ def _convert_to_flamegraph_format(self):
111112
"name": self._string_table.intern("No Data"),
112113
"value": 0,
113114
"children": [],
115+
"threads": [],
114116
"strings": self._string_table.get_strings()
115117
}
116118

@@ -133,6 +135,7 @@ def convert_children(children, min_samples):
133135
"filename": filename_idx,
134136
"lineno": func[1],
135137
"funcname": funcname_idx,
138+
"threads": sorted(list(node.get("threads", set()))),
136139
}
137140

138141
source = self._get_source_lines(func)
@@ -172,6 +175,7 @@ def convert_children(children, min_samples):
172175
new_name = f"Program Root: {old_name}"
173176
main_child["name"] = self._string_table.intern(new_name)
174177
main_child["stats"] = self.stats
178+
main_child["threads"] = sorted(list(self._all_threads))
175179
main_child["strings"] = self._string_table.get_strings()
176180
return main_child
177181

@@ -180,24 +184,28 @@ def convert_children(children, min_samples):
180184
"value": total_samples,
181185
"children": root_children,
182186
"stats": self.stats,
187+
"threads": sorted(list(self._all_threads)),
183188
"strings": self._string_table.get_strings()
184189
}
185190

186-
def process_frames(self, frames):
191+
def process_frames(self, frames, thread_id):
187192
# Reverse to root->leaf
188193
call_tree = reversed(frames)
189194
self._root["samples"] += 1
190195
self._total_samples += 1
196+
self._root["threads"].add(thread_id)
197+
self._all_threads.add(thread_id)
191198

192199
current = self._root
193200
for func in call_tree:
194201
func = self._func_intern.setdefault(func, func)
195202
children = current["children"]
196203
node = children.get(func)
197204
if node is None:
198-
node = {"samples": 0, "children": {}}
205+
node = {"samples": 0, "children": {}, "threads": set()}
199206
children[func] = node
200207
node["samples"] += 1
208+
node["threads"].add(thread_id)
201209
current = node
202210

203211
def _get_source_lines(self, func):

0 commit comments

Comments
 (0)