Skip to content
Open
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
3 changes: 3 additions & 0 deletions Include/cpython/pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ struct _ts {
/* Currently holds the GIL. Must be its own field to avoid data races */
int holds_gil;

/* Currently requesting the GIL */
int gil_requested;

int _whence;

/* Thread state (_Py_THREAD_ATTACHED, _Py_THREAD_DETACHED, _Py_THREAD_SUSPENDED).
Expand Down
4 changes: 4 additions & 0 deletions Include/internal/pycore_debug_offsets.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ typedef struct _Py_DebugOffsets {
uint64_t native_thread_id;
uint64_t datastack_chunk;
uint64_t status;
uint64_t holds_gil;
uint64_t gil_requested;
} thread_state;

// InterpreterFrame offset;
Expand Down Expand Up @@ -273,6 +275,8 @@ typedef struct _Py_DebugOffsets {
.native_thread_id = offsetof(PyThreadState, native_thread_id), \
.datastack_chunk = offsetof(PyThreadState, datastack_chunk), \
.status = offsetof(PyThreadState, _status), \
.holds_gil = offsetof(PyThreadState, holds_gil), \
.gil_requested = offsetof(PyThreadState, gil_requested), \
}, \
.interpreter_frame = { \
.size = sizeof(_PyInterpreterFrame), \
Expand Down
30 changes: 16 additions & 14 deletions Lib/profiling/sampling/collector.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
from abc import ABC, abstractmethod

# Enums are slow
THREAD_STATE_RUNNING = 0
THREAD_STATE_IDLE = 1
THREAD_STATE_GIL_WAIT = 2
THREAD_STATE_UNKNOWN = 3

STATUS = {
THREAD_STATE_RUNNING: "running",
THREAD_STATE_IDLE: "idle",
THREAD_STATE_GIL_WAIT: "gil_wait",
THREAD_STATE_UNKNOWN: "unknown",
}
# Thread status flags
try:
from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN
except ImportError:
# Fallback for tests or when module is not available
THREAD_STATUS_HAS_GIL = (1 << 0)
THREAD_STATUS_ON_CPU = (1 << 1)
THREAD_STATUS_UNKNOWN = (1 << 2)

class Collector(ABC):
@abstractmethod
Expand All @@ -26,8 +22,14 @@ 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:
if skip_idle and thread_info.status != THREAD_STATE_RUNNING:
continue
# skip_idle now means: skip if thread is not actively running
# A thread is "active" if it has the GIL OR is on CPU
if skip_idle:
status_flags = thread_info.status
has_gil = bool(status_flags & THREAD_STATUS_HAS_GIL)
on_cpu = bool(status_flags & THREAD_STATUS_ON_CPU)
if not (has_gil or on_cpu):
continue
frames = thread_info.frame_info
if frames:
yield frames, thread_info.thread_id
232 changes: 216 additions & 16 deletions Lib/profiling/sampling/gecko_collector.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
import itertools
import json
import os
import platform
import sys
import threading
import time

from .collector import Collector, THREAD_STATE_RUNNING
from .collector import Collector
try:
from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN, THREAD_STATUS_GIL_REQUESTED
except ImportError:
# Fallback if module not available (shouldn't happen in normal use)
THREAD_STATUS_HAS_GIL = (1 << 0)
THREAD_STATUS_ON_CPU = (1 << 1)
THREAD_STATUS_UNKNOWN = (1 << 2)
THREAD_STATUS_GIL_REQUESTED = (1 << 3)


# Categories matching Firefox Profiler expectations
GECKO_CATEGORIES = [
{"name": "Other", "color": "grey", "subcategories": ["Other"]},
{"name": "Python", "color": "yellow", "subcategories": ["Other"]},
{"name": "Native", "color": "blue", "subcategories": ["Other"]},
{"name": "Idle", "color": "transparent", "subcategories": ["Other"]},
{"name": "GC", "color": "orange", "subcategories": ["Other"]},
{"name": "GIL", "color": "green", "subcategories": ["Other"]},
{"name": "CPU", "color": "purple", "subcategories": ["Other"]},
{"name": "Code Type", "color": "red", "subcategories": ["Other"]},
]

# Category indices
CATEGORY_OTHER = 0
CATEGORY_PYTHON = 1
CATEGORY_NATIVE = 2
CATEGORY_IDLE = 3
CATEGORY_GC = 3
CATEGORY_GIL = 4
CATEGORY_CPU = 5
CATEGORY_CODE_TYPE = 6

# Subcategory indices
DEFAULT_SUBCATEGORY = 0
Expand Down Expand Up @@ -58,6 +75,43 @@ def __init__(self, *, skip_idle=False):
self.last_sample_time = 0
self.interval = 1.0 # Will be calculated from actual sampling

# State tracking for interval markers (tid -> start_time)
self.has_gil_start = {} # Thread has the GIL
self.no_gil_start = {} # Thread doesn't have the GIL
self.on_cpu_start = {} # Thread is running on CPU
self.off_cpu_start = {} # Thread is off CPU
self.python_code_start = {} # Thread running Python code (has GIL)
self.native_code_start = {} # Thread running native code (on CPU without GIL)
self.gil_wait_start = {} # Thread waiting for GIL

# GC event tracking: track if we're currently in a GC
self.potential_gc_start = None

def _track_state_transition(self, tid, condition, active_dict, inactive_dict,
active_name, inactive_name, category, current_time):
"""Track binary state transitions and emit markers.

Args:
tid: Thread ID
condition: Whether the active state is true
active_dict: Dict tracking start time of active state
inactive_dict: Dict tracking start time of inactive state
active_name: Name for active state marker
inactive_name: Name for inactive state marker
category: Gecko category for the markers
current_time: Current timestamp
"""
if condition:
active_dict.setdefault(tid, current_time)
if tid in inactive_dict:
self._add_marker(tid, inactive_name, inactive_dict.pop(tid),
current_time, category)
else:
inactive_dict.setdefault(tid, current_time)
if tid in active_dict:
self._add_marker(tid, active_name, active_dict.pop(tid),
current_time, category)

def collect(self, stack_frames):
"""Collect a sample from stack frames."""
current_time = (time.time() * 1000) - self.start_time
Expand All @@ -69,18 +123,16 @@ def collect(self, stack_frames):
) / self.sample_count
self.last_sample_time = current_time

# GC Event Detection and process threads
gc_collecting = False

for interpreter_info in stack_frames:
for thread_info in interpreter_info.threads:
if (
self.skip_idle
and thread_info.status != THREAD_STATE_RUNNING
):
continue
# Track GC status
if thread_info.gc_collecting:
gc_collecting = True

frames = thread_info.frame_info
if not frames:
continue

tid = thread_info.thread_id

# Initialize thread if needed
Expand All @@ -89,6 +141,61 @@ def collect(self, stack_frames):

thread_data = self.threads[tid]

# Decode status flags
status_flags = thread_info.status
has_gil = bool(status_flags & THREAD_STATUS_HAS_GIL)
on_cpu = bool(status_flags & THREAD_STATUS_ON_CPU)
gil_requested = bool(status_flags & THREAD_STATUS_GIL_REQUESTED)

# Track GIL possession (Has GIL / No GIL)
self._track_state_transition(
tid, has_gil, self.has_gil_start, self.no_gil_start,
"Has GIL", "No GIL", CATEGORY_GIL, current_time
)

# Track CPU state (On CPU / Off CPU)
self._track_state_transition(
tid, on_cpu, self.on_cpu_start, self.off_cpu_start,
"On CPU", "Off CPU", CATEGORY_CPU, current_time
)

# Track code type (Python Code / Native Code)
if has_gil:
self._track_state_transition(
tid, True, self.python_code_start, self.native_code_start,
"Python Code", "Native Code", CATEGORY_CODE_TYPE, current_time
)
elif on_cpu:
self._track_state_transition(
tid, True, self.native_code_start, self.python_code_start,
"Native Code", "Python Code", CATEGORY_CODE_TYPE, current_time
)
else:
# Neither has GIL nor on CPU - end both if running
if tid in self.python_code_start:
self._add_marker(tid, "Python Code", self.python_code_start.pop(tid),
current_time, CATEGORY_CODE_TYPE)
if tid in self.native_code_start:
self._add_marker(tid, "Native Code", self.native_code_start.pop(tid),
current_time, CATEGORY_CODE_TYPE)

# Track "Waiting for GIL" intervals (one-sided tracking)
if gil_requested:
self.gil_wait_start.setdefault(tid, current_time)
elif tid in self.gil_wait_start:
self._add_marker(tid, "Waiting for GIL", self.gil_wait_start.pop(tid),
current_time, CATEGORY_GIL)

# Categorize: idle if neither has GIL nor on CPU
is_idle = not has_gil and not on_cpu

# Skip idle threads if skip_idle is enabled
if self.skip_idle and is_idle:
continue

if not frames:
continue

# Process the stack
stack_index = self._process_stack(thread_data, frames)

Expand All @@ -98,11 +205,21 @@ def collect(self, stack_frames):
samples["time"].append(current_time)
samples["eventDelay"].append(None)

# Handle GC event markers after processing all threads
if gc_collecting:
if self.potential_gc_start is None:
# Start of GC
self.potential_gc_start = current_time
else:
# End of GC
if self.potential_gc_start is not None:
self._add_gc_marker(self.potential_gc_start, current_time)
self.potential_gc_start = None

self.sample_count += 1

def _create_thread(self, tid):
"""Create a new thread structure with processed profile format."""
import threading

# Determine if this is the main thread
try:
Expand Down Expand Up @@ -181,7 +298,7 @@ def _create_thread(self, tid):
"functionSize": [],
"length": 0,
},
# Markers - processed format
# Markers - processed format (arrays)
"markers": {
"data": [],
"name": [],
Expand Down Expand Up @@ -215,6 +332,36 @@ def _intern_string(self, s):
self.global_string_map[s] = idx
return idx

def _add_marker(self, tid, name, start_time, end_time, category):
"""Add an interval marker for a specific thread."""
if tid not in self.threads:
return

thread_data = self.threads[tid]
duration = end_time - start_time

name_idx = self._intern_string(name)
markers = thread_data["markers"]
markers["name"].append(name_idx)
markers["startTime"].append(start_time)
markers["endTime"].append(end_time)
markers["phase"].append(1) # 1 = interval marker
markers["category"].append(category)
markers["data"].append({
"type": name.replace(" ", ""),
"duration": duration,
"tid": tid
})

def _add_gc_marker(self, start_time, end_time):
"""Add a GC Collecting event marker to the main thread (or first thread we see)."""
if not self.threads:
return

# Add GC marker to the first thread (typically the main thread)
first_tid = next(iter(self.threads))
self._add_marker(first_tid, "GC Collecting", start_time, end_time, CATEGORY_GC)

def _process_stack(self, thread_data, frames):
"""Process a stack and return the stack index."""
if not frames:
Expand Down Expand Up @@ -383,15 +530,67 @@ def _get_or_create_frame(self, thread_data, func_idx, lineno):
frame_cache[frame_key] = frame_idx
return frame_idx

def _finalize_markers(self):
"""Close any open markers at the end of profiling."""
end_time = self.last_sample_time

# Close all open markers for each thread using a generic approach
marker_states = [
(self.has_gil_start, "Has GIL", CATEGORY_GIL),
(self.no_gil_start, "No GIL", CATEGORY_GIL),
(self.on_cpu_start, "On CPU", CATEGORY_CPU),
(self.off_cpu_start, "Off CPU", CATEGORY_CPU),
(self.python_code_start, "Python Code", CATEGORY_CODE_TYPE),
(self.native_code_start, "Native Code", CATEGORY_CODE_TYPE),
(self.gil_wait_start, "Waiting for GIL", CATEGORY_GIL),
]

for state_dict, marker_name, category in marker_states:
for tid in list(state_dict.keys()):
self._add_marker(tid, marker_name, state_dict[tid], end_time, category)
del state_dict[tid]

# Close any open GC marker
if self.potential_gc_start is not None:
self._add_gc_marker(self.potential_gc_start, end_time)
self.potential_gc_start = None

def export(self, filename):
"""Export the profile to a Gecko JSON file."""

if self.sample_count > 0 and self.last_sample_time > 0:
self.interval = self.last_sample_time / self.sample_count

profile = self._build_profile()
# Spinner for progress indication
spinner = itertools.cycle(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'])
stop_spinner = threading.Event()

def spin():
message = 'Building Gecko profile...'
while not stop_spinner.is_set():
sys.stderr.write(f'\r{next(spinner)} {message}')
sys.stderr.flush()
time.sleep(0.1)
# Clear the spinner line
sys.stderr.write('\r' + ' ' * (len(message) + 3) + '\r')
sys.stderr.flush()

spinner_thread = threading.Thread(target=spin, daemon=True)
spinner_thread.start()

try:
# Finalize any open markers before building profile
self._finalize_markers()

profile = self._build_profile()

with open(filename, "w") as f:
json.dump(profile, f, separators=(",", ":"))
with open(filename, "w") as f:
json.dump(profile, f, separators=(",", ":"))
finally:
stop_spinner.set()
spinner_thread.join(timeout=1.0)
# Small delay to ensure the clear happens
time.sleep(0.01)

print(f"Gecko profile written to {filename}")
print(
Expand All @@ -416,6 +615,7 @@ def _build_profile(self):
frame_table["length"] = len(frame_table["func"])
func_table["length"] = len(func_table["name"])
resource_table["length"] = len(resource_table["name"])
thread_data["markers"]["length"] = len(thread_data["markers"]["name"])

# Clean up internal caches
del thread_data["_stackCache"]
Expand Down
Loading
Loading