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: Track and display thread status statistics in flamegraph p…
…rofiler

The flamegraph profiler now collects thread state information (GIL held,
GIL released, waiting for GIL, and garbage collection activity) during
sampling and displays it in an interactive statistics bar. This helps users
identify GIL contention and understand thread behavior patterns.

Statistics are calculated both in aggregate and per-thread. When filtering
the flamegraph to a specific thread, the display updates to show that
thread's metrics. In GIL-only profiling mode, GIL-related statistics are
hidden since they aren't meaningful in that context.

The collection logic was refactored to process each sample in a single pass
rather than iterating through threads multiple times. GC frame detection now
uses a shared helper that consistently handles both tuple and object frame
formats.

Per-thread GC percentages now use total samples as the denominator instead
of per-thread sample counts. This makes them directly comparable with
aggregate statistics and easier to interpret when threads appear in different
numbers of samples.

The JavaScript includes null checks for DOM access and validates data
availability before rendering. Tests verify the JSON structure, percentage
calculations, and edge cases like zero samples, using only public APIs.
  • Loading branch information
pablogsal committed Nov 24, 2025
commit 9694d5a757a971596701bc955d8c8d1b26ffc2af
46 changes: 45 additions & 1 deletion Lib/profiling/sampling/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ def export(self, filename):
"""Export collected data to a file."""

def _iter_all_frames(self, stack_frames, skip_idle=False):
"""Iterate over all frame stacks from all interpreters and threads."""
for interpreter_info in stack_frames:
for thread_info in interpreter_info.threads:
# skip_idle now means: skip if thread is not actively running
Expand All @@ -33,3 +32,48 @@ def _iter_all_frames(self, stack_frames, skip_idle=False):
frames = thread_info.frame_info
if frames:
yield frames, thread_info.thread_id

def _is_gc_frame(self, frame):
if isinstance(frame, tuple):
funcname = frame[2] if len(frame) >= 3 else ""
else:
funcname = getattr(frame, "funcname", "")

return "<GC>" in funcname or "gc_collect" in funcname

def _collect_thread_status_stats(self, stack_frames):
status_counts = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"total": 0,
}
has_gc_frame = False

for interpreter_info in stack_frames:
threads = getattr(interpreter_info, "threads", [])
for thread_info in threads:
status_counts["total"] += 1

# Track thread status using bit flags
status_flags = getattr(thread_info, "status", 0)

if status_flags & THREAD_STATUS_HAS_GIL:
status_counts["has_gil"] += 1
if status_flags & THREAD_STATUS_ON_CPU:
status_counts["on_cpu"] += 1
if status_flags & THREAD_STATUS_GIL_REQUESTED:
status_counts["gil_requested"] += 1
if status_flags & THREAD_STATUS_UNKNOWN:
status_counts["unknown"] += 1

# Check for GC frames
frames = getattr(thread_info, "frame_info", None)
if frames and not has_gc_frame:
for frame in frames:
if self._is_gc_frame(frame):
has_gc_frame = True
break

return status_counts, has_gc_frame
121 changes: 121 additions & 0 deletions Lib/profiling/sampling/flamegraph.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,127 @@ body {
gap: 20px;
}

/* Compact Thread Stats Bar - Colorful Square Design */
.thread-stats-bar {
background: rgba(255, 255, 255, 0.95);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
font-size: 13px;
box-shadow: 0 2px 8px rgba(55, 118, 171, 0.2);
}

.thread-stat-item {
display: inline-flex;
align-items: center;
gap: 8px;
background: white;
padding: 6px 14px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 2px solid;
min-width: 115px;
justify-content: center;
animation: fadeIn 0.5s ease-out backwards;
}

.thread-stat-item:nth-child(1) { animation-delay: 0s; }
.thread-stat-item:nth-child(3) { animation-delay: 0.1s; }
.thread-stat-item:nth-child(5) { animation-delay: 0.2s; }
.thread-stat-item:nth-child(7) { animation-delay: 0.3s; }

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

/* Color-coded borders and subtle glow on hover */
#gil-held-stat {
--stat-color: 40, 167, 69;
border-color: rgb(var(--stat-color));
background: linear-gradient(135deg, rgba(var(--stat-color), 0.06) 0%, #ffffff 100%);
}

#gil-released-stat {
--stat-color: 220, 53, 69;
border-color: rgb(var(--stat-color));
background: linear-gradient(135deg, rgba(var(--stat-color), 0.06) 0%, #ffffff 100%);
}

#gil-waiting-stat {
--stat-color: 255, 193, 7;
border-color: rgb(var(--stat-color));
background: linear-gradient(135deg, rgba(var(--stat-color), 0.06) 0%, #ffffff 100%);
}

#gc-stat {
--stat-color: 111, 66, 193;
border-color: rgb(var(--stat-color));
background: linear-gradient(135deg, rgba(var(--stat-color), 0.06) 0%, #ffffff 100%);
}

#gil-held-stat:hover,
#gil-released-stat:hover,
#gil-waiting-stat:hover,
#gc-stat:hover {
box-shadow: 0 0 12px rgba(var(--stat-color), 0.4), 0 1px 3px rgba(0, 0, 0, 0.08);
}

.thread-stat-item .stat-label {
color: #5a6c7d;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.3px;
}

.thread-stat-item .stat-value {
color: #2e3338;
font-weight: 800;
font-size: 14px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

.thread-stat-separator {
color: rgba(0, 0, 0, 0.15);
font-weight: 300;
font-size: 16px;
position: relative;
z-index: 1;
}

/* Responsive - stack on small screens */
@media (max-width: 768px) {
.thread-stats-bar {
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
padding: 10px 16px;
}

.thread-stat-item {
padding: 4px 10px;
}

.thread-stat-item .stat-label {
font-size: 11px;
}

.thread-stat-item .stat-value {
font-size: 12px;
}

.thread-stat-separator {
display: none;
}
}

.stat-card {
background: #ffffff;
border: 1px solid #e9ecef;
Expand Down
93 changes: 91 additions & 2 deletions Lib/profiling/sampling/flamegraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,93 @@ if (document.readyState === "loading") {
initFlamegraph();
}

// Mode constants (must match constants.py)
const PROFILING_MODE_WALL = 0;
const PROFILING_MODE_CPU = 1;
const PROFILING_MODE_GIL = 2;
const PROFILING_MODE_ALL = 3;

function populateThreadStats(data, selectedThreadId = null) {
// Check if thread statistics are available
const stats = data?.stats;
if (!stats || !stats.thread_stats) {
return; // No thread stats available
}

const mode = stats.mode !== undefined ? stats.mode : PROFILING_MODE_WALL;
let threadStats;

// If a specific thread is selected, use per-thread stats
if (selectedThreadId !== null && stats.per_thread_stats && stats.per_thread_stats[selectedThreadId]) {
threadStats = stats.per_thread_stats[selectedThreadId];
} else {
threadStats = stats.thread_stats;
}

// Validate threadStats object
if (!threadStats || typeof threadStats.total !== 'number') {
return; // Invalid thread stats
}

const bar = document.getElementById('thread-stats-bar');
if (!bar) {
return; // DOM element not found
}

// Show the bar if we have valid thread stats
if (threadStats.total > 0) {
bar.style.display = 'flex';

// Hide/show GIL stats items in GIL mode
const gilHeldStat = document.getElementById('gil-held-stat');
const gilReleasedStat = document.getElementById('gil-released-stat');
const gilWaitingStat = document.getElementById('gil-waiting-stat');
const separators = bar.querySelectorAll('.thread-stat-separator');

if (mode === PROFILING_MODE_GIL) {
// In GIL mode, hide GIL-related stats
if (gilHeldStat) gilHeldStat.style.display = 'none';
if (gilReleasedStat) gilReleasedStat.style.display = 'none';
if (gilWaitingStat) gilWaitingStat.style.display = 'none';
separators.forEach((sep, i) => {
if (i < 3) sep.style.display = 'none';
});
} else {
// Show all stats in other modes
if (gilHeldStat) gilHeldStat.style.display = 'inline-flex';
if (gilReleasedStat) gilReleasedStat.style.display = 'inline-flex';
if (gilWaitingStat) gilWaitingStat.style.display = 'inline-flex';
separators.forEach(sep => sep.style.display = 'inline');

// GIL Held
const gilHeldPct = threadStats.has_gil_pct || 0;
const gilHeldPctElem = document.getElementById('gil-held-pct');
if (gilHeldPctElem) gilHeldPctElem.textContent = `${gilHeldPct.toFixed(1)}%`;

// GIL Released (threads running without GIL)
const gilReleasedPct = threadStats.on_cpu_pct || 0;
const gilReleasedPctElem = document.getElementById('gil-released-pct');
if (gilReleasedPctElem) gilReleasedPctElem.textContent = `${gilReleasedPct.toFixed(1)}%`;

// Waiting for GIL
const gilWaitingPct = threadStats.gil_requested_pct || 0;
const gilWaitingPctElem = document.getElementById('gil-waiting-pct');
if (gilWaitingPctElem) gilWaitingPctElem.textContent = `${gilWaitingPct.toFixed(1)}%`;
}

// Garbage Collection (always show)
const gcPct = threadStats.gc_pct || 0;
const gcPctElem = document.getElementById('gc-pct');
if (gcPctElem) gcPctElem.textContent = `${gcPct.toFixed(1)}%`;
}
}

function populateStats(data) {
const totalSamples = data.value || 0;

// Populate thread statistics if available
populateThreadStats(data);

// Collect all functions with their metrics, aggregated by function name
const functionMap = new Map();

Expand Down Expand Up @@ -579,13 +663,15 @@ function filterByThread() {
currentThreadFilter = selectedThread;

let filteredData;
let selectedThreadId = null;

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

if (filteredData.strings) {
stringTable = filteredData.strings;
Expand All @@ -597,6 +683,9 @@ function filterByThread() {
const tooltip = createPythonTooltip(filteredData);
const chart = createFlamegraph(tooltip, filteredData.value);
renderFlamegraph(chart, filteredData);

// Update thread stats to show per-thread or aggregate stats
populateThreadStats(originalData, selectedThreadId);
}

function filterDataByThread(data, threadId) {
Expand Down
24 changes: 24 additions & 0 deletions Lib/profiling/sampling/flamegraph_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,31 @@ <h1>Tachyon Profiler Performance Flamegraph</h1>
</div>
</div>

<!-- Compact Thread Stats Bar -->
<div class="thread-stats-bar" id="thread-stats-bar" style="display: none;">
<span class="thread-stat-item" id="gil-held-stat">
<span class="stat-label">🟢 GIL Held:</span>
<span class="stat-value" id="gil-held-pct">--</span>
</span>
<span class="thread-stat-separator">│</span>
<span class="thread-stat-item" id="gil-released-stat">
<span class="stat-label">🔴 GIL Released:</span>
<span class="stat-value" id="gil-released-pct">--</span>
</span>
<span class="thread-stat-separator">│</span>
<span class="thread-stat-item" id="gil-waiting-stat">
<span class="stat-label">🟡 Waiting:</span>
<span class="stat-value" id="gil-waiting-pct">--</span>
</span>
<span class="thread-stat-separator">│</span>
<span class="thread-stat-item" id="gc-stat">
<span class="stat-label">🗑️ GC:</span>
<span class="stat-value" id="gc-pct">--</span>
</span>
</div>

<div class="stats-section">
<!-- Hot Spots -->
<div class="stats-container">
<div class="stat-card hotspot-card">
<div class="stat-icon">🥇</div>
Expand Down
3 changes: 2 additions & 1 deletion Lib/profiling/sampling/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD
self.pid = pid
self.sample_interval_usec = sample_interval_usec
self.all_threads = all_threads
self.mode = mode # Store mode for later use
if _FREE_THREADED_BUILD:
self.unwinder = _remote_debugging.RemoteUnwinder(
self.pid, all_threads=self.all_threads, mode=mode, native=native, gc=gc,
Expand Down Expand Up @@ -117,7 +118,7 @@ def sample(self, collector, duration_sec=10):

# Pass stats to flamegraph collector if it's the right type
if hasattr(collector, 'set_stats'):
collector.set_stats(self.sample_interval_usec, running_time, sample_rate, error_rate)
collector.set_stats(self.sample_interval_usec, running_time, sample_rate, error_rate, mode=self.mode)

expected_samples = int(duration_sec / sample_interval_sec)
if num_samples < expected_samples and not is_live_mode:
Expand Down
Loading
Loading