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
Prev Previous commit
Next Next commit
fixup! gh-138122: Track and display thread status statistics in flame…
…graph profiler
  • Loading branch information
pablogsal committed Nov 28, 2025
commit b92859617646387e8c0cc8818c42d880f05dea20
51 changes: 43 additions & 8 deletions Lib/profiling/sampling/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ def _is_gc_frame(self, frame):
return "<GC>" in funcname or "gc_collect" in funcname

def _collect_thread_status_stats(self, stack_frames):
"""Collect aggregate and per-thread status statistics from a sample.

Returns:
tuple: (aggregate_status_counts, has_gc_frame, per_thread_stats)
- aggregate_status_counts: dict with has_gil, on_cpu, etc.
- has_gc_frame: bool indicating if any thread has GC frames
- per_thread_stats: dict mapping thread_id to per-thread counts
"""
status_counts = {
"has_gil": 0,
"on_cpu": 0,
Expand All @@ -50,6 +58,7 @@ def _collect_thread_status_stats(self, stack_frames):
"total": 0,
}
has_gc_frame = False
per_thread_stats = {}

for interpreter_info in stack_frames:
threads = getattr(interpreter_info, "threads", [])
Expand All @@ -68,12 +77,38 @@ def _collect_thread_status_stats(self, stack_frames):
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
# Track per-thread statistics
thread_id = getattr(thread_info, "thread_id", None)
if thread_id is not None:
if thread_id not in per_thread_stats:
per_thread_stats[thread_id] = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"total": 0,
"gc_samples": 0,
}

thread_stats = per_thread_stats[thread_id]
thread_stats["total"] += 1

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

# Check for GC frames in this thread
frames = getattr(thread_info, "frame_info", None)
if frames:
for frame in frames:
if self._is_gc_frame(frame):
thread_stats["gc_samples"] += 1
has_gc_frame = True
break

return status_counts, has_gc_frame
return status_counts, has_gc_frame, per_thread_stats
90 changes: 21 additions & 69 deletions Lib/profiling/sampling/stack_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@

from .collector import Collector
from .string_table import StringTable
from .constants import (
THREAD_STATUS_HAS_GIL,
THREAD_STATUS_ON_CPU,
THREAD_STATUS_GIL_REQUESTED,
THREAD_STATUS_UNKNOWN,
)


class StackTraceCollector(Collector):
Expand All @@ -30,13 +24,6 @@ def collect(self, stack_frames, skip_idle=False):
def process_frames(self, frames, thread_id):
pass

def collect_stats_sample(self, stack_frames):
"""
Collect thread status statistics from a sample.
Subclasses can override to track GIL/CPU/GC stats.
"""
pass


class CollapsedStackCollector(StackTraceCollector):
def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -98,66 +85,31 @@ def collect(self, stack_frames, skip_idle=False):
# Increment sample count once per sample
self._sample_count += 1

# Collect both aggregate and per-thread statistics in a single pass
has_gc_frame_in_sample = False

for interpreter_info in stack_frames:
threads = getattr(interpreter_info, "threads", [])
for thread_info in threads:
# Update aggregate counts
self.thread_status_counts["total"] += 1

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

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

# Track per-thread statistics
thread_id = getattr(thread_info, "thread_id", None)
if thread_id is not None:
# Initialize per-thread stats if needed
if thread_id not in self.per_thread_stats:
self.per_thread_stats[thread_id] = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"total": 0,
"gc_samples": 0,
}

thread_stats = self.per_thread_stats[thread_id]
thread_stats["total"] += 1

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

# Check for GC frames in this thread
frames = getattr(thread_info, "frame_info", None)
if frames:
for frame in frames:
if self._is_gc_frame(frame):
thread_stats["gc_samples"] += 1
has_gc_frame_in_sample = True
break
# Collect both aggregate and per-thread statistics using base method
status_counts, has_gc_frame, per_thread_stats = self._collect_thread_status_stats(stack_frames)

# Merge aggregate status counts
for key in status_counts:
self.thread_status_counts[key] += status_counts[key]

# Update aggregate GC frame count
if has_gc_frame_in_sample:
if has_gc_frame:
self.samples_with_gc_frames += 1

# Merge per-thread statistics
for thread_id, stats in per_thread_stats.items():
if thread_id not in self.per_thread_stats:
self.per_thread_stats[thread_id] = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"total": 0,
"gc_samples": 0,
}
for key, value in stats.items():
self.per_thread_stats[thread_id][key] += value

# Call parent collect to process frames
super().collect(stack_frames, skip_idle=skip_idle)

Expand Down
Loading