#!/usr/bin/env python # # Copyright (C) 2016 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """app_profiler.py: manage the process of profiling an android app. It downloads simpleperf on device, uses it to collect samples from user's app, and pulls perf.data and needed binaries on host. """ from __future__ import print_function import argparse import copy import os import os.path import re import shutil import subprocess import sys import time from binary_cache_builder import BinaryCacheBuilder from simpleperf_report_lib import * from utils import * class AppProfiler(object): """Used to manage the process of profiling an android app. There are three steps: 1. Prepare profiling. 2. Profile the app. 3. Collect profiling data. """ def __init__(self, config): self.check_config(config) self.config = config self.adb = AdbHelper(enable_switch_to_root=not config['disable_adb_root']) self.is_root_device = False self.android_version = 0 self.device_arch = None self.app_arch = self.config['app_arch'] self.app_program = self.config['app_package_name'] or self.config['native_program'] self.app_pid = None self.has_symfs_on_device = False self.record_subproc = None def check_config(self, config): config_names = ['app_package_name', 'native_program', 'cmd', 'native_lib_dir', 'apk_file_path', 'recompile_app', 'launch_activity', 'launch_inst_test', 'record_options', 'perf_data_path', 'profile_from_launch', 'app_arch'] for name in config_names: if name not in config: log_exit('config [%s] is missing' % name) if config['app_package_name'] and config['native_program']: log_exit("We can't profile an Android app and a native program at the same time.") elif config['app_package_name'] and config['cmd']: log_exit("We can't profile an Android app and a cmd at the same time.") elif config['native_program'] and config['cmd']: log_exit("We can't profile a native program and a cmd at the same time.") elif not config['app_package_name'] and not config['native_program'] and not config["cmd"]: log_exit("Please set a profiling target: an Android app, a native program or a cmd.") if config['app_package_name']: if config['launch_activity'] and config['launch_inst_test']: log_exit("We can't launch an activity and a test at the same time.") native_lib_dir = config.get('native_lib_dir') if native_lib_dir and not os.path.isdir(native_lib_dir): log_exit('[native_lib_dir] "%s" is not a dir' % native_lib_dir) apk_file_path = config.get('apk_file_path') if apk_file_path and not os.path.isfile(apk_file_path): log_exit('[apk_file_path] "%s" is not a file' % apk_file_path) if config['recompile_app']: if not config['launch_activity'] and not config['launch_inst_test']: # If recompile app, the app needs to be restarted to take effect. config['launch_activity'] = '.MainActivity' if config['profile_from_launch']: if not config['app_package_name']: log_exit('-p needs to be set to profile from launch.') if not config['launch_activity']: log_exit('-a needs to be set to profile from launch.') if not config['app_arch']: log_exit('--arch needs to be set to profile from launch.') def profile(self): log_info('prepare profiling') self.prepare_profiling() log_info('start profiling') self.start_and_wait_profiling() log_info('collect profiling data') self.collect_profiling_data() log_info('profiling is finished.') def prepare_profiling(self): self._get_device_environment() self._enable_profiling() self._recompile_app() self._restart_app() self._get_app_environment() if not self.config['profile_from_launch']: self._download_simpleperf() self._download_native_libs() def _get_device_environment(self): self.is_root_device = self.adb.switch_to_root() self.android_version = self.adb.get_android_version() if self.android_version < 7: log_warning("app_profiler.py is not tested prior Android N, please switch to use cmdline interface.") self.device_arch = self.adb.get_device_arch() def _enable_profiling(self): self.adb.set_property('security.perf_harden', '0') if self.is_root_device: # We can enable kernel symbols self.adb.run(['shell', 'echo 0 >/proc/sys/kernel/kptr_restrict']) def _recompile_app(self): if not self.config['recompile_app']: return if self.android_version == 0: log_warning("Can't fully compile an app on android version < L.") elif self.android_version == 5 or self.android_version == 6: if not self.is_root_device: log_warning("Can't fully compile an app on android version < N on non-root devices.") elif not self.config['apk_file_path']: log_warning("apk file is needed to reinstall the app on android version < N.") else: flag = '-g' if self.android_version == 6 else '--include-debug-symbols' self.adb.set_property('dalvik.vm.dex2oat-flags', flag) self.adb.check_run(['install', '-r', self.config['apk_file_path']]) elif self.android_version >= 7: self.adb.set_property('debug.generate-debug-info', 'true') self.adb.check_run(['shell', 'cmd', 'package', 'compile', '-f', '-m', 'speed', self.config['app_package_name']]) else: log_fatal('unreachable') def _restart_app(self): if not self.config['app_package_name']: return if not self.config['launch_activity'] and not self.config['launch_inst_test']: self.app_pid = self._find_app_process() if self.app_pid is not None: return else: self.config['launch_activity'] = '.MainActivity' self.adb.check_run(['shell', 'am', 'force-stop', self.config['app_package_name']]) count = 0 while True: time.sleep(1) pid = self._find_app_process() if pid is None: break # When testing on Android N, `am force-stop` sometimes can't kill # com.example.simpleperf.simpleperfexampleofkotlin. So use kill when this happens. count += 1 if count >= 3: self.run_in_app_dir(['kill', '-9', str(pid)], check_result=False, log_output=False) if self.config['profile_from_launch']: self._download_simpleperf() self.start_profiling() if self.config['launch_activity']: activity = self.config['app_package_name'] + '/' + self.config['launch_activity'] result = self.adb.run(['shell', 'am', 'start', '-n', activity]) if not result: log_exit("Can't start activity %s" % activity) else: runner = self.config['app_package_name'] + '/android.support.test.runner.AndroidJUnitRunner' result = self.adb.run(['shell', 'am', 'instrument', '-e', 'class', self.config['launch_inst_test'], runner]) if not result: log_exit("Can't start instrumentation test %s" % self.config['launch_inst_test']) for i in range(10): self.app_pid = self._find_app_process() if self.app_pid is not None: return time.sleep(1) log_info('Wait for the app process for %d seconds' % (i + 1)) log_exit("Can't find the app process") def _find_app_process(self): if not self.config['app_package_name'] and self.android_version >= 7: result, output = self.adb.run_and_return_output(['shell', 'pidof', self.app_program]) return int(output) if result else None ps_args = ['ps', '-e', '-o', 'PID,NAME'] if self.android_version >= 8 else ['ps'] result, output = self.adb.run_and_return_output(['shell'] + ps_args, log_output=False) if not result: return None for line in output.split('\n'): strs = line.split() if len(strs) < 2: continue process_name = strs[-1] if self.config['app_package_name']: # This is to match process names in multiprocess apps. process_name = process_name.split(':')[0] if process_name == self.app_program: pid = int(strs[0] if self.android_version >= 8 else strs[1]) # If a debuggable app with wrap.sh runs on Android O, the app will be started with # logwrapper as below: # 1. Zygote forks a child process, rename it to package_name. # 2. The child process execute sh, which starts a child process running # /system/bin/logwrapper. # 3. logwrapper starts a child process running sh, which interprets wrap.sh. # 4. wrap.sh starts a child process running the app. # The problem here is we want to profile the process started in step 4, but # sometimes we run into the process started in step 1. To solve it, we can check # if the process has opened an apk file in some app dirs. if self.android_version >= 8 and self.config['app_package_name'] and ( not self._has_opened_apk_file(pid)): continue return pid return None def _has_opened_apk_file(self, pid): result, output = self.run_in_app_dir(['ls -l /proc/%d/fd' % pid], check_result=False, log_output=False) return result and re.search(r'app.*\.apk', output) def _get_app_environment(self): if not self.config['cmd']: if self.app_pid is None: self.app_pid = self._find_app_process() if self.app_pid is None: log_exit("can't find process for app [%s]" % self.app_program) if not self.app_arch: if not self.config['cmd'] and self.device_arch in ['arm64', 'x86_64']: output = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid], log_output=False) if 'linker64' in output: self.app_arch = self.device_arch else: self.app_arch = 'arm' if self.device_arch == 'arm64' else 'x86' else: self.app_arch = self.device_arch log_info('app_arch: %s' % self.app_arch) def _download_simpleperf(self): simpleperf_binary = get_target_binary_path(self.app_arch, 'simpleperf') self.adb.check_run(['push', simpleperf_binary, '/data/local/tmp']) self.adb.check_run(['shell', 'chmod', 'a+x', '/data/local/tmp/simpleperf']) def _download_native_libs(self): if not self.config['native_lib_dir'] or not self.config['app_package_name']: return filename_dict = dict() for root, _, files in os.walk(self.config['native_lib_dir']): for file in files: if not file.endswith('.so'): continue path = os.path.join(root, file) old_path = filename_dict.get(file) log_info('app_arch = %s' % self.app_arch) if self._is_lib_better(path, old_path): log_info('%s is better than %s' % (path, old_path)) filename_dict[file] = path else: log_info('%s is worse than %s' % (path, old_path)) maps = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid], log_output=False) searched_lib = dict() for item in maps.split(): if item.endswith('.so') and searched_lib.get(item) is None: searched_lib[item] = True # Use '/' as path separator as item comes from android environment. filename = item[item.rfind('/') + 1:] dirname = '/data/local/tmp/native_libs' + item[:item.rfind('/')] path = filename_dict.get(filename) if path is None: continue self.adb.check_run(['shell', 'mkdir', '-p', dirname]) self.adb.check_run(['push', path, dirname]) self.has_symfs_on_device = True def _is_lib_better(self, new_path, old_path): """ Return true if new_path is more likely to be used on device. """ if old_path is None: return True if self.app_arch == 'arm': result1 = 'armeabi-v7a/' in new_path result2 = 'armeabi-v7a' in old_path if result1 != result2: return result1 arch_dir = self.app_arch + '/' result1 = arch_dir in new_path result2 = arch_dir in old_path if result1 != result2: return result1 result1 = 'obj/' in new_path result2 = 'obj/' in old_path if result1 != result2: return result1 return False def start_and_wait_profiling(self): if self.record_subproc is None: self.start_profiling() self.wait_profiling() def wait_profiling(self): returncode = None try: returncode = self.record_subproc.wait() except KeyboardInterrupt: self.stop_profiling() self.record_subproc = None # Don't check return value of record_subproc. Because record_subproc also # receives Ctrl-C, and always returns non-zero. returncode = 0 log_debug('profiling result [%s]' % (returncode == 0)) if returncode != 0: log_exit('Failed to record profiling data.') def start_profiling(self): args = ['/data/local/tmp/simpleperf', 'record', self.config['record_options'], '-o', '/data/local/tmp/perf.data'] if self.config['app_package_name']: args += ['--app', self.config['app_package_name']] elif self.config['native_program']: args += ['-p', str(self.app_pid)] elif self.config['cmd']: args.append(self.config['cmd']) if self.has_symfs_on_device: args += ['--symfs', '/data/local/tmp/native_libs'] adb_args = [self.adb.adb_path, 'shell'] + args log_debug('run adb cmd: %s' % adb_args) self.record_subproc = subprocess.Popen(adb_args) def stop_profiling(self): """ Stop profiling by sending SIGINT to simpleperf, and wait until it exits to make sure perf.data is completely generated.""" has_killed = False while True: (result, _) = self.adb.run_and_return_output(['shell', 'pidof', 'simpleperf']) if not result: break if not has_killed: has_killed = True self.adb.run_and_return_output(['shell', 'pkill', '-l', '2', 'simpleperf']) time.sleep(1) def collect_profiling_data(self): self.adb.check_run_and_return_output(['pull', '/data/local/tmp/perf.data', self.config['perf_data_path']]) if self.config['collect_binaries']: config = copy.copy(self.config) config['binary_cache_dir'] = 'binary_cache' config['symfs_dirs'] = [] if self.config['native_lib_dir']: config['symfs_dirs'].append(self.config['native_lib_dir']) binary_cache_builder = BinaryCacheBuilder(config) binary_cache_builder.build_binary_cache() def run_in_app_dir(self, args, stdout_file=None, check_result=True, log_output=True): args = self.get_run_in_app_dir_args(args) if check_result: return self.adb.check_run_and_return_output(args, stdout_file, log_output=log_output) return self.adb.run_and_return_output(args, stdout_file, log_output=log_output) def get_run_in_app_dir_args(self, args): if not self.config['app_package_name']: return ['shell'] + args if self.is_root_device: return ['shell', 'cd /data/data/' + self.config['app_package_name'] + ' && ' + (' '.join(args))] return ['shell', 'run-as', self.config['app_package_name']] + args def main(): parser = argparse.ArgumentParser( description= """Profile an Android app or native program.""") parser.add_argument('-p', '--app', help= """Profile an Android app, given the package name. Like -p com.example.android.myapp.""") parser.add_argument('-np', '--native_program', help= """Profile a native program. The program should be running on the device. Like -np surfaceflinger.""") parser.add_argument('-cmd', help= """Run a cmd and profile it. Like -cmd "pm -l".""") parser.add_argument('-lib', '--native_lib_dir', help= """Path to find debug version of native shared libraries used in the app.""") parser.add_argument('-nc', '--skip_recompile', action='store_true', help= """When profiling an Android app, by default we recompile java bytecode to native instructions to profile java code. It takes some time. You can skip it if the code has been compiled or you don't need to profile java code.""") parser.add_argument('--apk', help= """When profiling an Android app, we need the apk file to recompile the app on Android version <= M.""") parser.add_argument('-a', '--activity', help= """When profiling an Android app, start an activity before profiling. It restarts the app if the app is already running.""") parser.add_argument('-t', '--test', help= """When profiling an Android app, start an instrumentation test before profiling. It restarts the app if the app is already running.""") parser.add_argument('--arch', help= """Select which arch the app is running on, possible values are: arm, arm64, x86, x86_64. If not set, the script will try to detect it.""") parser.add_argument('-r', '--record_options', default="-e cpu-cycles:u -g -f 1000 --duration 10", help= """Set options for `simpleperf record` command. Default is '-e cpu-cycles:u -g -f 1000 --duration 10'.""") parser.add_argument('-o', '--perf_data_path', default="perf.data", help= """The path to store profiling data.""") parser.add_argument('-nb', '--skip_collect_binaries', action='store_true', help= """By default we collect binaries used in profiling data from device to binary_cache directory. It can be used to annotate source code. This option skips it.""") parser.add_argument('--profile_from_launch', action='store_true', help= """Profile an activity from initial launch. It should be used with -p, -a, and --arch options. Normally we run in the following order: restart the app, detect the architecture of the app, download simpleperf and native libs with debug info on device, and start simpleperf record. But with --profile_from_launch option, we change the order as below: kill the app if it is already running, download simpleperf on device, start simpleperf record, and start the app.""") parser.add_argument('--disable_adb_root', action='store_true', help= """Force adb to run in non root mode.""") args = parser.parse_args() config = {} config['app_package_name'] = args.app config['native_program'] = args.native_program config['cmd'] = args.cmd config['native_lib_dir'] = args.native_lib_dir config['recompile_app'] = args.app and not args.skip_recompile config['apk_file_path'] = args.apk config['launch_activity'] = args.activity config['launch_inst_test'] = args.test config['app_arch'] = args.arch config['record_options'] = args.record_options config['perf_data_path'] = args.perf_data_path config['collect_binaries'] = not args.skip_collect_binaries config['profile_from_launch'] = args.profile_from_launch config['disable_adb_root'] = args.disable_adb_root profiler = AppProfiler(config) profiler.profile() if __name__ == '__main__': main()