Skip to content

Commit b1e1a0e

Browse files
committed
gh-138122: Allow to filter by thread in tachyon's flamegraph
1 parent 9df477c commit b1e1a0e

File tree

6 files changed

+259
-19
lines changed

6 files changed

+259
-19
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: inline-flex;
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: 174 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,39 @@ 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+
// Debug to understand the node structure
407+
if (!node) return;
408+
409+
// Try multiple ways to get the filename and function name
410+
let filename = node.filename;
411+
let funcname = node.funcname || node.name;
412+
413+
// If they're numbers (string indices), resolve them
414+
if (typeof filename === 'number') {
415+
filename = resolveString(filename);
416+
}
417+
if (typeof funcname === 'number') {
418+
funcname = resolveString(funcname);
419+
}
400420

401-
if (filename && funcname) {
421+
// If they're still undefined or null, try extracting from the name field
422+
if (!filename && node.name) {
423+
const nameStr = typeof node.name === 'number' ? resolveString(node.name) : node.name;
424+
if (nameStr && nameStr.includes('(')) {
425+
// Parse format: "funcname (filename:lineno)"
426+
const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/);
427+
if (match) {
428+
funcname = funcname || match[1];
429+
filename = filename || match[2];
430+
}
431+
}
432+
}
433+
434+
// Final fallback
435+
filename = filename || 'unknown';
436+
funcname = funcname || 'unknown';
437+
438+
if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
402439
// Calculate direct samples (this node's value minus children's values)
403440
let childrenValue = 0;
404441
if (node.children) {
@@ -447,15 +484,18 @@ function populateStats(data) {
447484
// Populate the 3 cards
448485
for (let i = 0; i < 3; i++) {
449486
const num = i + 1;
450-
if (i < hotSpots.length) {
487+
if (i < hotSpots.length && hotSpots[i]) {
451488
const hotspot = hotSpots[i];
452-
const basename = hotspot.filename.split('/').pop();
453-
let funcDisplay = hotspot.funcname;
489+
// Safe extraction with fallbacks
490+
const filename = hotspot.filename || 'unknown';
491+
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
492+
const lineno = hotspot.lineno !== undefined && hotspot.lineno !== null ? hotspot.lineno : '?';
493+
let funcDisplay = hotspot.funcname || 'unknown';
454494
if (funcDisplay.length > 35) {
455495
funcDisplay = funcDisplay.substring(0, 32) + '...';
456496
}
457497

458-
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${hotspot.lineno}`;
498+
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${lineno}`;
459499
document.getElementById(`hotspot-func-${num}`).textContent = funcDisplay;
460500
document.getElementById(`hotspot-detail-${num}`).textContent = `${hotspot.directPercent.toFixed(1)}% samples (${hotspot.directSamples.toLocaleString()})`;
461501
} else {
@@ -505,3 +545,130 @@ function clearSearch() {
505545
}
506546
}
507547

548+
function initThreadFilter(data) {
549+
const threadFilter = document.getElementById('thread-filter');
550+
const threadWrapper = document.querySelector('.thread-filter-wrapper');
551+
552+
if (!threadFilter || !data.threads) {
553+
// Hide thread filter if no thread data
554+
if (threadWrapper) {
555+
threadWrapper.style.display = 'none';
556+
}
557+
return;
558+
}
559+
560+
// Clear existing options except "All Threads"
561+
threadFilter.innerHTML = '<option value="all">All Threads</option>';
562+
563+
// Add thread options
564+
const threads = data.threads || [];
565+
threads.forEach(threadId => {
566+
const option = document.createElement('option');
567+
option.value = threadId;
568+
option.textContent = `Thread ${threadId}`;
569+
threadFilter.appendChild(option);
570+
});
571+
572+
// Hide filter if only one thread or no threads
573+
if (threads.length <= 1 && threadWrapper) {
574+
threadWrapper.style.display = 'none';
575+
}
576+
}
577+
578+
function filterByThread() {
579+
const threadFilter = document.getElementById('thread-filter');
580+
if (!threadFilter || !originalData) return;
581+
582+
const selectedThread = threadFilter.value;
583+
currentThreadFilter = selectedThread;
584+
585+
let filteredData;
586+
if (selectedThread === 'all') {
587+
// Show all data
588+
filteredData = originalData;
589+
} else {
590+
// Filter data by thread
591+
const threadId = parseInt(selectedThread);
592+
filteredData = filterDataByThread(originalData, threadId);
593+
594+
// Ensure string indices are resolved for the filtered data
595+
if (filteredData.strings) {
596+
stringTable = filteredData.strings;
597+
filteredData = resolveStringIndices(filteredData);
598+
}
599+
}
600+
601+
// Re-render flamegraph with filtered data
602+
const tooltip = createPythonTooltip(filteredData);
603+
const chart = createFlamegraph(tooltip, filteredData.value);
604+
renderFlamegraph(chart, filteredData);
605+
}
606+
607+
function filterDataByThread(data, threadId) {
608+
// Deep clone the data structure and filter by thread
609+
function filterNode(node) {
610+
// Check if this node contains the thread
611+
if (!node.threads || !node.threads.includes(threadId)) {
612+
return null;
613+
}
614+
615+
// Create a filtered copy of the node, preserving all fields
616+
const filteredNode = {
617+
name: node.name,
618+
value: node.value,
619+
filename: node.filename,
620+
funcname: node.funcname,
621+
lineno: node.lineno,
622+
threads: node.threads,
623+
source: node.source,
624+
children: []
625+
};
626+
627+
// Copy any other properties that might exist
628+
Object.keys(node).forEach(key => {
629+
if (!(key in filteredNode)) {
630+
filteredNode[key] = node[key];
631+
}
632+
});
633+
634+
// Recursively filter children
635+
if (node.children && Array.isArray(node.children)) {
636+
filteredNode.children = node.children
637+
.map(child => filterNode(child))
638+
.filter(child => child !== null);
639+
}
640+
641+
return filteredNode;
642+
}
643+
644+
// Create filtered root, preserving all metadata
645+
const filteredRoot = {
646+
...data,
647+
children: [],
648+
strings: data.strings // Preserve string table
649+
};
650+
651+
// Filter children
652+
if (data.children && Array.isArray(data.children)) {
653+
filteredRoot.children = data.children
654+
.map(child => filterNode(child))
655+
.filter(child => child !== null);
656+
}
657+
658+
// Recalculate total value based on filtered children
659+
function recalculateValue(node) {
660+
if (!node.children || node.children.length === 0) {
661+
return node.value || 0;
662+
}
663+
const childrenValue = node.children.reduce((sum, child) => {
664+
return sum + recalculateValue(child);
665+
}, 0);
666+
node.value = Math.max(node.value || 0, childrenValue);
667+
return node.value;
668+
}
669+
670+
recalculateValue(filteredRoot);
671+
672+
return filteredRoot;
673+
}
674+

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):

0 commit comments

Comments
 (0)