| #!/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() |