44import importlib .util
55import locale
66import os
7+ import re
78import selectors
89import socket
910import subprocess
2021from .binary_collector import BinaryCollector
2122from .binary_reader import BinaryReader
2223from .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+
293343def _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
527577def _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
0 commit comments