Skip to content

Commit 7a76f68

Browse files
committed
Improve CLI
1 parent aeca768 commit 7a76f68

2 files changed

Lines changed: 167 additions & 85 deletions

File tree

Lib/profile/sample.py

Lines changed: 164 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -166,70 +166,94 @@ def _print_top_functions(stats_list, title, key_func, format_line, n=3):
166166
f"\n{ANSIColors.BOLD_BLUE}Summary of Interesting Functions:{ANSIColors.RESET}"
167167
)
168168

169+
# Aggregate stats by fully qualified function name (ignoring line numbers)
170+
func_aggregated = {}
171+
for func, prim_calls, total_calls, total_time, cumulative_time, callers in stats_list:
172+
# Use filename:function_name as the key to get fully qualified name
173+
qualified_name = f"{func[0]}:{func[2]}"
174+
if qualified_name not in func_aggregated:
175+
func_aggregated[qualified_name] = [0, 0, 0, 0] # prim_calls, total_calls, total_time, cumulative_time
176+
func_aggregated[qualified_name][0] += prim_calls
177+
func_aggregated[qualified_name][1] += total_calls
178+
func_aggregated[qualified_name][2] += total_time
179+
func_aggregated[qualified_name][3] += cumulative_time
180+
181+
# Convert aggregated data back to list format for processing
182+
aggregated_stats = []
183+
for qualified_name, (prim_calls, total_calls, total_time, cumulative_time) in func_aggregated.items():
184+
# Parse the qualified name back to filename and function name
185+
if ":" in qualified_name:
186+
filename, func_name = qualified_name.rsplit(":", 1)
187+
else:
188+
filename, func_name = "", qualified_name
189+
# Create a dummy func tuple with filename and function name for display
190+
dummy_func = (filename, "", func_name)
191+
aggregated_stats.append((dummy_func, prim_calls, total_calls, total_time, cumulative_time, {}))
192+
169193
# Most time-consuming functions (by total time)
170194
def format_time_consuming(stat):
171-
func, _, nc, tt, _, _ = stat
172-
if tt > 0:
195+
func, _, total_calls, total_time, _, _ = stat
196+
if total_time > 0:
173197
return (
174-
f"{tt * tt_scale:8.3f} {tt_unit} total time, "
175-
f"{(tt / nc) * tt_scale:8.3f} {tt_unit} per call: {_format_func_name(func)}"
198+
f"{total_time * tt_scale:8.3f} {tt_unit} total time, "
199+
f"{(total_time / total_calls) * tt_scale:8.3f} {tt_unit} per call: {_format_func_name(func)}"
176200
)
177201
return None
178202

179203
_print_top_functions(
180-
stats_list,
204+
aggregated_stats,
181205
"Most Time-Consuming Functions",
182206
key_func=lambda x: x[3],
183207
format_line=format_time_consuming,
184208
)
185209

186210
# Most called functions
187211
def format_most_called(stat):
188-
func, _, nc, tt, _, _ = stat
189-
if nc > 0:
212+
func, _, total_calls, total_time, _, _ = stat
213+
if total_calls > 0:
190214
return (
191-
f"{nc:8d} calls, {(tt / nc) * tt_scale:8.3f} {tt_unit} "
215+
f"{total_calls:8d} calls, {(total_time / total_calls) * tt_scale:8.3f} {tt_unit} "
192216
f"per call: {_format_func_name(func)}"
193217
)
194218
return None
195219

196220
_print_top_functions(
197-
stats_list,
221+
aggregated_stats,
198222
"Most Called Functions",
199223
key_func=lambda x: x[2],
200224
format_line=format_most_called,
201225
)
202226

203227
# Functions with highest per-call overhead
204228
def format_overhead(stat):
205-
func, _, nc, tt, _, _ = stat
206-
if nc > 0 and tt > 0:
229+
func, _, total_calls, total_time, _, _ = stat
230+
if total_calls > 0 and total_time > 0:
207231
return (
208-
f"{(tt / nc) * tt_scale:8.3f} {tt_unit} per call, "
209-
f"{nc:8d} calls: {_format_func_name(func)}"
232+
f"{(total_time / total_calls) * tt_scale:8.3f} {tt_unit} per call, "
233+
f"{total_calls:8d} calls: {_format_func_name(func)}"
210234
)
211235
return None
212236

213237
_print_top_functions(
214-
stats_list,
238+
aggregated_stats,
215239
"Functions with Highest Per-Call Overhead",
216240
key_func=lambda x: x[3] / x[2] if x[2] > 0 else 0,
217241
format_line=format_overhead,
218242
)
219243

220244
# Functions with highest cumulative impact
221245
def format_cumulative(stat):
222-
func, _, nc, _, ct, _ = stat
223-
if ct > 0:
246+
func, _, total_calls, _, cumulative_time, _ = stat
247+
if cumulative_time > 0:
224248
return (
225-
f"{ct * ct_scale:8.3f} {ct_unit} cumulative time, "
226-
f"{(ct / nc) * ct_scale:8.3f} {ct_unit} per call: "
249+
f"{cumulative_time * ct_scale:8.3f} {ct_unit} cumulative time, "
250+
f"{(cumulative_time / total_calls) * ct_scale:8.3f} {ct_unit} per call: "
227251
f"{_format_func_name(func)}"
228252
)
229253
return None
230254

231255
_print_top_functions(
232-
stats_list,
256+
aggregated_stats,
233257
"Functions with Highest Cumulative Impact",
234258
key_func=lambda x: x[4],
235259
format_line=format_cumulative,
@@ -270,125 +294,185 @@ def sample(
270294
else:
271295
collector.export(filename)
272296

297+
def _validate_collapsed_format_args(args, parser):
298+
# Check for incompatible pstats options
299+
invalid_opts = []
300+
301+
# Get list of pstats-specific options
302+
pstats_options = {
303+
'sort': None,
304+
'limit': None,
305+
'no_summary': False
306+
}
307+
308+
# Find the default values from the argument definitions
309+
for action in parser._actions:
310+
if action.dest in pstats_options and hasattr(action, 'default'):
311+
pstats_options[action.dest] = action.default
312+
313+
# Check if any pstats-specific options were provided by comparing with defaults
314+
for opt, default in pstats_options.items():
315+
if getattr(args, opt) != default:
316+
invalid_opts.append(opt.replace('no_', ''))
317+
318+
if invalid_opts:
319+
parser.error(f"The following options are only valid with --pstats format: {', '.join(invalid_opts)}")
320+
321+
# Set default output filename for collapsed format
322+
if not args.outfile:
323+
args.outfile = f"collapsed.{args.pid}.txt"
324+
273325

274326
def main():
327+
# Create the main parser
275328
parser = argparse.ArgumentParser(
276329
description=(
277-
"Sample a process's stack frames.\n\n"
278-
"Sort options:\n"
279-
" --sort-calls Sort by number of calls (most called functions first)\n"
280-
" --sort-time Sort by total time (most time-consuming functions first)\n"
281-
" --sort-cumulative Sort by cumulative time (functions with highest total impact first)\n"
282-
" --sort-percall Sort by time per call (functions with highest per-call overhead first)\n"
283-
" --sort-cumpercall Sort by cumulative time per call (functions with highest cumulative overhead per call)\n"
284-
" --sort-name Sort by function name (alphabetical order)\n\n"
285-
"The default sort is by cumulative time (--sort-cumulative)."
286-
"Format descriptions:\n"
287-
" pstats Standard Python profiler output format\n"
288-
" collapsed Stack traces in collapsed format (file:function:line;file:function:line;... count)\n"
289-
" Useful for generating flamegraphs with tools like flamegraph.pl"
330+
"Sample a process's stack frames and generate profiling data.\n"
331+
"Supports two output formats:\n"
332+
" - pstats: Detailed profiling statistics with sorting options\n"
333+
" - collapsed: Stack traces for generating flamegraphs\n"
334+
"\n"
335+
"Examples:\n"
336+
" # Profile process 1234 for 10 seconds with default settings\n"
337+
" python -m profile.sample 1234\n"
338+
"\n"
339+
" # Profile with custom interval and duration, save to file\n"
340+
" python -m profile.sample -i 50 -d 30 -o profile.stats 1234\n"
341+
"\n"
342+
" # Generate collapsed stacks for flamegraph\n"
343+
" python -m profile.sample --collapsed 1234\n"
344+
"\n"
345+
" # Profile all threads, sort by total time\n"
346+
" python -m profile.sample -a --sort-time 1234\n"
347+
"\n"
348+
" # Profile for 1 minute with 1ms sampling interval\n"
349+
" python -m profile.sample -i 1000 -d 60 1234\n"
350+
"\n"
351+
" # Show only top 20 functions sorted by calls\n"
352+
" python -m profile.sample --sort-calls -l 20 1234\n"
353+
"\n"
354+
" # Profile all threads and save collapsed stacks\n"
355+
" python -m profile.sample -a --collapsed -o stacks.txt 1234"
290356
),
291-
formatter_class=argparse.RawDescriptionHelpFormatter,
292-
color=True,
357+
formatter_class=argparse.RawDescriptionHelpFormatter
293358
)
294-
parser.add_argument("pid", type=int, help="Process ID to sample.")
359+
360+
# Required arguments
295361
parser.add_argument(
296-
"-i",
297-
"--interval",
362+
"pid",
298363
type=int,
299-
default=10,
300-
help="Sampling interval in microseconds (default: 10 usec)",
364+
help="Process ID to sample"
301365
)
302-
parser.add_argument(
303-
"-d",
304-
"--duration",
366+
367+
# Sampling options
368+
sampling_group = parser.add_argument_group("Sampling configuration")
369+
sampling_group.add_argument(
370+
"-i", "--interval",
305371
type=int,
306-
default=10,
307-
help="Sampling duration in seconds (default: 10 seconds)",
308-
)
309-
parser.add_argument(
310-
"-a",
311-
"--all-threads",
312-
action="store_true",
313-
help="Sample all threads in the process",
314-
)
315-
parser.add_argument("-o", "--outfile", help="Save stats to <outfile>")
316-
parser.add_argument(
317-
"--no-color",
318-
action="store_true",
319-
help="Disable color output",
372+
default=100,
373+
help="Sampling interval in microseconds (default: 100)"
320374
)
321-
parser.add_argument(
322-
"-l",
323-
"--limit",
375+
sampling_group.add_argument(
376+
"-d", "--duration",
324377
type=int,
325-
help="Limit the number of rows in the output",
378+
default=10,
379+
help="Sampling duration in seconds (default: 10)"
326380
)
327-
parser.add_argument(
328-
"--no-summary",
381+
sampling_group.add_argument(
382+
"-a", "--all-threads",
329383
action="store_true",
330-
help="Disable the summary section at the end of the output",
384+
help="Sample all threads in the process instead of just the main thread"
331385
)
332-
parser.add_argument(
333-
"--format",
334-
choices=["pstats", "collapsed"],
386+
387+
# Output format selection
388+
output_group = parser.add_argument_group("Output options")
389+
output_format = output_group.add_mutually_exclusive_group()
390+
output_format.add_argument(
391+
"--pstats",
392+
action="store_const",
393+
const="pstats",
394+
dest="format",
335395
default="pstats",
336-
help="Output format (default: pstats)",
396+
help="Generate pstats output (default)"
397+
)
398+
output_format.add_argument(
399+
"--collapsed",
400+
action="store_const",
401+
const="collapsed",
402+
dest="format",
403+
help="Generate collapsed stack traces for flamegraphs"
404+
)
405+
406+
output_group.add_argument(
407+
"-o", "--outfile",
408+
help="Save output to a file (if omitted, prints to stdout for pstats, "
409+
"or saves to collapsed.<pid>.txt for collapsed format)"
337410
)
338411

339-
# Add sorting options
340-
sort_group = parser.add_mutually_exclusive_group()
412+
# pstats-specific options
413+
pstats_group = parser.add_argument_group("pstats format options")
414+
sort_group = pstats_group.add_mutually_exclusive_group()
341415
sort_group.add_argument(
342416
"--sort-calls",
343417
action="store_const",
344418
const=0,
345419
dest="sort",
346-
help="Sort by number of calls (most called functions first)",
420+
help="Sort by number of calls"
347421
)
348422
sort_group.add_argument(
349423
"--sort-time",
350424
action="store_const",
351425
const=1,
352426
dest="sort",
353-
help="Sort by total time (most time-consuming functions first)",
427+
help="Sort by total time"
354428
)
355429
sort_group.add_argument(
356430
"--sort-cumulative",
357431
action="store_const",
358432
const=2,
359433
dest="sort",
360-
help="Sort by cumulative time (functions with highest total impact first)",
434+
default=2,
435+
help="Sort by cumulative time (default)"
361436
)
362437
sort_group.add_argument(
363438
"--sort-percall",
364439
action="store_const",
365440
const=3,
366441
dest="sort",
367-
help="Sort by time per call (functions with highest per-call overhead first)",
442+
help="Sort by time per call"
368443
)
369444
sort_group.add_argument(
370445
"--sort-cumpercall",
371446
action="store_const",
372447
const=4,
373448
dest="sort",
374-
help="Sort by cumulative time per call (functions with highest cumulative overhead per call)",
449+
help="Sort by cumulative time per call"
375450
)
376451
sort_group.add_argument(
377452
"--sort-name",
378453
action="store_const",
379454
const=5,
380455
dest="sort",
381-
help="Sort by function name (alphabetical order)",
456+
help="Sort by function name"
382457
)
383458

384-
# Set default sort to cumulative time
385-
parser.set_defaults(sort=2)
459+
pstats_group.add_argument(
460+
"-l", "--limit",
461+
type=int,
462+
help="Limit the number of rows in the output",
463+
default=15,
464+
)
465+
pstats_group.add_argument(
466+
"--no-summary",
467+
action="store_true",
468+
help="Disable the summary section in the output"
469+
)
386470

387471
args = parser.parse_args()
388472

389-
# Set color theme based on --no-color flag
390-
if args.no_color:
391-
_colorize.set_theme(_colorize.theme_no_color)
473+
# Validate format-specific arguments
474+
if args.format == "collapsed":
475+
_validate_collapsed_format_args(args, parser)
392476

393477
sample(
394478
args.pid,
@@ -401,7 +485,5 @@ def main():
401485
show_summary=not args.no_summary,
402486
output_format=args.format,
403487
)
404-
405-
406488
if __name__ == "__main__":
407489
main()

Lib/test/test_sample_profiler.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,12 +535,12 @@ def test_argument_parsing_basic(self):
535535

536536
mock_sample.assert_called_once_with(
537537
12345,
538-
sample_interval_usec=10,
538+
sample_interval_usec=100,
539539
duration_sec=10,
540540
filename=None,
541541
all_threads=False,
542-
limit=None,
543-
sort=2,
542+
limit=15,
543+
sort=None,
544544
show_summary=True,
545545
output_format="pstats",
546546
)

0 commit comments

Comments
 (0)