Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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: none;
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
139 changes: 132 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,102 @@ function clearSearch() {
}
}

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

if (!threadFilter || !data.threads) {
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);
});

// Show filter if more than one thread
if (threads.length > 1 && threadWrapper) {
threadWrapper.style.display = 'inline-flex';
}
}

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
2 changes: 1 addition & 1 deletion Lib/profiling/sampling/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ def main():
"--mode",
choices=["wall", "cpu", "gil"],
default="wall",
help="Sampling mode: wall (all threads), cpu (only CPU-running threads), gil (only GIL-holding threads)",
help="Sampling mode: wall (all threads), cpu (only CPU-running threads), gil (only GIL-holding threads) (default: wall)",
)

# Output format selection
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