Skip to content

Commit d4dc3dd

Browse files
authored
gh-138122: Replace --interval with --sampling-rate (#143085)
1 parent e8e044e commit d4dc3dd

File tree

15 files changed

+154
-100
lines changed

15 files changed

+154
-100
lines changed

Doc/library/profiling.sampling.rst

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ counts**, not direct measurements. Tachyon counts how many times each function
5353
appears in the collected samples, then multiplies by the sampling interval to
5454
estimate time.
5555

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

143143
Profile for 60 seconds with a faster sampling rate::
144144

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

147147
Generate a line-by-line heatmap::
148148

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

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

348348

349-
Sampling interval and duration
350-
------------------------------
349+
Sampling rate and duration
350+
--------------------------
351351

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

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

360-
python -m profiling.sampling run -i 50 script.py
359+
python -m profiling.sampling run -r 20khz script.py
361360

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

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

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

581580
Subprocess detection works by periodically scanning for new descendants of
@@ -1389,9 +1388,9 @@ Global options
13891388
Sampling options
13901389
----------------
13911390

1392-
.. option:: -i <microseconds>, --interval <microseconds>
1391+
.. option:: -r <rate>, --sampling-rate <rate>
13931392

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

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

Lib/profiling/sampling/_child_monitor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_CHILD_POLL_INTERVAL_SEC = 0.1
1717

1818
# Default timeout for waiting on child profilers
19-
_DEFAULT_WAIT_TIMEOUT = 30.0
19+
_DEFAULT_WAIT_TIMEOUT_SEC = 30.0
2020

2121
# Maximum number of child profilers to spawn (prevents resource exhaustion)
2222
_MAX_CHILD_PROFILERS = 100
@@ -138,7 +138,7 @@ def spawned_profilers(self):
138138
with self._lock:
139139
return list(self._spawned_profilers)
140140

141-
def wait_for_profilers(self, timeout=_DEFAULT_WAIT_TIMEOUT):
141+
def wait_for_profilers(self, timeout=_DEFAULT_WAIT_TIMEOUT_SEC):
142142
"""
143143
Wait for all spawned child profilers to complete.
144144

Lib/profiling/sampling/_sync_coordinator.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def _validate_arguments(args: List[str]) -> tuple[int, str, List[str]]:
7373

7474
# Constants for socket communication
7575
_MAX_RETRIES = 3
76-
_INITIAL_RETRY_DELAY = 0.1
77-
_SOCKET_TIMEOUT = 2.0
76+
_INITIAL_RETRY_DELAY_SEC = 0.1
77+
_SOCKET_TIMEOUT_SEC = 2.0
7878
_READY_MESSAGE = b"ready"
7979

8080

@@ -93,14 +93,14 @@ def _signal_readiness(sync_port: int) -> None:
9393
for attempt in range(_MAX_RETRIES):
9494
try:
9595
# Use context manager for automatic cleanup
96-
with socket.create_connection(("127.0.0.1", sync_port), timeout=_SOCKET_TIMEOUT) as sock:
96+
with socket.create_connection(("127.0.0.1", sync_port), timeout=_SOCKET_TIMEOUT_SEC) as sock:
9797
sock.send(_READY_MESSAGE)
9898
return
9999
except (socket.error, OSError) as e:
100100
last_error = e
101101
if attempt < _MAX_RETRIES - 1:
102102
# Exponential backoff before retry
103-
time.sleep(_INITIAL_RETRY_DELAY * (2 ** attempt))
103+
time.sleep(_INITIAL_RETRY_DELAY_SEC * (2 ** attempt))
104104

105105
# If we get here, all retries failed
106106
raise SyncError(f"Failed to signal readiness after {_MAX_RETRIES} attempts: {last_error}") from last_error

Lib/profiling/sampling/cli.py

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib.util
55
import locale
66
import os
7+
import re
78
import selectors
89
import socket
910
import subprocess
@@ -20,6 +21,7 @@
2021
from .binary_collector import BinaryCollector
2122
from .binary_reader import BinaryReader
2223
from .constants import (
24+
MICROSECONDS_PER_SECOND,
2325
PROFILING_MODE_ALL,
2426
PROFILING_MODE_WALL,
2527
PROFILING_MODE_CPU,
@@ -66,8 +68,8 @@ class CustomFormatter(
6668

6769

6870
# Constants for socket synchronization
69-
_SYNC_TIMEOUT = 5.0
70-
_PROCESS_KILL_TIMEOUT = 2.0
71+
_SYNC_TIMEOUT_SEC = 5.0
72+
_PROCESS_KILL_TIMEOUT_SEC = 2.0
7173
_READY_MESSAGE = b"ready"
7274
_RECV_BUFFER_SIZE = 1024
7375

@@ -116,7 +118,8 @@ def _build_child_profiler_args(args):
116118
child_args = []
117119

118120
# Sampling options
119-
child_args.extend(["-i", str(args.interval)])
121+
hz = MICROSECONDS_PER_SECOND // args.sample_interval_usec
122+
child_args.extend(["-r", str(hz)])
120123
child_args.extend(["-d", str(args.duration)])
121124

122125
if args.all_threads:
@@ -239,7 +242,7 @@ def _run_with_sync(original_cmd, suppress_output=False):
239242
sync_sock.bind(("127.0.0.1", 0)) # Let OS choose a free port
240243
sync_port = sync_sock.getsockname()[1]
241244
sync_sock.listen(1)
242-
sync_sock.settimeout(_SYNC_TIMEOUT)
245+
sync_sock.settimeout(_SYNC_TIMEOUT_SEC)
243246

244247
# Get current working directory to preserve it
245248
cwd = os.getcwd()
@@ -268,7 +271,7 @@ def _run_with_sync(original_cmd, suppress_output=False):
268271
process = subprocess.Popen(cmd, **popen_kwargs)
269272

270273
try:
271-
_wait_for_ready_signal(sync_sock, process, _SYNC_TIMEOUT)
274+
_wait_for_ready_signal(sync_sock, process, _SYNC_TIMEOUT_SEC)
272275

273276
# Close stderr pipe if we were capturing it
274277
if process.stderr:
@@ -279,7 +282,7 @@ def _run_with_sync(original_cmd, suppress_output=False):
279282
if process.poll() is None:
280283
process.terminate()
281284
try:
282-
process.wait(timeout=_PROCESS_KILL_TIMEOUT)
285+
process.wait(timeout=_PROCESS_KILL_TIMEOUT_SEC)
283286
except subprocess.TimeoutExpired:
284287
process.kill()
285288
process.wait()
@@ -290,16 +293,64 @@ def _run_with_sync(original_cmd, suppress_output=False):
290293
return process
291294

292295

296+
_RATE_PATTERN = re.compile(r'''
297+
^ # Start of string
298+
( # Group 1: The numeric value
299+
\d+ # One or more digits (integer part)
300+
(?:\.\d+)? # Optional: decimal point followed by digits
301+
) # Examples: "10", "0.5", "100.25"
302+
( # Group 2: Optional unit suffix
303+
hz # "hz" - hertz
304+
| khz # "khz" - kilohertz
305+
| k # "k" - shorthand for kilohertz
306+
)? # Suffix is optional (bare number = Hz)
307+
$ # End of string
308+
''', re.VERBOSE | re.IGNORECASE)
309+
310+
311+
def _parse_sampling_rate(rate_str: str) -> int:
312+
"""Parse sampling rate string to microseconds."""
313+
rate_str = rate_str.strip().lower()
314+
315+
match = _RATE_PATTERN.match(rate_str)
316+
if not match:
317+
raise argparse.ArgumentTypeError(
318+
f"Invalid sampling rate format: {rate_str}. "
319+
"Expected: number followed by optional suffix (hz, khz, k) with no spaces (e.g., 10khz)"
320+
)
321+
322+
number_part = match.group(1)
323+
suffix = match.group(2) or ''
324+
325+
# Determine multiplier based on suffix
326+
suffix_map = {
327+
'hz': 1,
328+
'khz': 1000,
329+
'k': 1000,
330+
}
331+
multiplier = suffix_map.get(suffix, 1)
332+
hz = float(number_part) * multiplier
333+
if hz <= 0:
334+
raise argparse.ArgumentTypeError(f"Sampling rate must be positive: {rate_str}")
335+
336+
interval_usec = int(MICROSECONDS_PER_SECOND / hz)
337+
if interval_usec < 1:
338+
raise argparse.ArgumentTypeError(f"Sampling rate too high: {rate_str}")
339+
340+
return interval_usec
341+
342+
293343
def _add_sampling_options(parser):
294344
"""Add sampling configuration options to a parser."""
295345
sampling_group = parser.add_argument_group("Sampling configuration")
296346
sampling_group.add_argument(
297-
"-i",
298-
"--interval",
299-
type=int,
300-
default=100,
301-
metavar="MICROSECONDS",
302-
help="sampling interval",
347+
"-r",
348+
"--sampling-rate",
349+
type=_parse_sampling_rate,
350+
default="1khz",
351+
metavar="RATE",
352+
dest="sample_interval_usec",
353+
help="sampling rate (e.g., 10000, 10khz, 10k)",
303354
)
304355
sampling_group.add_argument(
305356
"-d",
@@ -487,14 +538,13 @@ def _sort_to_mode(sort_choice):
487538
}
488539
return sort_map.get(sort_choice, SORT_MODE_NSAMPLES)
489540

490-
491-
def _create_collector(format_type, interval, skip_idle, opcodes=False,
541+
def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=False,
492542
output_file=None, compression='auto'):
493543
"""Create the appropriate collector based on format type.
494544
495545
Args:
496546
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko', 'heatmap', 'binary')
497-
interval: Sampling interval in microseconds
547+
sample_interval_usec: Sampling interval in microseconds
498548
skip_idle: Whether to skip idle samples
499549
opcodes: Whether to collect opcode information (only used by gecko format
500550
for creating interval markers in Firefox Profiler)
@@ -519,9 +569,9 @@ def _create_collector(format_type, interval, skip_idle, opcodes=False,
519569
# and is the only format that uses opcodes for interval markers
520570
if format_type == "gecko":
521571
skip_idle = False
522-
return collector_class(interval, skip_idle=skip_idle, opcodes=opcodes)
572+
return collector_class(sample_interval_usec, skip_idle=skip_idle, opcodes=opcodes)
523573

524-
return collector_class(interval, skip_idle=skip_idle)
574+
return collector_class(sample_interval_usec, skip_idle=skip_idle)
525575

526576

527577
def _generate_output_filename(format_type, pid):
@@ -725,8 +775,8 @@ def _main():
725775
# Generate flamegraph from a script
726776
`python -m profiling.sampling run --flamegraph -o output.html script.py`
727777
728-
# Profile with custom interval and duration
729-
`python -m profiling.sampling run -i 50 -d 30 script.py`
778+
# Profile with custom rate and duration
779+
`python -m profiling.sampling run -r 5khz -d 30 script.py`
730780
731781
# Save collapsed stacks to file
732782
`python -m profiling.sampling run --collapsed -o stacks.txt script.py`
@@ -860,7 +910,7 @@ def _handle_attach(args):
860910

861911
# Create the appropriate collector
862912
collector = _create_collector(
863-
args.format, args.interval, skip_idle, args.opcodes,
913+
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
864914
output_file=output_file,
865915
compression=getattr(args, 'compression', 'auto')
866916
)
@@ -938,7 +988,7 @@ def _handle_run(args):
938988

939989
# Create the appropriate collector
940990
collector = _create_collector(
941-
args.format, args.interval, skip_idle, args.opcodes,
991+
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
942992
output_file=output_file,
943993
compression=getattr(args, 'compression', 'auto')
944994
)
@@ -965,7 +1015,7 @@ def _handle_run(args):
9651015
if process.poll() is None:
9661016
process.terminate()
9671017
try:
968-
process.wait(timeout=_PROCESS_KILL_TIMEOUT)
1018+
process.wait(timeout=_PROCESS_KILL_TIMEOUT_SEC)
9691019
except subprocess.TimeoutExpired:
9701020
process.kill()
9711021
process.wait()
@@ -980,7 +1030,7 @@ def _handle_live_attach(args, pid):
9801030

9811031
# Create live collector with default settings
9821032
collector = LiveStatsCollector(
983-
args.interval,
1033+
args.sample_interval_usec,
9841034
skip_idle=skip_idle,
9851035
sort_by="tottime", # Default initial sort
9861036
limit=20, # Default limit
@@ -1027,7 +1077,7 @@ def _handle_live_run(args):
10271077

10281078
# Create live collector with default settings
10291079
collector = LiveStatsCollector(
1030-
args.interval,
1080+
args.sample_interval_usec,
10311081
skip_idle=skip_idle,
10321082
sort_by="tottime", # Default initial sort
10331083
limit=20, # Default limit

Lib/profiling/sampling/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Constants for the sampling profiler."""
22

3+
# Time unit conversion constants
4+
MICROSECONDS_PER_SECOND = 1_000_000
5+
MILLISECONDS_PER_SECOND = 1_000
6+
37
# Profiling mode constants
48
PROFILING_MODE_WALL = 0
59
PROFILING_MODE_CPU = 1

Lib/profiling/sampling/live_collector/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@
114114
from .constants import (
115115
MICROSECONDS_PER_SECOND,
116116
DISPLAY_UPDATE_HZ,
117-
DISPLAY_UPDATE_INTERVAL,
117+
DISPLAY_UPDATE_INTERVAL_SEC,
118118
MIN_TERMINAL_WIDTH,
119119
MIN_TERMINAL_HEIGHT,
120120
WIDTH_THRESHOLD_SAMPLE_PCT,
@@ -165,7 +165,7 @@
165165
# Constants
166166
"MICROSECONDS_PER_SECOND",
167167
"DISPLAY_UPDATE_HZ",
168-
"DISPLAY_UPDATE_INTERVAL",
168+
"DISPLAY_UPDATE_INTERVAL_SEC",
169169
"MIN_TERMINAL_WIDTH",
170170
"MIN_TERMINAL_HEIGHT",
171171
"WIDTH_THRESHOLD_SAMPLE_PCT",

0 commit comments

Comments
 (0)