Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
143 changes: 136 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,26 @@ function populateStats(data) {
const functionMap = new Map();

function collectFunctions(node) {
const filename = resolveString(node.filename);
const funcname = resolveString(node.funcname);
if (!node) return;

let filename = typeof node.filename === 'number' ? resolveString(node.filename) : node.filename;
let funcname = typeof node.funcname === 'number' ? resolveString(node.funcname) : node.funcname;

if (!filename || !funcname) {
const nameStr = typeof node.name === 'number' ? resolveString(node.name) : node.name;
if (nameStr?.includes('(')) {
const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/);
if (match) {
funcname = funcname || match[1];
filename = filename || match[2];
}
}
}

if (filename && funcname) {
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 +471,17 @@ 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;
const filename = hotspot.filename || 'unknown';
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
const lineno = 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 +531,106 @@ 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);

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) {
function filterNode(node) {
if (!node.threads || !node.threads.includes(threadId)) {
return null;
}

const filteredNode = {
...node,
children: []
};

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

return filteredNode;
}

const filteredRoot = {
...data,
children: []
};

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

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;
}

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
28 changes: 18 additions & 10 deletions Lib/profiling/sampling/stack_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ def __init__(self, *, skip_idle=False):
self.skip_idle = skip_idle

def collect(self, stack_frames, skip_idle=False):
for frames in self._iter_all_frames(stack_frames, skip_idle=skip_idle):
for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=skip_idle):
if not frames:
continue
self.process_frames(frames)
self.process_frames(frames, thread_id)

def process_frames(self, frames):
def process_frames(self, frames, thread_id):
pass


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

def process_frames(self, frames):
def process_frames(self, frames, thread_id):
call_tree = tuple(reversed(frames))
self.stack_counter[call_tree] += 1
self.stack_counter[(call_tree, thread_id)] += 1

def export(self, filename):
lines = []
for call_tree, count in self.stack_counter.items():
for (call_tree, thread_id), count in self.stack_counter.items():
stack_str = ";".join(
f"{os.path.basename(f[0])}:{f[2]}:{f[1]}" for f in call_tree
)
lines.append((stack_str, count))
lines.append((f"tid:{thread_id};{stack_str}", count))

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

Expand All @@ -53,10 +53,11 @@ class FlamegraphCollector(StackTraceCollector):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.stats = {}
self._root = {"samples": 0, "children": {}}
self._root = {"samples": 0, "children": {}, "threads": set()}
self._total_samples = 0
self._func_intern = {}
self._string_table = StringTable()
self._all_threads = set()

def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None):
"""Set profiling statistics to include in flamegraph data."""
Expand Down Expand Up @@ -111,6 +112,7 @@ def _convert_to_flamegraph_format(self):
"name": self._string_table.intern("No Data"),
"value": 0,
"children": [],
"threads": [],
"strings": self._string_table.get_strings()
}

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

source = self._get_source_lines(func)
Expand Down Expand Up @@ -172,6 +175,7 @@ def convert_children(children, min_samples):
new_name = f"Program Root: {old_name}"
main_child["name"] = self._string_table.intern(new_name)
main_child["stats"] = self.stats
main_child["threads"] = sorted(list(self._all_threads))
main_child["strings"] = self._string_table.get_strings()
return main_child

Expand All @@ -180,24 +184,28 @@ def convert_children(children, min_samples):
"value": total_samples,
"children": root_children,
"stats": self.stats,
"threads": sorted(list(self._all_threads)),
"strings": self._string_table.get_strings()
}

def process_frames(self, frames):
def process_frames(self, frames, thread_id):
# Reverse to root->leaf
call_tree = reversed(frames)
self._root["samples"] += 1
self._total_samples += 1
self._root["threads"].add(thread_id)
self._all_threads.add(thread_id)

current = self._root
for func in call_tree:
func = self._func_intern.setdefault(func, func)
children = current["children"]
node = children.get(func)
if node is None:
node = {"samples": 0, "children": {}}
node = {"samples": 0, "children": {}, "threads": set()}
children[func] = node
node["samples"] += 1
node["threads"].add(thread_id)
current = node

def _get_source_lines(self, func):
Expand Down
Loading
Loading