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
Replace --interval with --sampling-rate
Sampling rate is more intuitive to the number of samples per second
taken, rather than the intervals between samples.
  • Loading branch information
lkollar committed Dec 24, 2025
commit f392e828a637bd3b28bf489a284977790a2382ed
37 changes: 18 additions & 19 deletions Doc/library/profiling.sampling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ counts**, not direct measurements. Tachyon counts how many times each function
appears in the collected samples, then multiplies by the sampling interval to
estimate time.

For example, with a 100 microsecond sampling interval over a 10-second profile,
For example, with a 10 kHz sampling rate over a 10-second profile,
Tachyon collects approximately 100,000 samples. If a function appears in 5,000
samples (5% of total), Tachyon estimates it consumed 5% of the 10-second
duration, or about 500 milliseconds. This is a statistical estimate, not a
Expand Down Expand Up @@ -142,7 +142,7 @@ Use live mode for real-time monitoring (press ``q`` to quit)::

Profile for 60 seconds with a faster sampling rate::

python -m profiling.sampling run -d 60 -i 50 script.py
python -m profiling.sampling run -d 60 -r 20khz script.py

Generate a line-by-line heatmap::

Expand Down Expand Up @@ -326,8 +326,8 @@ The default configuration works well for most use cases:

* - Option
- Default
* - Default for ``--interval`` / ``-i``
- 100 µs between samples (~10,000 samples/sec)
* - Default for ``--sampling-rate`` / ``-r``
- 1 kHz
* - Default for ``--duration`` / ``-d``
- 10 seconds
* - Default for ``--all-threads`` / ``-a``
Expand All @@ -346,23 +346,22 @@ The default configuration works well for most use cases:
- Disabled (non-blocking sampling)


Sampling interval and duration
------------------------------
Sampling rate and duration
--------------------------

The two most fundamental parameters are the sampling interval and duration.
The two most fundamental parameters are the sampling rate and duration.
Together, these determine how many samples will be collected during a profiling
session.

The :option:`--interval` option (:option:`-i`) sets the time between samples in
microseconds. The default is 100 microseconds, which produces approximately
10,000 samples per second::
The :option:`--sampling-rate` option (:option:`-r`) sets how frequently samples
are collected. The default is 1 kHz (10,000 samples per second)::

python -m profiling.sampling run -i 50 script.py
python -m profiling.sampling run -r 20khz script.py

Lower intervals capture more samples and provide finer-grained data at the
cost of slightly higher profiler CPU usage. Higher intervals reduce profiler
Higher rates capture more samples and provide finer-grained data at the
cost of slightly higher profiler CPU usage. Lower rates reduce profiler
overhead but may miss short-lived functions. For most applications, the
default interval provides a good balance between accuracy and overhead.
default rate provides a good balance between accuracy and overhead.

The :option:`--duration` option (:option:`-d`) sets how long to profile in seconds. The
default is 10 seconds::
Expand Down Expand Up @@ -573,9 +572,9 @@ appended:
- For pstats format (which defaults to stdout), subprocesses produce files like
``profile_12345.pstats``

The subprocess profilers inherit most sampling options from the parent (interval,
duration, thread selection, native frames, GC frames, async-aware mode, and
output format). All Python descendant processes are profiled recursively,
The subprocess profilers inherit most sampling options from the parent (sampling
rate, duration, thread selection, native frames, GC frames, async-aware mode,
and output format). All Python descendant processes are profiled recursively,
including grandchildren and further descendants.

Subprocess detection works by periodically scanning for new descendants of
Expand Down Expand Up @@ -1389,9 +1388,9 @@ Global options
Sampling options
----------------

.. option:: -i <microseconds>, --interval <microseconds>
.. option:: -r <rate>, --sampling-rate <rate>

Sampling interval in microseconds. Default: 100.
Sampling rate (for example, ``10000``, ``10khz``, ``10k``). Default: ``1khz``.

.. option:: -d <seconds>, --duration <seconds>

Expand Down
86 changes: 68 additions & 18 deletions Lib/profiling/sampling/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import importlib.util
import locale
import os
import re
import selectors
import socket
import subprocess
Expand All @@ -20,6 +21,7 @@
from .binary_collector import BinaryCollector
from .binary_reader import BinaryReader
from .constants import (
MICROSECONDS_PER_SECOND,
PROFILING_MODE_ALL,
PROFILING_MODE_WALL,
PROFILING_MODE_CPU,
Expand Down Expand Up @@ -116,7 +118,8 @@ def _build_child_profiler_args(args):
child_args = []

# Sampling options
child_args.extend(["-i", str(args.interval)])
hz = MICROSECONDS_PER_SECOND // args.sample_interval_usec
child_args.extend(["-r", str(hz)])
child_args.extend(["-d", str(args.duration)])

if args.all_threads:
Expand Down Expand Up @@ -290,16 +293,64 @@ def _run_with_sync(original_cmd, suppress_output=False):
return process


_RATE_PATTERN = re.compile(r'''
^ # Start of string
( # Group 1: The numeric value
\d+ # One or more digits (integer part)
(?:\.\d+)? # Optional: decimal point followed by digits
) # Examples: "10", "0.5", "100.25"
( # Group 2: Optional unit suffix
hz # "hz" - hertz
| khz # "khz" - kilohertz
| k # "k" - shorthand for kilohertz
)? # Suffix is optional (bare number = Hz)
$ # End of string
''', re.VERBOSE | re.IGNORECASE)


def _parse_sampling_rate(rate_str: str) -> int:
"""Parse sampling rate string to microseconds."""
rate_str = rate_str.strip().lower()

match = _RATE_PATTERN.match(rate_str)
if not match:
raise argparse.ArgumentTypeError(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Let's add a hint about spaces in the error message since "10 khz" (with space) is rejected but users might try it

f"Invalid sampling rate format: {rate_str}. "
"Expected: number followed by optional suffix (hz, khz, k) with no spaces (e.g., 10khz)"
)

number_part = match.group(1)
suffix = match.group(2) or ''

# Determine multiplier based on suffix
suffix_map = {
'hz': 1,
'khz': 1000,
'k': 1000,
}
multiplier = suffix_map.get(suffix, 1)
hz = float(number_part) * multiplier
if hz <= 0:
raise argparse.ArgumentTypeError(f"Sampling rate must be positive: {rate_str}")

interval_usec = int(MICROSECONDS_PER_SECOND / hz)
if interval_usec < 1:
raise argparse.ArgumentTypeError(f"Sampling rate too high: {rate_str}")

return interval_usec


def _add_sampling_options(parser):
"""Add sampling configuration options to a parser."""
sampling_group = parser.add_argument_group("Sampling configuration")
sampling_group.add_argument(
"-i",
"--interval",
type=int,
default=100,
metavar="MICROSECONDS",
help="sampling interval",
"-r",
"--sampling-rate",
type=_parse_sampling_rate,
Copy link
Member

@pablogsal pablogsal Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The argument is named sampling_rate but after parsing it stores the interval in microseconds, not the rate in Hz. This is kind of confusing in the rest of the code

default="1khz",
metavar="RATE",
dest="sample_interval_usec",
help="sampling rate (e.g., 10000, 10khz, 10k)",
)
sampling_group.add_argument(
"-d",
Expand Down Expand Up @@ -487,14 +538,13 @@ def _sort_to_mode(sort_choice):
}
return sort_map.get(sort_choice, SORT_MODE_NSAMPLES)


def _create_collector(format_type, interval, skip_idle, opcodes=False,
def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=False,
output_file=None, compression='auto'):
"""Create the appropriate collector based on format type.

Args:
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko', 'heatmap', 'binary')
interval: Sampling interval in microseconds
sample_interval_usec: Sampling interval in microseconds
skip_idle: Whether to skip idle samples
opcodes: Whether to collect opcode information (only used by gecko format
for creating interval markers in Firefox Profiler)
Expand All @@ -519,9 +569,9 @@ def _create_collector(format_type, interval, skip_idle, opcodes=False,
# and is the only format that uses opcodes for interval markers
if format_type == "gecko":
skip_idle = False
return collector_class(interval, skip_idle=skip_idle, opcodes=opcodes)
return collector_class(sample_interval_usec, skip_idle=skip_idle, opcodes=opcodes)

return collector_class(interval, skip_idle=skip_idle)
return collector_class(sample_interval_usec, skip_idle=skip_idle)


def _generate_output_filename(format_type, pid):
Expand Down Expand Up @@ -725,8 +775,8 @@ def _main():
# Generate flamegraph from a script
`python -m profiling.sampling run --flamegraph -o output.html script.py`

# Profile with custom interval and duration
`python -m profiling.sampling run -i 50 -d 30 script.py`
# Profile with custom rate and duration
`python -m profiling.sampling run -r 5khz -d 30 script.py`

# Save collapsed stacks to file
`python -m profiling.sampling run --collapsed -o stacks.txt script.py`
Expand Down Expand Up @@ -860,7 +910,7 @@ def _handle_attach(args):

# Create the appropriate collector
collector = _create_collector(
args.format, args.interval, skip_idle, args.opcodes,
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
output_file=output_file,
compression=getattr(args, 'compression', 'auto')
)
Expand Down Expand Up @@ -938,7 +988,7 @@ def _handle_run(args):

# Create the appropriate collector
collector = _create_collector(
args.format, args.interval, skip_idle, args.opcodes,
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
output_file=output_file,
compression=getattr(args, 'compression', 'auto')
)
Expand Down Expand Up @@ -980,7 +1030,7 @@ def _handle_live_attach(args, pid):

# Create live collector with default settings
collector = LiveStatsCollector(
args.interval,
args.sample_interval_usec,
skip_idle=skip_idle,
sort_by="tottime", # Default initial sort
limit=20, # Default limit
Expand Down Expand Up @@ -1027,7 +1077,7 @@ def _handle_live_run(args):

# Create live collector with default settings
collector = LiveStatsCollector(
args.interval,
args.sample_interval_usec,
skip_idle=skip_idle,
sort_by="tottime", # Default initial sort
limit=20, # Default limit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ def worker(x):
"run",
"-d",
"5",
"-i",
"100000",
"-r",
"10",
script,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
Expand Down
26 changes: 13 additions & 13 deletions Lib/test/test_profiling/test_sampling_profiler/test_children.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,11 @@ def test_monitor_creation(self):

monitor = ChildProcessMonitor(
pid=os.getpid(),
cli_args=["-i", "100", "-d", "5"],
cli_args=["-r", "10khz", "-d", "5"],
output_pattern="test_{pid}.pstats",
)
self.assertEqual(monitor.parent_pid, os.getpid())
self.assertEqual(monitor.cli_args, ["-i", "100", "-d", "5"])
self.assertEqual(monitor.cli_args, ["-r", "10khz", "-d", "5"])
self.assertEqual(monitor.output_pattern, "test_{pid}.pstats")

def test_monitor_lifecycle(self):
Expand Down Expand Up @@ -386,7 +386,7 @@ def test_build_child_profiler_args(self):
from profiling.sampling.cli import _build_child_profiler_args

args = argparse.Namespace(
interval=200,
sample_interval_usec=200,
duration=15,
all_threads=True,
realtime_stats=False,
Expand Down Expand Up @@ -420,7 +420,7 @@ def assert_flag_value_pair(flag, value):
f"'{child_args[flag_index + 1]}' in args: {child_args}",
)

assert_flag_value_pair("-i", 200)
assert_flag_value_pair("-r", 5000)
assert_flag_value_pair("-d", 15)
assert_flag_value_pair("--mode", "cpu")

Expand All @@ -444,7 +444,7 @@ def test_build_child_profiler_args_no_gc(self):
from profiling.sampling.cli import _build_child_profiler_args

args = argparse.Namespace(
interval=100,
sample_interval_usec=100,
duration=5,
all_threads=False,
realtime_stats=False,
Expand Down Expand Up @@ -510,7 +510,7 @@ def test_setup_child_monitor(self):
from profiling.sampling.cli import _setup_child_monitor

args = argparse.Namespace(
interval=100,
sample_interval_usec=100,
duration=5,
all_threads=False,
realtime_stats=False,
Expand Down Expand Up @@ -690,7 +690,7 @@ def test_monitor_respects_max_limit(self):
# Create a monitor
monitor = ChildProcessMonitor(
pid=os.getpid(),
cli_args=["-i", "100", "-d", "5"],
cli_args=["-r", "10khz", "-d", "5"],
output_pattern="test_{pid}.pstats",
)

Expand Down Expand Up @@ -927,8 +927,8 @@ def test_subprocesses_flag_spawns_child_and_creates_output(self):
"--subprocesses",
"-d",
"3",
"-i",
"10000",
"-r",
"100",
"-o",
output_file,
script_file,
Expand Down Expand Up @@ -989,8 +989,8 @@ def test_subprocesses_flag_with_flamegraph_output(self):
"--subprocesses",
"-d",
"2",
"-i",
"10000",
"-r",
"100",
"--flamegraph",
"-o",
output_file,
Expand Down Expand Up @@ -1043,8 +1043,8 @@ def test_subprocesses_flag_no_crash_on_quick_child(self):
"--subprocesses",
"-d",
"2",
"-i",
"10000",
"-r",
"100",
"-o",
output_file,
script_file,
Expand Down
6 changes: 3 additions & 3 deletions Lib/test/test_profiling/test_sampling_profiler/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def test_cli_module_with_profiler_options(self):
test_args = [
"profiling.sampling.cli",
"run",
"-i",
"-r",
"1000",
"-d",
"30",
Expand Down Expand Up @@ -265,8 +265,8 @@ def test_cli_script_with_profiler_options(self):
test_args = [
"profiling.sampling.cli",
"run",
"-i",
"2000",
"-r",
"500",
"-d",
"60",
"--collapsed",
Expand Down
Loading
Loading