1#!/usr/bin/env python 2# 3# Copyright (C) 2016 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18"""app_profiler.py: manage the process of profiling an android app. 19 It downloads simpleperf on device, uses it to collect samples from 20 user's app, and pulls perf.data and needed binaries on host. 21""" 22 23from __future__ import print_function 24import argparse 25import copy 26import os 27import os.path 28import shutil 29import subprocess 30import sys 31import time 32 33from binary_cache_builder import BinaryCacheBuilder 34from simpleperf_report_lib import * 35from utils import * 36 37class AppProfiler(object): 38 """Used to manage the process of profiling an android app. 39 40 There are three steps: 41 1. Prepare profiling. 42 2. Profile the app. 43 3. Collect profiling data. 44 """ 45 def __init__(self, config): 46 self.check_config(config) 47 self.config = config 48 self.adb = AdbHelper() 49 self.is_root_device = False 50 self.android_version = 0 51 self.device_arch = None 52 self.app_arch = None 53 self.app_pid = None 54 55 56 def check_config(self, config): 57 config_names = ['app_package_name', 'native_lib_dir', 'apk_file_path', 58 'recompile_app', 'launch_activity', 'launch_inst_test', 59 'record_options', 'perf_data_path'] 60 for name in config_names: 61 if name not in config: 62 log_exit('config [%s] is missing' % name) 63 if not config['app_package_name']: 64 log_exit("The package name of the application hasn't been set") 65 native_lib_dir = config.get('native_lib_dir') 66 if native_lib_dir and not os.path.isdir(native_lib_dir): 67 log_exit('[native_lib_dir] "%s" is not a dir' % native_lib_dir) 68 apk_file_path = config.get('apk_file_path') 69 if apk_file_path and not os.path.isfile(apk_file_path): 70 log_exit('[apk_file_path] "%s" is not a file' % apk_file_path) 71 if config['recompile_app']: 72 if not config['launch_activity'] and not config['launch_inst_test']: 73 log_exit('one of launch_activity and launch_inst_test is needed for recompile app') 74 75 76 def profile(self): 77 log_info('prepare profiling') 78 self.prepare_profiling() 79 log_info('start profiling') 80 self.start_and_wait_profiling() 81 log_info('collect profiling data') 82 self.collect_profiling_data() 83 log_info('profiling is finished.') 84 85 86 def prepare_profiling(self): 87 self._get_device_environment() 88 self._enable_profiling() 89 self._recompile_app() 90 self._restart_app() 91 self._get_app_environment() 92 self._download_simpleperf() 93 self._download_native_libs() 94 95 96 def _get_device_environment(self): 97 self.is_root_device = self.adb.switch_to_root() 98 99 # Get android version. 100 build_version = self.adb.get_property('ro.build.version.release') 101 if build_version: 102 if not build_version[0].isdigit(): 103 c = build_version[0].upper() 104 if c < 'L': 105 self.android_version = 0 106 else: 107 self.android_version = ord(c) - ord('L') + 5 108 else: 109 strs = build_version.split('.') 110 if strs: 111 self.android_version = int(strs[0]) 112 113 # Get device architecture. 114 output = self.adb.check_run_and_return_output(['shell', 'uname', '-m']) 115 if output.find('aarch64') != -1: 116 self.device_arch = 'aarch64' 117 elif output.find('arm') != -1: 118 self.device_arch = 'arm' 119 elif output.find('x86_64') != -1: 120 self.device_arch = 'x86_64' 121 elif output.find('86') != -1: 122 self.device_arch = 'x86' 123 else: 124 log_fatal('unsupported architecture: %s' % output.strip()) 125 126 127 def _enable_profiling(self): 128 self.adb.set_property('security.perf_harden', '0') 129 if self.is_root_device: 130 # We can enable kernel symbols 131 self.adb.run(['shell', 'echo 0 >/proc/sys/kernel/kptr_restrict']) 132 133 134 def _recompile_app(self): 135 if not self.config['recompile_app']: 136 return 137 if self.android_version == 0: 138 log_warning("Can't fully compile an app on android version < L.") 139 elif self.android_version == 5 or self.android_version == 6: 140 if not self.is_root_device: 141 log_warning("Can't fully compile an app on android version < N on non-root devices.") 142 elif not self.config['apk_file_path']: 143 log_warning("apk file is needed to reinstall the app on android version < N.") 144 else: 145 flag = '-g' if self.android_version == 6 else '--include-debug-symbols' 146 self.adb.set_property('dalvik.vm.dex2oat-flags', flag) 147 self.adb.check_run(['install', '-r', self.config['apk_file_path']]) 148 elif self.android_version >= 7: 149 self.adb.set_property('debug.generate-debug-info', 'true') 150 self.adb.check_run(['shell', 'cmd', 'package', 'compile', '-f', '-m', 'speed', 151 self.config['app_package_name']]) 152 else: 153 log_fatal('unreachable') 154 155 156 def _restart_app(self): 157 if not self.config['launch_activity'] and not self.config['launch_inst_test']: 158 return 159 160 pid = self._find_app_process() 161 if pid is not None: 162 self.run_in_app_dir(['kill', '-9', str(pid)]) 163 time.sleep(1) 164 165 if self.config['launch_activity']: 166 activity = self.config['app_package_name'] + '/' + self.config['launch_activity'] 167 result = self.adb.run(['shell', 'am', 'start', '-n', activity]) 168 if not result: 169 log_exit("Can't start activity %s" % activity) 170 else: 171 runner = self.config['app_package_name'] + '/android.support.test.runner.AndroidJUnitRunner' 172 result = self.adb.run(['shell', 'am', 'instrument', '-e', 'class', 173 self.config['launch_inst_test'], runner]) 174 if not result: 175 log_exit("Can't start instrumentation test %s" % self.config['launch_inst_test']) 176 177 for i in range(10): 178 pid = self._find_app_process() 179 if pid is not None: 180 return 181 time.sleep(1) 182 log_info('Wait for the app process for %d seconds' % (i + 1)) 183 log_exit("Can't find the app process") 184 185 186 def _find_app_process(self): 187 ps_args = ['-e'] if self.android_version >= 8 else [] 188 result, output = self.adb.run_and_return_output(['shell', 'ps'] + ps_args) 189 if not result: 190 return None 191 output = output.split('\n') 192 for line in output: 193 strs = line.split() 194 if len(strs) > 2 and strs[-1].find(self.config['app_package_name']) != -1: 195 return int(strs[1]) 196 return None 197 198 199 def _get_app_environment(self): 200 self.app_pid = self._find_app_process() 201 if self.app_pid is None: 202 log_exit("can't find process for app [%s]" % self.config['app_package_name']) 203 if self.device_arch in ['aarch64', 'x86_64']: 204 output = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid]) 205 if output.find('linker64') != -1: 206 self.app_arch = self.device_arch 207 else: 208 self.app_arch = 'arm' if self.device_arch == 'aarch64' else 'x86' 209 else: 210 self.app_arch = self.device_arch 211 log_info('app_arch: %s' % self.app_arch) 212 213 214 def _download_simpleperf(self): 215 simpleperf_binary = get_target_binary_path(self.app_arch, 'simpleperf') 216 self.adb.check_run(['push', simpleperf_binary, '/data/local/tmp']) 217 self.run_in_app_dir(['cp', '/data/local/tmp/simpleperf', '.']) 218 self.run_in_app_dir(['chmod', 'a+x', 'simpleperf']) 219 220 221 def _download_native_libs(self): 222 if not self.config['native_lib_dir']: 223 return 224 filename_dict = dict() 225 for root, _, files in os.walk(self.config['native_lib_dir']): 226 for file in files: 227 if not file.endswith('.so'): 228 continue 229 path = os.path.join(root, file) 230 old_path = filename_dict.get(file) 231 log_info('app_arch = %s' % self.app_arch) 232 if self._is_lib_better(path, old_path): 233 log_info('%s is better than %s' % (path, old_path)) 234 filename_dict[file] = path 235 else: 236 log_info('%s is worse than %s' % (path, old_path)) 237 maps = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid]) 238 searched_lib = dict() 239 for item in maps.split(): 240 if item.endswith('.so') and searched_lib.get(item) is None: 241 searched_lib[item] = True 242 # Use '/' as path separator as item comes from android environment. 243 filename = item[item.rfind('/') + 1:] 244 dirname = item[1:item.rfind('/')] 245 path = filename_dict.get(filename) 246 if path is None: 247 continue 248 self.adb.check_run(['push', path, '/data/local/tmp']) 249 self.run_in_app_dir(['mkdir', '-p', dirname]) 250 self.run_in_app_dir(['cp', '/data/local/tmp/' + filename, dirname]) 251 252 253 def _is_lib_better(self, new_path, old_path): 254 """ Return true if new_path is more likely to be used on device. """ 255 if old_path is None: 256 return True 257 if self.app_arch == 'arm': 258 result1 = new_path.find('armeabi-v7a/') != -1 259 result2 = old_path.find('armeabi-v7a') != -1 260 if result1 != result2: 261 return result1 262 arch_dir = 'arm64' if self.app_arch == 'aarch64' else self.app_arch + '/' 263 result1 = new_path.find(arch_dir) != -1 264 result2 = old_path.find(arch_dir) != -1 265 if result1 != result2: 266 return result1 267 result1 = new_path.find('obj/') != -1 268 result2 = old_path.find('obj/') != -1 269 if result1 != result2: 270 return result1 271 return False 272 273 274 def start_and_wait_profiling(self): 275 subproc = None 276 returncode = None 277 try: 278 args = self.get_run_in_app_dir_args([ 279 './simpleperf', 'record', self.config['record_options'], '-p', 280 str(self.app_pid), '--symfs', '.']) 281 adb_args = [self.adb.adb_path] + args 282 log_debug('run adb cmd: %s' % adb_args) 283 subproc = subprocess.Popen(adb_args) 284 returncode = subproc.wait() 285 except KeyboardInterrupt: 286 if subproc: 287 self.stop_profiling() 288 returncode = 0 289 log_debug('run adb cmd: %s [result %s]' % (adb_args, returncode == 0)) 290 291 292 def stop_profiling(self): 293 """ Stop profiling by sending SIGINT to simpleperf, and wait until it exits 294 to make sure perf.data is completely generated.""" 295 has_killed = False 296 while True: 297 (result, _) = self.run_in_app_dir(['pidof', 'simpleperf'], check_result=False) 298 if not result: 299 break 300 if not has_killed: 301 has_killed = True 302 self.run_in_app_dir(['pkill', '-l', '2', 'simpleperf'], check_result=False) 303 time.sleep(1) 304 305 306 def collect_profiling_data(self): 307 self.run_in_app_dir(['cat perf.data | tee /data/local/tmp/perf.data >/dev/null']) 308 self.adb.check_run_and_return_output(['pull', '/data/local/tmp/perf.data', 309 self.config['perf_data_path']]) 310 if self.config['collect_binaries']: 311 config = copy.copy(self.config) 312 config['binary_cache_dir'] = 'binary_cache' 313 config['symfs_dirs'] = [] 314 if self.config['native_lib_dir']: 315 config['symfs_dirs'].append(self.config['native_lib_dir']) 316 binary_cache_builder = BinaryCacheBuilder(config) 317 binary_cache_builder.build_binary_cache() 318 319 320 def run_in_app_dir(self, args, stdout_file=None, check_result=True): 321 args = self.get_run_in_app_dir_args(args) 322 if check_result: 323 return self.adb.check_run_and_return_output(args, stdout_file) 324 else: 325 return self.adb.run_and_return_output(args, stdout_file) 326 327 328 def get_run_in_app_dir_args(self, args): 329 if self.is_root_device: 330 return ['shell', 'cd /data/data/' + self.config['app_package_name'] + ' && ' + 331 (' '.join(args))] 332 else: 333 return ['shell', 'run-as', self.config['app_package_name']] + args 334 335def main(): 336 parser = argparse.ArgumentParser( 337 description= 338"""Profile an android app. See configurations in app_profiler.config.""") 339 parser.add_argument('--config', default='app_profiler.config', help= 340"""Set configuration file. Default is app_profiler.config. The configurations 341can be overridden by options in cmdline.""") 342 parser.add_argument('-p', '--package_name', help= 343"""The package name of the profiled Android app.""") 344 parser.add_argument('-lib', '--native_lib_dir', help= 345"""Path to find debug version of native shared libraries used in the app.""") 346 parser.add_argument('-nc', '--skip_recompile', action='store_true', help= 347"""By default we recompile java bytecode to native instructions to profile java 348code. It takes some time. You can skip it if the code has been compiled or you 349don't need to profile java code.""") 350 parser.add_argument('--apk', help= 351"""Apk file of the profiled app, used on Android version <= M, which needs to 352reinstall the app to recompile it.""") 353 parser.add_argument('-a', '--activity', help= 354"""Start an activity before profiling. It can be used to profile the startup 355time of an activity. Default is .MainActivity.""") 356 parser.add_argument('-t', '--test', help= 357"""Start an instrumentation test before profiling. It can be used to profile 358an instrumentation test.""") 359 parser.add_argument('-r', '--record_options', help= 360"""Set options for `simpleperf record` command. Default is "-e cpu-cycles:u -f 4000 -g --duration 10".""") 361 parser.add_argument('-o', '--perf_data_path', help= 362"""The path to store profiling data. Default is perf.data.""") 363 parser.add_argument('-nb', '--skip_collect_binaries', action='store_true', help= 364"""By default we collect binaries used in profiling data from device to 365binary_cache directory. It can be used to annotate source code. This option skips it.""") 366 args = parser.parse_args() 367 config = load_config(args.config) 368 if args.package_name: 369 config['app_package_name'] = args.package_name 370 if args.native_lib_dir: 371 config['native_lib_dir'] = args.native_lib_dir 372 if args.skip_recompile: 373 config['recompile_app'] = False 374 if args.apk: 375 config['apk'] = args.apk 376 if args.activity: 377 config['launch_activity'] = args.activity 378 config['launch_inst_test'] = None 379 if args.test: 380 config['launch_inst_test'] = args.test 381 config['launch_activity'] = None 382 if args.record_options: 383 config['record_options'] = args.record_options 384 if args.perf_data_path: 385 config['perf_data_path'] = args.perf_data_path 386 if args.skip_collect_binaries: 387 config['collect_binaries'] = False 388 389 profiler = AppProfiler(config) 390 profiler.profile() 391 392if __name__ == '__main__': 393 main()