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
4 changes: 3 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ Include/opcode.h generated
Include/opcode_ids.h generated
Include/token.h generated
Lib/_opcode_metadata.py generated
Lib/keyword.py generated
Lib/idlelib/help.html generated
Lib/keyword.py generated
Lib/pydoc_data/topics.py generated
Lib/pydoc_data/module_docs.py generated
Lib/test/certdata/*.pem generated
Lib/test/certdata/*.0 generated
Lib/test/levenshtein_examples.json generated
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ repos:
files: ^Apple
- id: ruff-format
name: Run Ruff (format) on Doc/
args: [--check]
args: [--exit-non-zero-on-fix]
files: ^Doc/
- id: ruff-format
name: Run Ruff (format) on Tools/build/check_warnings.py
args: [--check, --config=Tools/build/.ruff.toml]
args: [--exit-non-zero-on-fix, --config=Tools/build/.ruff.toml]
files: ^Tools/build/check_warnings.py
- id: ruff-format
name: Run Ruff (format) on Tools/wasm/
args: [--check, --config=Tools/wasm/.ruff.toml]
args: [--exit-non-zero-on-fix, --config=Tools/wasm/.ruff.toml]
files: ^Tools/wasm/

- repo: https://github.com/psf/black-pre-commit-mirror
Expand Down
3 changes: 2 additions & 1 deletion Doc/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ doctest:
pydoc-topics: BUILDER = pydoc-topics
pydoc-topics: build
@echo "Building finished; now run this:" \
"cp build/pydoc-topics/topics.py ../Lib/pydoc_data/topics.py"
"cp build/pydoc-topics/topics.py ../Lib/pydoc_data/topics.py" \
"&& cp build/pydoc-topics/module_docs.py ../Lib/pydoc_data/module_docs.py"

.PHONY: gettext
gettext: BUILDER = gettext
Expand Down
53 changes: 53 additions & 0 deletions Doc/library/profiling.sampling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ The default configuration works well for most use cases:
- Disabled
* - Default for ``--subprocesses``
- Disabled
* - Default for ``--blocking``
- Disabled (non-blocking sampling)


Sampling interval and duration
Expand Down Expand Up @@ -392,6 +394,50 @@ This option is particularly useful when investigating concurrency issues or
when work is distributed across a thread pool.


.. _blocking-mode:

Blocking mode
-------------

By default, Tachyon reads the target process's memory without stopping it.
This non-blocking approach is ideal for most profiling scenarios because it
imposes virtually zero overhead on the target application: the profiled
program runs at full speed and is unaware it is being observed.

However, non-blocking sampling can occasionally produce incomplete or
inconsistent stack traces in applications with many generators or coroutines
that rapidly switch between yield points, or in programs with very fast-changing
call stacks where functions enter and exit between the start and end of a single
stack read, resulting in reconstructed stacks that mix frames from different
execution states or that never actually existed.

For these cases, the :option:`--blocking` option stops the target process during
each sample::

python -m profiling.sampling run --blocking script.py
python -m profiling.sampling attach --blocking 12345

When blocking mode is enabled, the profiler suspends the target process,
reads its stack, then resumes it. This guarantees that each captured stack
represents a real, consistent snapshot of what the process was doing at that
instant. The trade-off is that the target process runs slower because it is
repeatedly paused.

.. warning::

Do not use very high sample rates (low ``--interval`` values) with blocking
mode. Suspending and resuming a process takes time, and if the sampling
interval is too short, the target will spend more time stopped than running.
For blocking mode, intervals of 1000 microseconds (1 millisecond) or higher
are recommended. The default 100 microsecond interval may cause noticeable
slowdown in the target application.

Use blocking mode only when you observe inconsistent stacks in your profiles,
particularly with generator-heavy or coroutine-heavy code. For most
applications, the default non-blocking mode provides accurate results with
zero impact on the target process.


Special frames
--------------

Expand Down Expand Up @@ -1383,6 +1429,13 @@ Sampling options
Also profile subprocesses. Each subprocess gets its own profiler
instance and output file. Incompatible with ``--live``.

.. option:: --blocking

Pause the target process during each sample. This ensures consistent
stack traces at the cost of slowing down the target. Use with longer
intervals (1000 µs or higher) to minimize impact. See :ref:`blocking-mode`
for details.


Mode options
------------
Expand Down
26 changes: 26 additions & 0 deletions Doc/tools/extensions/pydoc_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class PydocTopicsBuilder(TextBuilder):
def init(self) -> None:
super().init()
self.topics: dict[str, str] = {}
self.module_docs: dict[str, str] = {}

def get_outdated_docs(self) -> str:
# Return a string describing what an update build will build.
Expand All @@ -130,6 +131,15 @@ def write_documents(self, _docnames: Set[str]) -> None:
continue
doc_labels.setdefault(docname, []).append((topic_label, label_id))

py_domain = env.domains['py']
for module_name, module_info in py_domain.data['modules'].items():
docname = module_info[0]
if docname.startswith('library/'):
doc_file = docname.replace('library/', '')
self.module_docs[module_name] = (
f"{doc_file}#module-{module_name}"
)

for docname, label_ids in status_iterator(
doc_labels.items(),
"building topics... ",
Expand Down Expand Up @@ -161,6 +171,22 @@ def finish(self) -> None:
"""
self.outdir.joinpath("topics.py").write_text(topics, encoding="utf-8")

module_docs_repr = "\n".join(
f" '{module}': '{doc_file}',"
for module, doc_file in sorted(self.module_docs.items())
)
module_docs = f"""\
# Autogenerated by Sphinx on {asctime()}
# as part of the release process.
module_docs = {{
{module_docs_repr}
}}
"""
self.outdir.joinpath("module_docs.py").write_text(
module_docs, encoding="utf-8"
)


def _display_labels(item: tuple[str, Sequence[tuple[str, str]]]) -> str:
_docname, label_ids = item
Expand Down
20 changes: 20 additions & 0 deletions Lib/profiling/sampling/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,13 @@ def _add_sampling_options(parser):
action="store_true",
help="Also profile subprocesses. Each subprocess gets its own profiler and output file.",
)
sampling_group.add_argument(
"--blocking",
action="store_true",
help="Stop all threads in target process before sampling to get consistent snapshots. "
"Uses thread_suspend on macOS and ptrace on Linux. Adds overhead but ensures memory "
"reads are from a frozen state.",
)


def _add_mode_options(parser):
Expand Down Expand Up @@ -585,6 +592,15 @@ def _validate_args(args, parser):
if getattr(args, 'command', None) == "replay":
return

# Warn about blocking mode with aggressive sampling intervals
if args.blocking and args.interval < 100:
print(
f"Warning: --blocking with a {args.interval} µs interval will stop all threads "
f"{1_000_000 // args.interval} times per second. "
"Consider using --interval 1000 or higher to reduce overhead.",
file=sys.stderr
)

# Check if live mode is available
if hasattr(args, 'live') and args.live and LiveStatsCollector is None:
parser.error(
Expand Down Expand Up @@ -861,6 +877,7 @@ def _handle_attach(args):
native=args.native,
gc=args.gc,
opcodes=args.opcodes,
blocking=args.blocking,
)
_handle_output(collector, args, args.pid, mode)

Expand Down Expand Up @@ -939,6 +956,7 @@ def _handle_run(args):
native=args.native,
gc=args.gc,
opcodes=args.opcodes,
blocking=args.blocking,
)
_handle_output(collector, args, process.pid, mode)
finally:
Expand Down Expand Up @@ -984,6 +1002,7 @@ def _handle_live_attach(args, pid):
native=args.native,
gc=args.gc,
opcodes=args.opcodes,
blocking=args.blocking,
)


Expand Down Expand Up @@ -1031,6 +1050,7 @@ def _handle_live_run(args):
native=args.native,
gc=args.gc,
opcodes=args.opcodes,
blocking=args.blocking,
)
finally:
# Clean up the subprocess
Expand Down
49 changes: 38 additions & 11 deletions Lib/profiling/sampling/sample.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import _remote_debugging
import contextlib
import os
import statistics
import sys
Expand All @@ -7,7 +8,26 @@
from collections import deque
from _colorize import ANSIColors

from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .binary_collector import BinaryCollector


@contextlib.contextmanager
def _pause_threads(unwinder, blocking):
"""Context manager to pause/resume threads around sampling if blocking is True."""
if blocking:
unwinder.pause_threads()
try:
yield
finally:
unwinder.resume_threads()
else:
yield


from .constants import (
PROFILING_MODE_WALL,
PROFILING_MODE_CPU,
Expand All @@ -25,12 +45,13 @@


class SampleProfiler:
def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, opcodes=False, skip_non_matching_threads=True, collect_stats=False):
def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, opcodes=False, skip_non_matching_threads=True, collect_stats=False, blocking=False):
self.pid = pid
self.sample_interval_usec = sample_interval_usec
self.all_threads = all_threads
self.mode = mode # Store mode for later use
self.collect_stats = collect_stats
self.blocking = blocking
try:
self.unwinder = self._new_unwinder(native, gc, opcodes, skip_non_matching_threads)
except RuntimeError as err:
Expand Down Expand Up @@ -60,12 +81,11 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
running_time = 0
num_samples = 0
errors = 0
interrupted = False
start_time = next_time = time.perf_counter()
last_sample_time = start_time
realtime_update_interval = 1.0 # Update every second
last_realtime_update = start_time
interrupted = False

try:
while running_time < duration_sec:
# Check if live collector wants to stop
Expand All @@ -75,14 +95,15 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
current_time = time.perf_counter()
if next_time < current_time:
try:
if async_aware == "all":
stack_frames = self.unwinder.get_all_awaited_by()
elif async_aware == "running":
stack_frames = self.unwinder.get_async_stack_trace()
else:
stack_frames = self.unwinder.get_stack_trace()
collector.collect(stack_frames)
except ProcessLookupError:
with _pause_threads(self.unwinder, self.blocking):
if async_aware == "all":
stack_frames = self.unwinder.get_all_awaited_by()
elif async_aware == "running":
stack_frames = self.unwinder.get_async_stack_trace()
else:
stack_frames = self.unwinder.get_stack_trace()
collector.collect(stack_frames)
except ProcessLookupError as e:
duration_sec = current_time - start_time
break
except (RuntimeError, UnicodeDecodeError, MemoryError, OSError):
Expand Down Expand Up @@ -350,6 +371,7 @@ def sample(
native=False,
gc=True,
opcodes=False,
blocking=False,
):
"""Sample a process using the provided collector.

Expand All @@ -365,6 +387,7 @@ def sample(
native: Whether to include native frames
gc: Whether to include GC frames
opcodes: Whether to include opcode information
blocking: Whether to stop all threads before sampling for consistent snapshots

Returns:
The collector with collected samples
Expand All @@ -390,6 +413,7 @@ def sample(
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
collect_stats=realtime_stats,
blocking=blocking,
)
profiler.realtime_stats = realtime_stats

Expand All @@ -411,6 +435,7 @@ def sample_live(
native=False,
gc=True,
opcodes=False,
blocking=False,
):
"""Sample a process in live/interactive mode with curses TUI.

Expand All @@ -426,6 +451,7 @@ def sample_live(
native: Whether to include native frames
gc: Whether to include GC frames
opcodes: Whether to include opcode information
blocking: Whether to stop all threads before sampling for consistent snapshots

Returns:
The collector with collected samples
Expand All @@ -451,6 +477,7 @@ def sample_live(
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
collect_stats=realtime_stats,
blocking=blocking,
)
profiler.realtime_stats = realtime_stats

Expand Down
16 changes: 13 additions & 3 deletions Lib/pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,20 @@ def getdocloc(self, object, basedir=None):

if (self._is_stdlib_module(object, basedir) and
object.__name__ not in ('xml.etree', 'test.test_pydoc.pydoc_mod')):
if docloc.startswith(("http://", "https://")):
docloc = "{}/{}.html".format(docloc.rstrip("/"), object.__name__.lower())

try:
from pydoc_data import module_docs
except ImportError:
module_docs = None

if module_docs and object.__name__ in module_docs.module_docs:
doc_name = module_docs.module_docs[object.__name__]
if docloc.startswith(("http://", "https://")):
docloc = "{}/{}".format(docloc.rstrip("/"), doc_name)
else:
docloc = os.path.join(docloc, doc_name)
else:
docloc = os.path.join(docloc, object.__name__.lower() + ".html")
docloc = None
else:
docloc = None
return docloc
Expand Down
Loading
Loading