xref: /aosp_15_r20/external/perfetto/python/tools/cpu_profile.py (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1#!/usr/bin/env python3
2# Copyright (C) 2022 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15"""Runs tracing with CPU profiling enabled, and symbolizes traces if requested.
16
17For usage instructions, please see:
18https://perfetto.dev/docs/quickstart/callstack-sampling
19
20Adapted in large part from `heap_profile`.
21"""
22
23import argparse
24import os
25import shutil
26import signal
27import subprocess
28import sys
29import tempfile
30import textwrap
31import time
32import uuid
33
34from perfetto.prebuilts.manifests.traceconv import *
35from perfetto.prebuilts.perfetto_prebuilts import *
36
37# Used for creating directories, etc.
38UUID = str(uuid.uuid4())[-6:]
39
40# See `sigint_handler` below.
41IS_INTERRUPTED = False
42
43
44def sigint_handler(signal, frame):
45  """Useful for cleanly interrupting tracing."""
46  global IS_INTERRUPTED
47  IS_INTERRUPTED = True
48
49
50def exit_with_no_profile():
51  sys.exit("No profiles generated.")
52
53
54def exit_with_bug_report(error):
55  sys.exit(
56      "{}\n\n If this is unexpected, please consider filing a bug at: \n"
57      "https://perfetto.dev/docs/contributing/getting-started#bugs.".format(
58          error))
59
60
61def adb_check_output(command):
62  """Runs an `adb` command and returns its output."""
63  try:
64    return subprocess.check_output(command).decode('utf-8')
65  except FileNotFoundError:
66    sys.exit("`adb` not found: Is it installed or on PATH?")
67  except subprocess.CalledProcessError as error:
68    sys.exit("`adb` error: Are any (or multiple) devices connected?\n"
69             "If multiple devices are connected, please select one by "
70             "setting `ANDROID_SERIAL=device_id`.\n"
71             "{}".format(error))
72  except Exception as error:
73    exit_with_bug_report(error)
74
75
76def parse_and_validate_args():
77  """Parses, validates, and returns command-line arguments for this script."""
78  DESCRIPTION = """Runs tracing with CPU profiling enabled, and symbolizes
79  traces if requested.
80
81  For usage instructions, please see:
82  https://perfetto.dev/docs/quickstart/callstack-sampling
83  """
84  parser = argparse.ArgumentParser(description=DESCRIPTION)
85  parser.add_argument(
86      "-f",
87      "--frequency",
88      help="Sampling frequency (Hz). "
89      "Default: 100 Hz.",
90      metavar="FREQUENCY",
91      type=int,
92      default=100)
93  parser.add_argument(
94      "-d",
95      "--duration",
96      help="Duration of profile (ms). 0 to run until interrupted. "
97      "Default: until interrupted by user.",
98      metavar="DURATION",
99      type=int,
100      default=0)
101  # Profiling using hardware counters.
102  parser.add_argument(
103      "-e",
104      "--event",
105      help="Use the specified hardware counter event for sampling.",
106      metavar="EVENT",
107      action="append",
108      # See: '//perfetto/protos/perfetto/trace/perfetto_trace.proto'.
109      choices=['HW_CPU_CYCLES', 'HW_INSTRUCTIONS', 'HW_CACHE_REFERENCES',
110               'HW_CACHE_MISSES', 'HW_BRANCH_INSTRUCTIONS', 'HW_BRANCH_MISSES',
111               'HW_BUS_CYCLES', 'HW_STALLED_CYCLES_FRONTEND',
112               'HW_STALLED_CYCLES_BACKEND'],
113      default=[])
114  parser.add_argument(
115      "-k",
116      "--kernel-frames",
117      help="Collect kernel frames.  Default: false.",
118      action="store_true",
119      default=False)
120  parser.add_argument(
121      "-n",
122      "--name",
123      help="Comma-separated list of names of processes to be profiled.",
124      metavar="NAMES",
125      default=None)
126  parser.add_argument(
127      "-p",
128      "--partial-matching",
129      help="If set, enables \"partial matching\" on the strings in --names/-n."
130      "Processes that are already running when profiling is started, and whose "
131      "names include any of the values in --names/-n as substrings will be "
132      "profiled.",
133      action="store_true")
134  parser.add_argument(
135      "-c",
136      "--config",
137      help="A custom configuration file, if any, to be used for profiling. "
138      "If provided, --frequency/-f, --duration/-d, and --name/-n are not used.",
139      metavar="CONFIG",
140      default=None)
141  parser.add_argument(
142      "--no-annotations",
143      help="Do not suffix the pprof function names with Android ART mode "
144      "annotations such as [jit].",
145      action="store_true")
146  parser.add_argument(
147      "--print-config",
148      action="store_true",
149      help="Print config instead of running. For debugging.")
150  parser.add_argument(
151      "-o",
152      "--output",
153      help="Output directory for recorded trace.",
154      metavar="DIRECTORY",
155      default=None)
156
157  args = parser.parse_args()
158  if args.config is not None:
159    if args.name is not None:
160      sys.exit("--name/-n should not be specified with --config/-c.")
161    elif args.event:
162      sys.exit("-e/--event should not be specified with --config/-c.")
163  elif args.config is None and args.name is None:
164    sys.exit("One of --names/-n or --config/-c is required.")
165
166  return args
167
168
169def get_matching_processes(args, names_to_match):
170  """Returns a list of currently-running processes whose names match
171  `names_to_match`.
172
173  Args:
174    args: The command-line arguments provided to this script.
175    names_to_match: The list of process names provided by the user.
176  """
177  # Returns names as they are.
178  if not args.partial_matching:
179    return names_to_match
180
181  # Attempt to match names to names of currently running processes.
182  PS_PROCESS_OFFSET = 8
183  matching_processes = []
184  for line in adb_check_output(['adb', 'shell', 'ps', '-A']).splitlines():
185    line_split = line.split()
186    if len(line_split) <= PS_PROCESS_OFFSET:
187      continue
188    process = line_split[PS_PROCESS_OFFSET]
189    for name in names_to_match:
190      if name in process:
191        matching_processes.append(process)
192        break
193
194  return matching_processes
195
196
197def get_perfetto_config(args):
198  """Returns a Perfetto config with CPU profiling enabled for the selected
199  processes.
200
201  Args:
202    args: The command-line arguments provided to this script.
203  """
204  if args.config is not None:
205    try:
206      with open(args.config, 'r') as config_file:
207        return config_file.read()
208    except IOError as error:
209      sys.exit("Unable to read config file: {}".format(error))
210
211  CONFIG_INDENT = '          '
212  CONFIG = textwrap.dedent('''\
213  buffers {{
214    size_kb: 2048
215  }}
216
217  buffers {{
218    size_kb: 63488
219  }}
220
221  data_sources {{
222    config {{
223      name: "linux.process_stats"
224      target_buffer: 0
225      process_stats_config {{
226        proc_stats_poll_ms: 100
227      }}
228    }}
229  }}
230
231  duration_ms: {duration}
232  write_into_file: true
233  flush_timeout_ms: 30000
234  flush_period_ms: 604800000
235  ''')
236
237  matching_processes = []
238  if args.name is not None:
239    names_to_match = [name.strip() for name in args.name.split(',')]
240    matching_processes = get_matching_processes(args, names_to_match)
241
242  if not matching_processes:
243    sys.exit("No running processes matched for profiling.")
244
245  target_config = "\n".join(
246      [f'{CONFIG_INDENT}target_cmdline: "{p}"' for p in matching_processes])
247
248  events = args.event or ['SW_CPU_CLOCK']
249  for event in events:
250    CONFIG += (textwrap.dedent('''
251    data_sources {{
252      config {{
253        name: "linux.perf"
254        target_buffer: 1
255        perf_event_config {{
256          timebase {{
257            counter: %s
258            frequency: {frequency}
259            timestamp_clock: PERF_CLOCK_MONOTONIC
260          }}
261          callstack_sampling {{
262            scope {{
263    {target_config}
264            }}
265            kernel_frames: {kernel_config}
266          }}
267        }}
268      }}
269    }}
270    ''') % (event))
271
272  if args.kernel_frames:
273    kernel_config = "true"
274  else:
275    kernel_config = "false"
276
277  if not args.print_config:
278    print("Configured profiling for these processes:\n")
279    for matching_process in matching_processes:
280      print(matching_process)
281    print()
282
283  config = CONFIG.format(
284      frequency=args.frequency,
285      duration=args.duration,
286      target_config=target_config,
287      kernel_config=kernel_config)
288
289  return config
290
291
292def release_or_newer(release):
293  """Returns whether a new enough Android release is being used."""
294  SDK = {'T': 33}
295  sdk = int(
296      adb_check_output(
297          ['adb', 'shell', 'getprop', 'ro.system.build.version.sdk']).strip())
298  if sdk >= SDK[release]:
299    return True
300
301  codename = adb_check_output(
302      ['adb', 'shell', 'getprop', 'ro.build.version.codename']).strip()
303  return codename == release
304
305
306def get_and_prepare_profile_target(args):
307  """Returns the target where the trace/profile will be output.  Creates a
308  new directory if necessary.
309
310  Args:
311    args: The command-line arguments provided to this script.
312  """
313  profile_target = os.path.join(tempfile.gettempdir(), UUID)
314  if args.output is not None:
315    profile_target = args.output
316  else:
317    os.makedirs(profile_target, exist_ok=True)
318  if not os.path.isdir(profile_target):
319    sys.exit("Output directory {} not found.".format(profile_target))
320  if os.listdir(profile_target):
321    sys.exit("Output directory {} not empty.".format(profile_target))
322
323  return profile_target
324
325
326def record_trace(config, profile_target):
327  """Runs Perfetto with the provided configuration to record a trace.
328
329  Args:
330    config: The Perfetto config to be used for tracing/profiling.
331    profile_target: The directory where the recorded trace is output.
332  """
333  NULL = open(os.devnull)
334  NO_OUT = {
335      'stdout': NULL,
336      'stderr': NULL,
337  }
338  if not release_or_newer('T'):
339    sys.exit("This tool requires Android T+ to run.")
340
341  # Push configuration to the device.
342  tf = tempfile.NamedTemporaryFile()
343  tf.file.write(config.encode('utf-8'))
344  tf.file.flush()
345  profile_config_path = '/data/misc/perfetto-configs/config-' + UUID
346  adb_check_output(['adb', 'push', tf.name, profile_config_path])
347  tf.close()
348
349
350  profile_device_path = '/data/misc/perfetto-traces/profile-' + UUID
351  perfetto_command = ('perfetto --txt -c {} -o {} -d')
352  try:
353    perfetto_pid = int(
354        adb_check_output([
355            'adb', 'exec-out',
356            perfetto_command.format(profile_config_path, profile_device_path)
357        ]).strip())
358  except ValueError as error:
359    sys.exit("Unable to start profiling: {}".format(error))
360
361  print("Profiling active. Press Ctrl+C to terminate.")
362
363  old_handler = signal.signal(signal.SIGINT, sigint_handler)
364
365  perfetto_alive = True
366  while perfetto_alive and not IS_INTERRUPTED:
367    perfetto_alive = subprocess.call(
368        ['adb', 'shell', '[ -d /proc/{} ]'.format(perfetto_pid)], **NO_OUT) == 0
369    time.sleep(0.25)
370
371  print("Finishing profiling and symbolization...")
372
373  if IS_INTERRUPTED:
374    adb_check_output(['adb', 'shell', 'kill', '-INT', str(perfetto_pid)])
375
376  # Restore old handler.
377  signal.signal(signal.SIGINT, old_handler)
378
379  while perfetto_alive:
380    perfetto_alive = subprocess.call(
381        ['adb', 'shell', '[ -d /proc/{} ]'.format(perfetto_pid)]) == 0
382    time.sleep(0.25)
383
384  profile_host_path = os.path.join(profile_target, 'raw-trace')
385  adb_check_output(['adb', 'pull', profile_device_path, profile_host_path])
386  adb_check_output(['adb', 'shell', 'rm', profile_config_path])
387  adb_check_output(['adb', 'shell', 'rm', profile_device_path])
388
389
390def get_traceconv():
391  """Sets up and returns the path to `traceconv`."""
392  try:
393    traceconv = get_perfetto_prebuilt(TRACECONV_MANIFEST, soft_fail=True)
394  except Exception as error:
395    exit_with_bug_report(error)
396  if traceconv is None:
397    exit_with_bug_report(
398        "Unable to download `traceconv` for symbolizing profiles.")
399
400  return traceconv
401
402
403def concatenate_files(files_to_concatenate, output_file):
404  """Concatenates files.
405
406  Args:
407    files_to_concatenate: Paths for input files to concatenate.
408    output_file: Path to the resultant output file.
409  """
410  with open(output_file, 'wb') as output:
411    for file in files_to_concatenate:
412      with open(file, 'rb') as input:
413        shutil.copyfileobj(input, output)
414
415
416def symbolize_trace(traceconv, profile_target):
417  """Attempts symbolization of the recorded trace/profile, if symbols are
418  available.
419
420  Args:
421    traceconv: The path to the `traceconv` binary used for symbolization.
422    profile_target: The directory where the recorded trace was output.
423
424  Returns:
425    The path to the symbolized trace file if symbolization was completed,
426    and the original trace file, if it was not.
427  """
428  binary_path = os.getenv('PERFETTO_BINARY_PATH')
429  trace_file = os.path.join(profile_target, 'raw-trace')
430  files_to_concatenate = [trace_file]
431
432  if binary_path is not None:
433    try:
434      with open(os.path.join(profile_target, 'symbols'), 'w') as symbols_file:
435        return_code = subprocess.call([traceconv, 'symbolize', trace_file],
436                                      env=dict(
437                                          os.environ,
438                                          PERFETTO_BINARY_PATH=binary_path),
439                                      stdout=symbols_file)
440    except IOError as error:
441      sys.exit("Unable to write symbols to disk: {}".format(error))
442    if return_code == 0:
443      files_to_concatenate.append(os.path.join(profile_target, 'symbols'))
444    else:
445      print("Failed to symbolize. Continuing without symbols.", file=sys.stderr)
446
447  if len(files_to_concatenate) > 1:
448    trace_file = os.path.join(profile_target, 'symbolized-trace')
449    try:
450      concatenate_files(files_to_concatenate, trace_file)
451    except Exception as error:
452      sys.exit("Unable to write symbolized profile to disk: {}".format(error))
453
454  return trace_file
455
456
457def generate_pprof_profiles(traceconv, trace_file, args):
458  """Generates pprof profiles from the recorded trace.
459
460  Args:
461    traceconv: The path to the `traceconv` binary used for generating profiles.
462    trace_file: The oath to the recorded and potentially symbolized trace file.
463
464  Returns:
465    The directory where pprof profiles are output.
466  """
467  try:
468    conversion_args = [traceconv, 'profile', '--perf'] + (
469        ['--no-annotations'] if args.no_annotations else []) + [trace_file]
470    traceconv_output = subprocess.check_output(conversion_args)
471  except Exception as error:
472    exit_with_bug_report(
473        "Unable to extract profiles from trace: {}".format(error))
474
475  profiles_output_directory = None
476  for word in traceconv_output.decode('utf-8').split():
477    if 'perf_profile-' in word:
478      profiles_output_directory = word
479  if profiles_output_directory is None:
480    exit_with_no_profile()
481  return profiles_output_directory
482
483
484def copy_profiles_to_destination(profile_target, profile_path):
485  """Copies recorded profiles to `profile_target` from `profile_path`."""
486  profile_files = os.listdir(profile_path)
487  if not profile_files:
488    exit_with_no_profile()
489
490  try:
491    for profile_file in profile_files:
492      shutil.copy(os.path.join(profile_path, profile_file), profile_target)
493  except Exception as error:
494    sys.exit("Unable to copy profiles to {}: {}".format(profile_target, error))
495
496  print("Wrote profiles to {}".format(profile_target))
497
498
499def main(argv):
500  args = parse_and_validate_args()
501  profile_target = get_and_prepare_profile_target(args)
502  trace_config = get_perfetto_config(args)
503  if args.print_config:
504    print(trace_config)
505    return 0
506  record_trace(trace_config, profile_target)
507  traceconv = get_traceconv()
508  trace_file = symbolize_trace(traceconv, profile_target)
509  copy_profiles_to_destination(
510      profile_target, generate_pprof_profiles(traceconv, trace_file, args))
511  return 0
512
513
514if __name__ == '__main__':
515  sys.exit(main(sys.argv))
516