| #!/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: Record cpu profiling data of an android app or native program. |
| |
| It downloads simpleperf on device, uses it to collect profiling data on the selected app, |
| and pulls profiling data and related binaries on host. |
| """ |
| |
| from __future__ import print_function |
| import argparse |
| import os |
| import os.path |
| import subprocess |
| import sys |
| import time |
| |
| from utils import get_script_dir, log_debug, log_info, log_exit, get_target_binary_path, extant_dir |
| from utils import AdbHelper, ReadElf, remove |
| |
| NATIVE_LIBS_DIR_ON_DEVICE = '/data/local/tmp/native_libs/' |
| |
| class HostElfEntry(object): |
| """Represent a native lib on host in NativeLibDownloader.""" |
| def __init__(self, path, name, score): |
| self.path = path |
| self.name = name |
| self.score = score |
| |
| def __repr__(self): |
| return self.__str__() |
| |
| def __str__(self): |
| return '[path: %s, name %s, score %s]' % (self.path, self.name, self.score) |
| |
| |
| class NativeLibDownloader(object): |
| """Download native libs on device. |
| |
| 1. Collect info of all native libs in the native_lib_dir on host. |
| 2. Check the available native libs in /data/local/tmp/native_libs on device. |
| 3. Sync native libs on device. |
| """ |
| def __init__(self, ndk_path, device_arch, adb): |
| self.adb = adb |
| self.readelf = ReadElf(ndk_path) |
| self.device_arch = device_arch |
| self.need_archs = self._get_need_archs() |
| self.host_build_id_map = {} # Map from build_id to HostElfEntry. |
| self.device_build_id_map = {} # Map from build_id to relative_path on device. |
| self.name_count_map = {} # Used to give a unique name for each library. |
| self.dir_on_device = NATIVE_LIBS_DIR_ON_DEVICE |
| self.build_id_list_file = 'build_id_list' |
| |
| def _get_need_archs(self): |
| """Return the archs of binaries needed on device.""" |
| if self.device_arch == 'arm64': |
| return ['arm', 'arm64'] |
| if self.device_arch == 'arm': |
| return ['arm'] |
| if self.device_arch == 'x86_64': |
| return ['x86', 'x86_64'] |
| if self.device_arch == 'x86': |
| return ['x86'] |
| return [] |
| |
| def collect_native_libs_on_host(self, native_lib_dir): |
| self.host_build_id_map.clear() |
| for root, _, files in os.walk(native_lib_dir): |
| for name in files: |
| if not name.endswith('.so'): |
| continue |
| self.add_native_lib_on_host(os.path.join(root, name), name) |
| |
| def add_native_lib_on_host(self, path, name): |
| build_id = self.readelf.get_build_id(path) |
| if not build_id: |
| return |
| arch = self.readelf.get_arch(path) |
| if arch not in self.need_archs: |
| return |
| sections = self.readelf.get_sections(path) |
| score = 0 |
| if '.debug_info' in sections: |
| score = 3 |
| elif '.gnu_debugdata' in sections: |
| score = 2 |
| elif '.symtab' in sections: |
| score = 1 |
| entry = self.host_build_id_map.get(build_id) |
| if entry: |
| if entry.score < score: |
| entry.path = path |
| entry.score = score |
| else: |
| repeat_count = self.name_count_map.get(name, 0) |
| self.name_count_map[name] = repeat_count + 1 |
| unique_name = name if repeat_count == 0 else name + '_' + str(repeat_count) |
| self.host_build_id_map[build_id] = HostElfEntry(path, unique_name, score) |
| |
| def collect_native_libs_on_device(self): |
| self.device_build_id_map.clear() |
| self.adb.check_run(['shell', 'mkdir', '-p', self.dir_on_device]) |
| if os.path.exists(self.build_id_list_file): |
| os.remove(self.build_id_list_file) |
| self.adb.run(['pull', self.dir_on_device + self.build_id_list_file]) |
| if os.path.exists(self.build_id_list_file): |
| with open(self.build_id_list_file, 'rb') as fh: |
| for line in fh.readlines(): |
| line = line.strip() |
| items = line.split('=') |
| if len(items) == 2: |
| self.device_build_id_map[items[0]] = items[1] |
| remove(self.build_id_list_file) |
| |
| def sync_natives_libs_on_device(self): |
| # Push missing native libs on device. |
| for build_id in self.host_build_id_map: |
| if build_id not in self.device_build_id_map: |
| entry = self.host_build_id_map[build_id] |
| self.adb.check_run(['push', entry.path, self.dir_on_device + entry.name]) |
| # Remove native libs not exist on host. |
| for build_id in self.device_build_id_map: |
| if build_id not in self.host_build_id_map: |
| name = self.device_build_id_map[build_id] |
| self.adb.run(['shell', 'rm', self.dir_on_device + name]) |
| # Push new build_id_list on device. |
| with open(self.build_id_list_file, 'wb') as fh: |
| for build_id in self.host_build_id_map: |
| fh.write('%s=%s\n' % (build_id, self.host_build_id_map[build_id].name)) |
| self.adb.check_run(['push', self.build_id_list_file, |
| self.dir_on_device + self.build_id_list_file]) |
| os.remove(self.build_id_list_file) |
| |
| |
| class ProfilerBase(object): |
| """Base class of all Profilers.""" |
| def __init__(self, args): |
| self.args = args |
| self.adb = AdbHelper(enable_switch_to_root=not args.disable_adb_root) |
| self.is_root_device = self.adb.switch_to_root() |
| self.android_version = self.adb.get_android_version() |
| if self.android_version < 7: |
| log_exit("""app_profiler.py isn't supported on Android < N, please switch to use |
| simpleperf binary directly.""") |
| self.device_arch = self.adb.get_device_arch() |
| self.record_subproc = None |
| |
| def profile(self): |
| log_info('prepare profiling') |
| self.prepare() |
| log_info('start profiling') |
| self.start() |
| self.wait_profiling() |
| log_info('collect profiling data') |
| self.collect_profiling_data() |
| log_info('profiling is finished.') |
| |
| def prepare(self): |
| """Prepare recording. """ |
| self.download_simpleperf() |
| if self.args.native_lib_dir: |
| self.download_libs() |
| |
| def download_simpleperf(self): |
| simpleperf_binary = get_target_binary_path(self.device_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_libs(self): |
| downloader = NativeLibDownloader(self.args.ndk_path, self.device_arch, self.adb) |
| downloader.collect_native_libs_on_host(self.args.native_lib_dir) |
| downloader.collect_native_libs_on_device() |
| downloader.sync_natives_libs_on_device() |
| |
| def start(self): |
| raise NotImplementedError |
| |
| def start_profiling(self, target_args): |
| """Start simpleperf reocrd process on device.""" |
| args = ['/data/local/tmp/simpleperf', 'record', '-o', '/data/local/tmp/perf.data', |
| self.args.record_options] |
| if self.adb.run(['shell', 'ls', NATIVE_LIBS_DIR_ON_DEVICE]): |
| args += ['--symfs', NATIVE_LIBS_DIR_ON_DEVICE] |
| args += target_args |
| adb_args = [self.adb.adb_path, 'shell'] + args |
| log_debug('run adb cmd: %s' % adb_args) |
| self.record_subproc = subprocess.Popen(adb_args) |
| |
| def wait_profiling(self): |
| """Wait until profiling finishes, or stop profiling when user presses Ctrl-C.""" |
| 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 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.args.perf_data_path]) |
| if not self.args.skip_collect_binaries: |
| binary_cache_args = [sys.executable, |
| os.path.join(get_script_dir(), 'binary_cache_builder.py')] |
| binary_cache_args += ['-i', self.args.perf_data_path] |
| if self.args.native_lib_dir: |
| binary_cache_args += ['-lib', self.args.native_lib_dir] |
| if self.args.disable_adb_root: |
| binary_cache_args += ['--disable_adb_root'] |
| if self.args.ndk_path: |
| binary_cache_args += ['--ndk-path', self.args.ndk_path] |
| subprocess.check_call(binary_cache_args) |
| |
| |
| class AppProfiler(ProfilerBase): |
| """Profile an Android app.""" |
| def prepare(self): |
| super(AppProfiler, self).prepare() |
| if self.args.compile_java_code: |
| self.compile_java_code() |
| |
| def compile_java_code(self): |
| self.kill_app_process() |
| # Fully compile Java code on Android >= N. |
| self.adb.set_property('debug.generate-debug-info', 'true') |
| self.adb.check_run(['shell', 'cmd', 'package', 'compile', '-f', '-m', 'speed', |
| self.args.app]) |
| |
| def kill_app_process(self): |
| if self.find_app_process(): |
| self.adb.check_run(['shell', 'am', 'force-stop', self.args.app]) |
| count = 0 |
| while True: |
| time.sleep(1) |
| pid = self.find_app_process() |
| if not pid: |
| 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)]) |
| |
| def find_app_process(self): |
| result, output = self.adb.run_and_return_output(['shell', 'pidof', self.args.app]) |
| return int(output) if result else None |
| |
| def run_in_app_dir(self, args): |
| if self.is_root_device: |
| adb_args = ['shell', 'cd /data/data/' + self.args.app + ' && ' + (' '.join(args))] |
| else: |
| adb_args = ['shell', 'run-as', self.args.app] + args |
| return self.adb.run_and_return_output(adb_args, log_output=False) |
| |
| def start(self): |
| if self.args.activity or self.args.test: |
| self.kill_app_process() |
| self.start_profiling(['--app', self.args.app]) |
| if self.args.activity: |
| self.start_activity() |
| elif self.args.test: |
| self.start_test() |
| # else: no need to start an activity or test. |
| |
| def start_activity(self): |
| activity = self.args.app + '/' + self.args.activity |
| result = self.adb.run(['shell', 'am', 'start', '-n', activity]) |
| if not result: |
| self.record_subproc.terminate() |
| log_exit("Can't start activity %s" % activity) |
| |
| def start_test(self): |
| runner = self.args.app + '/android.support.test.runner.AndroidJUnitRunner' |
| result = self.adb.run(['shell', 'am', 'instrument', '-e', 'class', |
| self.args.test, runner]) |
| if not result: |
| self.record_subproc.terminate() |
| log_exit("Can't start instrumentation test %s" % self.args.test) |
| |
| |
| class NativeProgramProfiler(ProfilerBase): |
| """Profile a native program.""" |
| def start(self): |
| pid = int(self.adb.check_run_and_return_output(['shell', 'pidof', |
| self.args.native_program])) |
| self.start_profiling(['-p', str(pid)]) |
| |
| |
| class NativeCommandProfiler(ProfilerBase): |
| """Profile running a native command.""" |
| def start(self): |
| self.start_profiling([self.args.cmd]) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| |
| target_group = parser.add_argument_group('Select profiling target') |
| target_type_group = target_group.add_mutually_exclusive_group(required=True) |
| target_type_group.add_argument('-p', '--app', |
| help="""Profile an Android app, given the package name. |
| Like `-p com.example.android.myapp`.""") |
| |
| target_type_group.add_argument('-np', '--native_program', |
| help="""Profile a native program running on the Android device. |
| Like `-np surfaceflinger`.""") |
| |
| target_type_group.add_argument('-cmd', |
| help="""Profile running a command on the Android device. Like |
| `-cmd "pm -l"`.""") |
| |
| target_group.add_argument('--compile_java_code', action='store_true', |
| help="""Used with -p. On Android N and Android O, we need to compile |
| Java code into native instructions to profile Java code. |
| Android O also needs wrap.sh in the apk to use the native |
| instructions.""") |
| |
| target_start_group = target_group.add_mutually_exclusive_group(required=False) |
| target_start_group.add_argument('-a', '--activity', |
| help="""Used with -p. Profile the launch time of an activity in |
| an Android app. The app will be started or restarted to |
| run the activity. Like `-a .MainActivity`.""") |
| |
| target_start_group.add_argument('-t', '--test', |
| help="""Used with -p. Profile the launch time of an |
| instrumentation test in an Android app. The app will be |
| started or restarted to run the instrumentation test. |
| Like `-t test_class_name`.""") |
| |
| record_group = parser.add_argument_group('Select recording options') |
| record_group.add_argument('-r', '--record_options', |
| default='-e task-clock:u -f 1000 -g --duration 10', |
| help="""Set recording options for `simpleperf record` command. |
| Default is "-e task-clock:u -f 1000 -g --duration 10".""") |
| |
| record_group.add_argument('-lib', '--native_lib_dir', type=extant_dir, |
| help="""When profiling an Android app containing native libraries, |
| the native libraries are usually stripped and lake of symbols |
| and debug information to provide good profiling result. By |
| using -lib, you tell app_profiler.py the path storing |
| unstripped native libraries, and app_profiler.py will search |
| all shared libraries with suffix .so in the directory. Then |
| the native libraries will be downloaded on device and |
| collected in build_cache.""") |
| |
| record_group.add_argument('-o', '--perf_data_path', default='perf.data', |
| help='The path to store profiling data. Default is perf.data.') |
| |
| record_group.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 and disassembly. This option skips it.""") |
| |
| other_group = parser.add_argument_group('Other options') |
| other_group.add_argument('--ndk_path', type=extant_dir, |
| help="""Set the path of a ndk release. app_profiler.py needs some |
| tools in ndk, like readelf.""") |
| |
| other_group.add_argument('--disable_adb_root', action='store_true', |
| help="""Force adb to run in non root mode. By default, app_profiler.py |
| will try to switch to root mode to be able to profile released |
| Android apps.""") |
| |
| def check_args(args): |
| if (not args.app) and (args.compile_java_code or args.activity or args.test): |
| log_exit('--compile_java_code, -a, -t can only be used when profiling an Android app.') |
| |
| args = parser.parse_args() |
| check_args(args) |
| if args.app: |
| profiler = AppProfiler(args) |
| elif args.native_program: |
| profiler = NativeProgramProfiler(args) |
| elif args.cmd: |
| profiler = NativeCommandProfiler(args) |
| profiler.profile() |
| |
| if __name__ == '__main__': |
| main() |