Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
gh-138122: Allow to filter by thread in tachyon's flamegraph
  • Loading branch information
pablogsal committed Sep 21, 2025
commit b1e1a0e3b1aaab6e96cd1def87435c03a990e212
2 changes: 1 addition & 1 deletion Lib/profiling/sampling/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ def _iter_all_frames(self, stack_frames, skip_idle=False):
continue
frames = thread_info.frame_info
if frames:
yield frames
yield frames, thread_info.thread_id
59 changes: 59 additions & 0 deletions Lib/profiling/sampling/flamegraph.css
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,65 @@ body {
background: #ffcd02;
}

.thread-filter-wrapper {
display: inline-flex;
align-items: center;
margin-left: 16px;
background: white;
border-radius: 6px;
padding: 4px 8px 4px 12px;
border: 2px solid #3776ab;
transition: all 0.2s ease;
}

.thread-filter-wrapper:hover {
border-color: #2d5aa0;
box-shadow: 0 2px 6px rgba(55, 118, 171, 0.2);
}

.thread-filter-label {
color: #3776ab;
font-size: 14px;
font-weight: 600;
margin-right: 8px;
display: flex;
align-items: center;
}

.thread-filter-select {
background: transparent;
color: #2e3338;
border: none;
padding: 4px 24px 4px 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
min-width: 120px;
font-family: inherit;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
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");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 16px;
}

.thread-filter-select:focus {
outline: none;
}

.thread-filter-select:hover {
color: #3776ab;
}

.thread-filter-select option {
padding: 8px;
background: white;
color: #2e3338;
font-weight: normal;
}

#chart {
width: 100%;
height: calc(100vh - 160px);
Expand Down
181 changes: 174 additions & 7 deletions Lib/profiling/sampling/flamegraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}};

// Global string table for resolving string indices
let stringTable = [];
let originalData = null;
let currentThreadFilter = 'all';

// Function to resolve string indices to actual strings
function resolveString(index) {
Expand Down Expand Up @@ -374,6 +376,12 @@ function initFlamegraph() {
processedData = resolveStringIndices(EMBEDDED_DATA);
}

// Store original data for filtering
originalData = processedData;

// Initialize thread filter dropdown
initThreadFilter(processedData);

const tooltip = createPythonTooltip(processedData);
const chart = createFlamegraph(tooltip, processedData.value);
renderFlamegraph(chart, processedData);
Expand All @@ -395,10 +403,39 @@ function populateStats(data) {
const functionMap = new Map();

function collectFunctions(node) {
const filename = resolveString(node.filename);
const funcname = resolveString(node.funcname);
// Debug to understand the node structure
if (!node) return;

// Try multiple ways to get the filename and function name
let filename = node.filename;
let funcname = node.funcname || node.name;

// If they're numbers (string indices), resolve them
if (typeof filename === 'number') {
filename = resolveString(filename);
}
if (typeof funcname === 'number') {
funcname = resolveString(funcname);
}

if (filename && funcname) {
// If they're still undefined or null, try extracting from the name field
if (!filename && node.name) {
const nameStr = typeof node.name === 'number' ? resolveString(node.name) : node.name;
if (nameStr && nameStr.includes('(')) {
// Parse format: "funcname (filename:lineno)"
const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/);
if (match) {
funcname = funcname || match[1];
filename = filename || match[2];
}
}
}

// Final fallback
filename = filename || 'unknown';
funcname = funcname || 'unknown';

if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
// Calculate direct samples (this node's value minus children's values)
let childrenValue = 0;
if (node.children) {
Expand Down Expand Up @@ -447,15 +484,18 @@ function populateStats(data) {
// Populate the 3 cards
for (let i = 0; i < 3; i++) {
const num = i + 1;
if (i < hotSpots.length) {
if (i < hotSpots.length && hotSpots[i]) {
const hotspot = hotSpots[i];
const basename = hotspot.filename.split('/').pop();
let funcDisplay = hotspot.funcname;
// Safe extraction with fallbacks
const filename = hotspot.filename || 'unknown';
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
const lineno = hotspot.lineno !== undefined && hotspot.lineno !== null ? hotspot.lineno : '?';
let funcDisplay = hotspot.funcname || 'unknown';
if (funcDisplay.length > 35) {
funcDisplay = funcDisplay.substring(0, 32) + '...';
}

document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${hotspot.lineno}`;
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${lineno}`;
document.getElementById(`hotspot-func-${num}`).textContent = funcDisplay;
document.getElementById(`hotspot-detail-${num}`).textContent = `${hotspot.directPercent.toFixed(1)}% samples (${hotspot.directSamples.toLocaleString()})`;
} else {
Expand Down Expand Up @@ -505,3 +545,130 @@ function clearSearch() {
}
}

function initThreadFilter(data) {
const threadFilter = document.getElementById('thread-filter');
const threadWrapper = document.querySelector('.thread-filter-wrapper');

if (!threadFilter || !data.threads) {
// Hide thread filter if no thread data
if (threadWrapper) {
threadWrapper.style.display = 'none';
}
return;
}

// Clear existing options except "All Threads"
threadFilter.innerHTML = '<option value="all">All Threads</option>';

// Add thread options
const threads = data.threads || [];
threads.forEach(threadId => {
const option = document.createElement('option');
option.value = threadId;
option.textContent = `Thread ${threadId}`;
threadFilter.appendChild(option);
});

// Hide filter if only one thread or no threads
if (threads.length <= 1 && threadWrapper) {
threadWrapper.style.display = 'none';
}
}

function filterByThread() {
const threadFilter = document.getElementById('thread-filter');
if (!threadFilter || !originalData) return;

const selectedThread = threadFilter.value;
currentThreadFilter = selectedThread;

let filteredData;
if (selectedThread === 'all') {
// Show all data
filteredData = originalData;
} else {
// Filter data by thread
const threadId = parseInt(selectedThread);
filteredData = filterDataByThread(originalData, threadId);

// Ensure string indices are resolved for the filtered data
if (filteredData.strings) {
stringTable = filteredData.strings;
filteredData = resolveStringIndices(filteredData);
}
}

// Re-render flamegraph with filtered data
const tooltip = createPythonTooltip(filteredData);
const chart = createFlamegraph(tooltip, filteredData.value);
renderFlamegraph(chart, filteredData);
}

function filterDataByThread(data, threadId) {
// Deep clone the data structure and filter by thread
function filterNode(node) {
// Check if this node contains the thread
if (!node.threads || !node.threads.includes(threadId)) {
return null;
}

// Create a filtered copy of the node, preserving all fields
const filteredNode = {
name: node.name,
value: node.value,
filename: node.filename,
funcname: node.funcname,
lineno: node.lineno,
threads: node.threads,
source: node.source,
children: []
};

// Copy any other properties that might exist
Object.keys(node).forEach(key => {
if (!(key in filteredNode)) {
filteredNode[key] = node[key];
}
});

// Recursively filter children
if (node.children && Array.isArray(node.children)) {
filteredNode.children = node.children
.map(child => filterNode(child))
.filter(child => child !== null);
}

return filteredNode;
}

// Create filtered root, preserving all metadata
const filteredRoot = {
...data,
children: [],
strings: data.strings // Preserve string table
};

// Filter children
if (data.children && Array.isArray(data.children)) {
filteredRoot.children = data.children
.map(child => filterNode(child))
.filter(child => child !== null);
}

// Recalculate total value based on filtered children
function recalculateValue(node) {
if (!node.children || node.children.length === 0) {
return node.value || 0;
}
const childrenValue = node.children.reduce((sum, child) => {
return sum + recalculateValue(child);
}, 0);
node.value = Math.max(node.value || 0, childrenValue);
return node.value;
}

recalculateValue(filteredRoot);

return filteredRoot;
}

6 changes: 6 additions & 0 deletions Lib/profiling/sampling/flamegraph_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ <h1>Tachyon Profiler Performance Flamegraph</h1>
<button onclick="resetZoom()">🏠 Reset Zoom</button>
<button onclick="exportSVG()" class="secondary">📁 Export SVG</button>
<button onclick="toggleLegend()">🔥 Heat Map Legend</button>
<div class="thread-filter-wrapper">
<label class="thread-filter-label">🧵 Thread:</label>
<select id="thread-filter" class="thread-filter-select" onchange="filterByThread()">
<option value="all">All Threads</option>
</select>
</div>
</div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion Lib/profiling/sampling/pstats_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _process_frames(self, frames):
self.callers[callee][caller] += 1

def collect(self, stack_frames):
for frames in self._iter_all_frames(stack_frames, skip_idle=self.skip_idle):
for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=self.skip_idle):
self._process_frames(frames)

def export(self, filename):
Expand Down
Loading
Loading