Skip to content

Commit 5da2777

Browse files
committed
Exceptions
1 parent 9188483 commit 5da2777

File tree

19 files changed

+765
-15
lines changed

19 files changed

+765
-15
lines changed

Doc/library/profiling.sampling.rst

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,10 @@ which you can use to judge whether the data is sufficient for your analysis.
426426
Profiling modes
427427
===============
428428

429-
The sampling profiler supports three modes that control which samples are
429+
The sampling profiler supports five modes that control which samples are
430430
recorded. The mode determines what the profile measures: total elapsed time,
431-
CPU execution time, or time spent holding the global interpreter lock.
431+
CPU execution time, time spent holding the global interpreter lock, exception
432+
handling, or critical section activity.
432433

433434

434435
Wall-clock mode
@@ -509,6 +510,85 @@ single-threaded programs to distinguish Python execution time from time spent
509510
in C extensions or I/O.
510511

511512

513+
Exception mode
514+
--------------
515+
516+
Exception mode (``--mode=exception``) records samples only when a thread has
517+
an active exception::
518+
519+
python -m profiling.sampling run --mode=exception script.py
520+
521+
Samples are recorded when a thread is either propagating an exception (between
522+
``raise`` and being caught) or executing inside an ``except`` block where
523+
exception information is present.
524+
525+
The following example illustrates which code regions are captured:
526+
527+
.. code-block:: python
528+
529+
def example():
530+
try:
531+
raise ValueError("error") # Captured: exception being raised
532+
except ValueError:
533+
process_error() # Captured: inside except block
534+
finally:
535+
cleanup() # NOT captured: exception already handled
536+
537+
def example_propagating():
538+
try:
539+
try:
540+
raise ValueError("error")
541+
finally:
542+
cleanup() # Captured: exception propagating through
543+
except ValueError:
544+
pass
545+
546+
def example_no_exception():
547+
try:
548+
do_work()
549+
finally:
550+
cleanup() # NOT captured: no exception involved
551+
552+
Note that ``finally`` blocks are only captured when an exception is actively
553+
propagating through them. A ``finally`` block that runs after an ``except``
554+
block has handled the exception, or during normal execution without any
555+
exception, is not captured.
556+
557+
This mode is useful for understanding where your program spends time handling
558+
errors, which can be significant in code paths that use exceptions for flow
559+
control or in applications that process many error conditions.
560+
561+
Exception mode helps answer questions like "how much time is spent handling
562+
exceptions?" and "which exception handlers are the most expensive?" It can
563+
reveal hidden performance costs in code that catches and processes many
564+
exceptions, even when those exceptions are handled gracefully.
565+
566+
567+
Critical section mode
568+
---------------------
569+
570+
Critical section mode (``--mode=critical``) records samples only when a thread
571+
is executing inside a critical section::
572+
573+
python -m profiling.sampling run --mode=critical script.py
574+
575+
Critical sections are regions of code protected by internal interpreter locks.
576+
This mode is primarily useful on free-threaded Python builds (built with
577+
``--disable-gil``) where critical sections replace the GIL for fine-grained
578+
locking.
579+
580+
On standard Python builds with the GIL, critical section mode captures samples
581+
when threads hold internal locks used by the interpreter for thread-safe
582+
operations. While less commonly needed than other modes, it can help diagnose
583+
lock contention issues in multi-threaded code.
584+
585+
Critical section mode helps answer questions like "which code paths trigger
586+
critical section acquisition?" and "where is lock contention occurring?" This
587+
is particularly valuable when debugging performance issues in free-threaded
588+
Python programs where multiple threads may contend for the same critical
589+
sections.
590+
591+
512592
Output formats
513593
==============
514594

@@ -945,8 +1025,9 @@ Mode options
9451025

9461026
.. option:: --mode <mode>
9471027

948-
Sampling mode: ``wall`` (default), ``cpu``, or ``gil``.
949-
The ``cpu`` and ``gil`` modes are incompatible with ``--async-aware``.
1028+
Sampling mode: ``wall`` (default), ``cpu``, ``gil``, ``exception``, or
1029+
``critical``. The ``cpu``, ``gil``, ``exception``, and ``critical`` modes
1030+
are incompatible with ``--async-aware``.
9501031

9511032
.. option:: --async-mode <mode>
9521033

Doc/sphinx-warnings.txt

Whitespace-only changes.

Include/internal/pycore_debug_offsets.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,18 @@ typedef struct _Py_DebugOffsets {
110110
uint64_t status;
111111
uint64_t holds_gil;
112112
uint64_t gil_requested;
113+
uint64_t current_exception;
114+
uint64_t exc_info;
115+
uint64_t critical_section;
113116
} thread_state;
114117

118+
// Exception stack item offset;
119+
struct {
120+
uint64_t size;
121+
uint64_t exc_value;
122+
uint64_t previous_item;
123+
} err_stackitem;
124+
115125
// InterpreterFrame offset;
116126
struct _interpreter_frame {
117127
uint64_t size;
@@ -282,6 +292,14 @@ typedef struct _Py_DebugOffsets {
282292
.status = offsetof(PyThreadState, _status), \
283293
.holds_gil = offsetof(PyThreadState, holds_gil), \
284294
.gil_requested = offsetof(PyThreadState, gil_requested), \
295+
.current_exception = offsetof(PyThreadState, current_exception), \
296+
.exc_info = offsetof(PyThreadState, exc_info), \
297+
.critical_section = offsetof(PyThreadState, critical_section), \
298+
}, \
299+
.err_stackitem = { \
300+
.size = sizeof(_PyErr_StackItem), \
301+
.exc_value = offsetof(_PyErr_StackItem, exc_value), \
302+
.previous_item = offsetof(_PyErr_StackItem, previous_item), \
285303
}, \
286304
.interpreter_frame = { \
287305
.size = sizeof(_PyInterpreterFrame), \

Lib/profiling/sampling/_flamegraph_assets/flamegraph.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ body.resizing-sidebar {
505505
.stat-tile--red { --tile-color: 220, 53, 69; --tile-text: #dc3545; }
506506
.stat-tile--yellow { --tile-color: 255, 193, 7; --tile-text: #d39e00; }
507507
.stat-tile--purple { --tile-color: 111, 66, 193; --tile-text: #6f42c1; }
508+
.stat-tile--orange { --tile-color: 253, 126, 20; --tile-text: #fd7e14; }
509+
.stat-tile--cyan { --tile-color: 23, 162, 184; --tile-text: #17a2b8; }
508510

509511
.stat-tile[class*="--"] {
510512
border-color: rgba(var(--tile-color), 0.4);

Lib/profiling/sampling/_flamegraph_assets/flamegraph.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,18 @@ function populateThreadStats(data, selectedThreadId = null) {
643643

644644
const gcPctElem = document.getElementById('gc-pct');
645645
if (gcPctElem) gcPctElem.textContent = `${(threadStats.gc_pct || 0).toFixed(1)}%`;
646+
647+
// Exception stats
648+
const excPctElem = document.getElementById('exc-pct');
649+
if (excPctElem) excPctElem.textContent = `${(threadStats.has_exception_pct || 0).toFixed(1)}%`;
650+
651+
// Critical section stats (only shown for free-threaded builds)
652+
const critStat = document.getElementById('crit-stat');
653+
const critPctElem = document.getElementById('crit-pct');
654+
if (critStat && critPctElem && threadStats.free_threaded) {
655+
critStat.style.display = 'block';
656+
critPctElem.textContent = `${(threadStats.in_critical_section_pct || 0).toFixed(1)}%`;
657+
}
646658
}
647659

648660
// ============================================================================

Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ <h3 class="section-title">Runtime Stats</h3>
161161
<div class="stat-tile-value" id="gc-pct">--</div>
162162
<div class="stat-tile-label">GC</div>
163163
</div>
164+
<div class="stat-tile stat-tile--orange" id="exc-stat">
165+
<div class="stat-tile-value" id="exc-pct">--</div>
166+
<div class="stat-tile-label">Exception</div>
167+
</div>
168+
<div class="stat-tile stat-tile--cyan" id="crit-stat" style="display: none;">
169+
<div class="stat-tile-value" id="crit-pct">--</div>
170+
<div class="stat-tile-label">Critical Sect</div>
171+
</div>
164172
</div>
165173
</div>
166174
</section>

Lib/profiling/sampling/cli.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
PROFILING_MODE_WALL,
1717
PROFILING_MODE_CPU,
1818
PROFILING_MODE_GIL,
19+
PROFILING_MODE_EXCEPTION,
20+
PROFILING_MODE_CRITICAL_SECTION,
1921
SORT_MODE_NSAMPLES,
2022
SORT_MODE_TOTTIME,
2123
SORT_MODE_CUMTIME,
@@ -90,6 +92,8 @@ def _parse_mode(mode_string):
9092
"wall": PROFILING_MODE_WALL,
9193
"cpu": PROFILING_MODE_CPU,
9294
"gil": PROFILING_MODE_GIL,
95+
"exception": PROFILING_MODE_EXCEPTION,
96+
"critical": PROFILING_MODE_CRITICAL_SECTION,
9397
}
9498
return mode_map[mode_string]
9599

@@ -207,10 +211,13 @@ def _add_mode_options(parser):
207211
mode_group = parser.add_argument_group("Mode options")
208212
mode_group.add_argument(
209213
"--mode",
210-
choices=["wall", "cpu", "gil"],
214+
choices=["wall", "cpu", "gil", "exception", "critical"],
211215
default="wall",
212216
help="Sampling mode: wall (all samples), cpu (only samples when thread is on CPU), "
213-
"gil (only samples when thread holds the GIL). Incompatible with --async-aware",
217+
"gil (only samples when thread holds the GIL), "
218+
"exception (only samples when thread has an active exception), "
219+
"critical (only samples when thread is in a critical section). "
220+
"Incompatible with --async-aware",
214221
)
215222
mode_group.add_argument(
216223
"--async-mode",

Lib/profiling/sampling/collector.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
THREAD_STATUS_ON_CPU,
55
THREAD_STATUS_GIL_REQUESTED,
66
THREAD_STATUS_UNKNOWN,
7+
THREAD_STATUS_HAS_EXCEPTION,
8+
THREAD_STATUS_IN_CRITICAL_SECTION,
79
)
810

911
try:
@@ -141,7 +143,7 @@ def _collect_thread_status_stats(self, stack_frames):
141143
142144
Returns:
143145
tuple: (aggregate_status_counts, has_gc_frame, per_thread_stats)
144-
- aggregate_status_counts: dict with has_gil, on_cpu, etc.
146+
- aggregate_status_counts: dict with has_gil, on_cpu, has_exception, etc.
145147
- has_gc_frame: bool indicating if any thread has GC frames
146148
- per_thread_stats: dict mapping thread_id to per-thread counts
147149
"""
@@ -150,6 +152,8 @@ def _collect_thread_status_stats(self, stack_frames):
150152
"on_cpu": 0,
151153
"gil_requested": 0,
152154
"unknown": 0,
155+
"has_exception": 0,
156+
"in_critical_section": 0,
153157
"total": 0,
154158
}
155159
has_gc_frame = False
@@ -171,6 +175,10 @@ def _collect_thread_status_stats(self, stack_frames):
171175
status_counts["gil_requested"] += 1
172176
if status_flags & THREAD_STATUS_UNKNOWN:
173177
status_counts["unknown"] += 1
178+
if status_flags & THREAD_STATUS_HAS_EXCEPTION:
179+
status_counts["has_exception"] += 1
180+
if status_flags & THREAD_STATUS_IN_CRITICAL_SECTION:
181+
status_counts["in_critical_section"] += 1
174182

175183
# Track per-thread statistics
176184
thread_id = getattr(thread_info, "thread_id", None)
@@ -181,6 +189,8 @@ def _collect_thread_status_stats(self, stack_frames):
181189
"on_cpu": 0,
182190
"gil_requested": 0,
183191
"unknown": 0,
192+
"has_exception": 0,
193+
"in_critical_section": 0,
184194
"total": 0,
185195
"gc_samples": 0,
186196
}
@@ -196,6 +206,10 @@ def _collect_thread_status_stats(self, stack_frames):
196206
thread_stats["gil_requested"] += 1
197207
if status_flags & THREAD_STATUS_UNKNOWN:
198208
thread_stats["unknown"] += 1
209+
if status_flags & THREAD_STATUS_HAS_EXCEPTION:
210+
thread_stats["has_exception"] += 1
211+
if status_flags & THREAD_STATUS_IN_CRITICAL_SECTION:
212+
thread_stats["in_critical_section"] += 1
199213

200214
# Check for GC frames in this thread
201215
frames = getattr(thread_info, "frame_info", None)

Lib/profiling/sampling/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
PROFILING_MODE_CPU = 1
66
PROFILING_MODE_GIL = 2
77
PROFILING_MODE_ALL = 3 # Combines GIL + CPU checks
8+
PROFILING_MODE_EXCEPTION = 4 # Only samples when thread has an active exception
9+
PROFILING_MODE_CRITICAL_SECTION = 5 # Only samples when thread is in a critical section
810

911
# Sort mode constants
1012
SORT_MODE_NSAMPLES = 0
@@ -21,10 +23,14 @@
2123
THREAD_STATUS_ON_CPU,
2224
THREAD_STATUS_UNKNOWN,
2325
THREAD_STATUS_GIL_REQUESTED,
26+
THREAD_STATUS_HAS_EXCEPTION,
27+
THREAD_STATUS_IN_CRITICAL_SECTION,
2428
)
2529
except ImportError:
2630
# Fallback for tests or when module is not available
2731
THREAD_STATUS_HAS_GIL = (1 << 0)
2832
THREAD_STATUS_ON_CPU = (1 << 1)
2933
THREAD_STATUS_UNKNOWN = (1 << 2)
3034
THREAD_STATUS_GIL_REQUESTED = (1 << 3)
35+
THREAD_STATUS_HAS_EXCEPTION = (1 << 4)
36+
THREAD_STATUS_IN_CRITICAL_SECTION = (1 << 5)

Lib/profiling/sampling/gecko_collector.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99
from .collector import Collector
1010
try:
11-
from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN, THREAD_STATUS_GIL_REQUESTED
11+
from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN, THREAD_STATUS_GIL_REQUESTED, THREAD_STATUS_HAS_EXCEPTION, THREAD_STATUS_IN_CRITICAL_SECTION
1212
except ImportError:
1313
# Fallback if module not available (shouldn't happen in normal use)
1414
THREAD_STATUS_HAS_GIL = (1 << 0)
1515
THREAD_STATUS_ON_CPU = (1 << 1)
1616
THREAD_STATUS_UNKNOWN = (1 << 2)
1717
THREAD_STATUS_GIL_REQUESTED = (1 << 3)
18+
THREAD_STATUS_HAS_EXCEPTION = (1 << 4)
19+
THREAD_STATUS_IN_CRITICAL_SECTION = (1 << 5)
1820

1921

2022
# Categories matching Firefox Profiler expectations
@@ -26,6 +28,8 @@
2628
{"name": "GIL", "color": "green", "subcategories": ["Other"]},
2729
{"name": "CPU", "color": "purple", "subcategories": ["Other"]},
2830
{"name": "Code Type", "color": "red", "subcategories": ["Other"]},
31+
{"name": "Exception", "color": "magenta", "subcategories": ["Other"]},
32+
{"name": "Critical Section", "color": "lightblue", "subcategories": ["Other"]},
2933
]
3034

3135
# Category indices
@@ -36,6 +40,8 @@
3640
CATEGORY_GIL = 4
3741
CATEGORY_CPU = 5
3842
CATEGORY_CODE_TYPE = 6
43+
CATEGORY_EXCEPTION = 7
44+
CATEGORY_CRITICAL_SECTION = 8
3945

4046
# Subcategory indices
4147
DEFAULT_SUBCATEGORY = 0
@@ -84,6 +90,10 @@ def __init__(self, sample_interval_usec, *, skip_idle=False):
8490
self.python_code_start = {} # Thread running Python code (has GIL)
8591
self.native_code_start = {} # Thread running native code (on CPU without GIL)
8692
self.gil_wait_start = {} # Thread waiting for GIL
93+
self.exception_start = {} # Thread has an exception set
94+
self.no_exception_start = {} # Thread has no exception set
95+
self.critical_section_start = {} # Thread is in critical section (free-threaded)
96+
self.no_critical_section_start = {} # Thread is not in critical section
8797

8898
# GC event tracking: track GC start time per thread
8999
self.gc_start_per_thread = {} # tid -> start_time
@@ -197,6 +207,21 @@ def collect(self, stack_frames):
197207
self._add_marker(tid, "Waiting for GIL", self.gil_wait_start.pop(tid),
198208
current_time, CATEGORY_GIL)
199209

210+
# Track exception state (Has Exception / No Exception)
211+
has_exception = bool(status_flags & THREAD_STATUS_HAS_EXCEPTION)
212+
self._track_state_transition(
213+
tid, has_exception, self.exception_start, self.no_exception_start,
214+
"Has Exception", "No Exception", CATEGORY_EXCEPTION, current_time
215+
)
216+
217+
# Track critical section state (In Critical Section / Not In Critical Section)
218+
# This is mainly relevant for free-threaded Python builds
219+
in_critical_section = bool(status_flags & THREAD_STATUS_IN_CRITICAL_SECTION)
220+
self._track_state_transition(
221+
tid, in_critical_section, self.critical_section_start, self.no_critical_section_start,
222+
"In Critical Section", "Not In Critical Section", CATEGORY_CRITICAL_SECTION, current_time
223+
)
224+
200225
# Track GC events by detecting <GC> frames in the stack trace
201226
# This leverages the improved GC frame tracking from commit 336366fd7ca
202227
# which precisely identifies the thread that initiated GC collection
@@ -551,6 +576,10 @@ def _finalize_markers(self):
551576
(self.native_code_start, "Native Code", CATEGORY_CODE_TYPE),
552577
(self.gil_wait_start, "Waiting for GIL", CATEGORY_GIL),
553578
(self.gc_start_per_thread, "GC Collecting", CATEGORY_GC),
579+
(self.exception_start, "Has Exception", CATEGORY_EXCEPTION),
580+
(self.no_exception_start, "No Exception", CATEGORY_EXCEPTION),
581+
(self.critical_section_start, "In Critical Section", CATEGORY_CRITICAL_SECTION),
582+
(self.no_critical_section_start, "Not In Critical Section", CATEGORY_CRITICAL_SECTION),
554583
]
555584

556585
for state_dict, marker_name, category in marker_states:

0 commit comments

Comments
 (0)