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 re 29import shutil 30import subprocess 31import sys 32import time 33 34from binary_cache_builder import BinaryCacheBuilder 35from simpleperf_report_lib import * 36from utils import * 37 38class AppProfiler(object): 39 """Used to manage the process of profiling an android app. 40 41 There are three steps: 42 1. Prepare profiling. 43 2. Profile the app. 44 3. Collect profiling data. 45 """ 46 def __init__(self, config): 47 self.check_config(config) 48 self.config = config 49 self.adb = AdbHelper(enable_switch_to_root=not config['disable_adb_root']) 50 self.is_root_device = False 51 self.android_version = 0 52 self.device_arch = None 53 self.app_arch = self.config['app_arch'] 54 self.app_program = self.config['app_package_name'] or self.config['native_program'] 55 self.app_pid = None 56 self.has_symfs_on_device = False 57 self.record_subproc = None 58 59 60 def check_config(self, config): 61 config_names = ['app_package_name', 'native_program', 'cmd', 'native_lib_dir', 62 'apk_file_path', 'recompile_app', 'launch_activity', 'launch_inst_test', 63 'record_options', 'perf_data_path', 'profile_from_launch', 'app_arch'] 64 for name in config_names: 65 if name not in config: 66 log_exit('config [%s] is missing' % name) 67 if config['app_package_name'] and config['native_program']: 68 log_exit("We can't profile an Android app and a native program at the same time.") 69 elif config['app_package_name'] and config['cmd']: 70 log_exit("We can't profile an Android app and a cmd at the same time.") 71 elif config['native_program'] and config['cmd']: 72 log_exit("We can't profile a native program and a cmd at the same time.") 73 elif not config['app_package_name'] and not config['native_program'] and not config["cmd"]: 74 log_exit("Please set a profiling target: an Android app, a native program or a cmd.") 75 if config['app_package_name']: 76 if config['launch_activity'] and config['launch_inst_test']: 77 log_exit("We can't launch an activity and a test at the same time.") 78 native_lib_dir = config.get('native_lib_dir') 79 if native_lib_dir and not os.path.isdir(native_lib_dir): 80 log_exit('[native_lib_dir] "%s" is not a dir' % native_lib_dir) 81 apk_file_path = config.get('apk_file_path') 82 if apk_file_path and not os.path.isfile(apk_file_path): 83 log_exit('[apk_file_path] "%s" is not a file' % apk_file_path) 84 if config['recompile_app']: 85 if not config['launch_activity'] and not config['launch_inst_test']: 86 # If recompile app, the app needs to be restarted to take effect. 87 config['launch_activity'] = '.MainActivity' 88 if config['profile_from_launch']: 89 if not config['app_package_name']: 90 log_exit('-p needs to be set to profile from launch.') 91 if not config['launch_activity']: 92 log_exit('-a needs to be set to profile from launch.') 93 if not config['app_arch']: 94 log_exit('--arch needs to be set to profile from launch.') 95 96 97 def profile(self): 98 log_info('prepare profiling') 99 self.prepare_profiling() 100 log_info('start profiling') 101 self.start_and_wait_profiling() 102 log_info('collect profiling data') 103 self.collect_profiling_data() 104 log_info('profiling is finished.') 105 106 107 def prepare_profiling(self): 108 self._get_device_environment() 109 self._enable_profiling() 110 self._recompile_app() 111 self._restart_app() 112 self._get_app_environment() 113 if not self.config['profile_from_launch']: 114 self._download_simpleperf() 115 self._download_native_libs() 116 117 118 def _get_device_environment(self): 119 self.is_root_device = self.adb.switch_to_root() 120 self.android_version = self.adb.get_android_version() 121 if self.android_version < 7: 122 log_warning("app_profiler.py is not tested prior Android N, please switch to use cmdline interface.") 123 self.device_arch = self.adb.get_device_arch() 124 125 126 def _enable_profiling(self): 127 self.adb.set_property('security.perf_harden', '0') 128 if self.is_root_device: 129 # We can enable kernel symbols 130 self.adb.run(['shell', 'echo 0 >/proc/sys/kernel/kptr_restrict']) 131 132 133 def _recompile_app(self): 134 if not self.config['recompile_app']: 135 return 136 if self.android_version == 0: 137 log_warning("Can't fully compile an app on android version < L.") 138 elif self.android_version == 5 or self.android_version == 6: 139 if not self.is_root_device: 140 log_warning("Can't fully compile an app on android version < N on non-root devices.") 141 elif not self.config['apk_file_path']: 142 log_warning("apk file is needed to reinstall the app on android version < N.") 143 else: 144 flag = '-g' if self.android_version == 6 else '--include-debug-symbols' 145 self.adb.set_property('dalvik.vm.dex2oat-flags', flag) 146 self.adb.check_run(['install', '-r', self.config['apk_file_path']]) 147 elif self.android_version >= 7: 148 self.adb.set_property('debug.generate-debug-info', 'true') 149 self.adb.check_run(['shell', 'cmd', 'package', 'compile', '-f', '-m', 'speed', 150 self.config['app_package_name']]) 151 else: 152 log_fatal('unreachable') 153 154 155 def _restart_app(self): 156 if not self.config['app_package_name']: 157 return 158 if not self.config['launch_activity'] and not self.config['launch_inst_test']: 159 self.app_pid = self._find_app_process() 160 if self.app_pid is not None: 161 return 162 else: 163 self.config['launch_activity'] = '.MainActivity' 164 165 self.adb.check_run(['shell', 'am', 'force-stop', self.config['app_package_name']]) 166 count = 0 167 while True: 168 time.sleep(1) 169 pid = self._find_app_process() 170 if pid is None: 171 break 172 # When testing on Android N, `am force-stop` sometimes can't kill 173 # com.example.simpleperf.simpleperfexampleofkotlin. So use kill when this happens. 174 count += 1 175 if count >= 3: 176 self.run_in_app_dir(['kill', '-9', str(pid)], check_result=False, log_output=False) 177 178 if self.config['profile_from_launch']: 179 self._download_simpleperf() 180 self.start_profiling() 181 182 if self.config['launch_activity']: 183 activity = self.config['app_package_name'] + '/' + self.config['launch_activity'] 184 result = self.adb.run(['shell', 'am', 'start', '-n', activity]) 185 if not result: 186 log_exit("Can't start activity %s" % activity) 187 else: 188 runner = self.config['app_package_name'] + '/android.support.test.runner.AndroidJUnitRunner' 189 result = self.adb.run(['shell', 'am', 'instrument', '-e', 'class', 190 self.config['launch_inst_test'], runner]) 191 if not result: 192 log_exit("Can't start instrumentation test %s" % self.config['launch_inst_test']) 193 194 for i in range(10): 195 self.app_pid = self._find_app_process() 196 if self.app_pid is not None: 197 return 198 time.sleep(1) 199 log_info('Wait for the app process for %d seconds' % (i + 1)) 200 log_exit("Can't find the app process") 201 202 203 def _find_app_process(self): 204 if not self.config['app_package_name'] and self.android_version >= 7: 205 result, output = self.adb.run_and_return_output(['shell', 'pidof', self.app_program]) 206 return int(output) if result else None 207 ps_args = ['ps', '-e', '-o', 'PID,NAME'] if self.android_version >= 8 else ['ps'] 208 result, output = self.adb.run_and_return_output(['shell'] + ps_args, log_output=False) 209 if not result: 210 return None 211 for line in output.split('\n'): 212 strs = line.split() 213 if len(strs) < 2: 214 continue 215 process_name = strs[-1] 216 if self.config['app_package_name']: 217 # This is to match process names in multiprocess apps. 218 process_name = process_name.split(':')[0] 219 if process_name == self.app_program: 220 pid = int(strs[0] if self.android_version >= 8 else strs[1]) 221 # If a debuggable app with wrap.sh runs on Android O, the app will be started with 222 # logwrapper as below: 223 # 1. Zygote forks a child process, rename it to package_name. 224 # 2. The child process execute sh, which starts a child process running 225 # /system/bin/logwrapper. 226 # 3. logwrapper starts a child process running sh, which interprets wrap.sh. 227 # 4. wrap.sh starts a child process running the app. 228 # The problem here is we want to profile the process started in step 4, but 229 # sometimes we run into the process started in step 1. To solve it, we can check 230 # if the process has opened an apk file in some app dirs. 231 if self.android_version >= 8 and self.config['app_package_name'] and ( 232 not self._has_opened_apk_file(pid)): 233 continue 234 return pid 235 return None 236 237 238 def _has_opened_apk_file(self, pid): 239 result, output = self.run_in_app_dir(['ls -l /proc/%d/fd' % pid], 240 check_result=False, log_output=False) 241 return result and re.search(r'app.*\.apk', output) 242 243 244 def _get_app_environment(self): 245 if not self.config['cmd']: 246 if self.app_pid is None: 247 self.app_pid = self._find_app_process() 248 if self.app_pid is None: 249 log_exit("can't find process for app [%s]" % self.app_program) 250 if not self.app_arch: 251 if not self.config['cmd'] and self.device_arch in ['arm64', 'x86_64']: 252 output = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid], log_output=False) 253 if 'linker64' in output: 254 self.app_arch = self.device_arch 255 else: 256 self.app_arch = 'arm' if self.device_arch == 'arm64' else 'x86' 257 else: 258 self.app_arch = self.device_arch 259 log_info('app_arch: %s' % self.app_arch) 260 261 262 def _download_simpleperf(self): 263 simpleperf_binary = get_target_binary_path(self.app_arch, 'simpleperf') 264 self.adb.check_run(['push', simpleperf_binary, '/data/local/tmp']) 265 self.adb.check_run(['shell', 'chmod', 'a+x', '/data/local/tmp/simpleperf']) 266 267 268 def _download_native_libs(self): 269 if not self.config['native_lib_dir'] or not self.config['app_package_name']: 270 return 271 filename_dict = dict() 272 for root, _, files in os.walk(self.config['native_lib_dir']): 273 for file in files: 274 if not file.endswith('.so'): 275 continue 276 path = os.path.join(root, file) 277 old_path = filename_dict.get(file) 278 log_info('app_arch = %s' % self.app_arch) 279 if self._is_lib_better(path, old_path): 280 log_info('%s is better than %s' % (path, old_path)) 281 filename_dict[file] = path 282 else: 283 log_info('%s is worse than %s' % (path, old_path)) 284 maps = self.run_in_app_dir(['cat', '/proc/%d/maps' % self.app_pid], log_output=False) 285 searched_lib = dict() 286 for item in maps.split(): 287 if item.endswith('.so') and searched_lib.get(item) is None: 288 searched_lib[item] = True 289 # Use '/' as path separator as item comes from android environment. 290 filename = item[item.rfind('/') + 1:] 291 dirname = '/data/local/tmp/native_libs' + item[:item.rfind('/')] 292 path = filename_dict.get(filename) 293 if path is None: 294 continue 295 self.adb.check_run(['shell', 'mkdir', '-p', dirname]) 296 self.adb.check_run(['push', path, dirname]) 297 self.has_symfs_on_device = True 298 299 300 def _is_lib_better(self, new_path, old_path): 301 """ Return true if new_path is more likely to be used on device. """ 302 if old_path is None: 303 return True 304 if self.app_arch == 'arm': 305 result1 = 'armeabi-v7a/' in new_path 306 result2 = 'armeabi-v7a' in old_path 307 if result1 != result2: 308 return result1 309 arch_dir = self.app_arch + '/' 310 result1 = arch_dir in new_path 311 result2 = arch_dir in old_path 312 if result1 != result2: 313 return result1 314 result1 = 'obj/' in new_path 315 result2 = 'obj/' in old_path 316 if result1 != result2: 317 return result1 318 return False 319 320 321 def start_and_wait_profiling(self): 322 if self.record_subproc is None: 323 self.start_profiling() 324 self.wait_profiling() 325 326 327 def wait_profiling(self): 328 returncode = None 329 try: 330 returncode = self.record_subproc.wait() 331 except KeyboardInterrupt: 332 self.stop_profiling() 333 self.record_subproc = None 334 # Don't check return value of record_subproc. Because record_subproc also 335 # receives Ctrl-C, and always returns non-zero. 336 returncode = 0 337 log_debug('profiling result [%s]' % (returncode == 0)) 338 if returncode != 0: 339 log_exit('Failed to record profiling data.') 340 341 342 def start_profiling(self): 343 args = ['/data/local/tmp/simpleperf', 'record', self.config['record_options'], 344 '-o', '/data/local/tmp/perf.data'] 345 if self.config['app_package_name']: 346 args += ['--app', self.config['app_package_name']] 347 elif self.config['native_program']: 348 args += ['-p', str(self.app_pid)] 349 elif self.config['cmd']: 350 args.append(self.config['cmd']) 351 if self.has_symfs_on_device: 352 args += ['--symfs', '/data/local/tmp/native_libs'] 353 adb_args = [self.adb.adb_path, 'shell'] + args 354 log_debug('run adb cmd: %s' % adb_args) 355 self.record_subproc = subprocess.Popen(adb_args) 356 357 358 def stop_profiling(self): 359 """ Stop profiling by sending SIGINT to simpleperf, and wait until it exits 360 to make sure perf.data is completely generated.""" 361 has_killed = False 362 while True: 363 (result, _) = self.adb.run_and_return_output(['shell', 'pidof', 'simpleperf']) 364 if not result: 365 break 366 if not has_killed: 367 has_killed = True 368 self.adb.run_and_return_output(['shell', 'pkill', '-l', '2', 'simpleperf']) 369 time.sleep(1) 370 371 372 def collect_profiling_data(self): 373 self.adb.check_run_and_return_output(['pull', '/data/local/tmp/perf.data', 374 self.config['perf_data_path']]) 375 if self.config['collect_binaries']: 376 config = copy.copy(self.config) 377 config['binary_cache_dir'] = 'binary_cache' 378 config['symfs_dirs'] = [] 379 if self.config['native_lib_dir']: 380 config['symfs_dirs'].append(self.config['native_lib_dir']) 381 binary_cache_builder = BinaryCacheBuilder(config) 382 binary_cache_builder.build_binary_cache() 383 384 385 def run_in_app_dir(self, args, stdout_file=None, check_result=True, log_output=True): 386 args = self.get_run_in_app_dir_args(args) 387 if check_result: 388 return self.adb.check_run_and_return_output(args, stdout_file, log_output=log_output) 389 return self.adb.run_and_return_output(args, stdout_file, log_output=log_output) 390 391 392 def get_run_in_app_dir_args(self, args): 393 if not self.config['app_package_name']: 394 return ['shell'] + args 395 if self.is_root_device: 396 return ['shell', 'cd /data/data/' + self.config['app_package_name'] + ' && ' + 397 (' '.join(args))] 398 return ['shell', 'run-as', self.config['app_package_name']] + args 399 400def main(): 401 parser = argparse.ArgumentParser( 402 description= 403"""Profile an Android app or native program.""") 404 parser.add_argument('-p', '--app', help= 405"""Profile an Android app, given the package name. Like -p com.example.android.myapp.""") 406 parser.add_argument('-np', '--native_program', help= 407"""Profile a native program. The program should be running on the device. 408Like -np surfaceflinger.""") 409 parser.add_argument('-cmd', help= 410"""Run a cmd and profile it. Like -cmd "pm -l".""") 411 parser.add_argument('-lib', '--native_lib_dir', help= 412"""Path to find debug version of native shared libraries used in the app.""") 413 parser.add_argument('-nc', '--skip_recompile', action='store_true', help= 414"""When profiling an Android app, by default we recompile java bytecode to native instructions 415to profile java code. It takes some time. You can skip it if the code has been compiled or you 416don't need to profile java code.""") 417 parser.add_argument('--apk', help= 418"""When profiling an Android app, we need the apk file to recompile the app on 419Android version <= M.""") 420 parser.add_argument('-a', '--activity', help= 421"""When profiling an Android app, start an activity before profiling. 422It restarts the app if the app is already running.""") 423 parser.add_argument('-t', '--test', help= 424"""When profiling an Android app, start an instrumentation test before profiling. 425It restarts the app if the app is already running.""") 426 parser.add_argument('--arch', help= 427"""Select which arch the app is running on, possible values are: 428arm, arm64, x86, x86_64. If not set, the script will try to detect it.""") 429 parser.add_argument('-r', '--record_options', 430 default='-e task-clock:u -g -f 1000 --duration 10', help=""" 431 Set options for `simpleperf record` command. 432 Default is "-e task-clock:u -g -f 1000 --duration 10".""") 433 parser.add_argument('-o', '--perf_data_path', default="perf.data", help= 434"""The path to store profiling data.""") 435 parser.add_argument('-nb', '--skip_collect_binaries', action='store_true', help= 436"""By default we collect binaries used in profiling data from device to 437binary_cache directory. It can be used to annotate source code. This option skips it.""") 438 parser.add_argument('--profile_from_launch', action='store_true', help= 439"""Profile an activity from initial launch. It should be used with -p, -a, and --arch options. 440Normally we run in the following order: restart the app, detect the architecture of the app, 441download simpleperf and native libs with debug info on device, and start simpleperf record. 442But with --profile_from_launch option, we change the order as below: kill the app if it is 443already running, download simpleperf on device, start simpleperf record, and start the app.""") 444 parser.add_argument('--disable_adb_root', action='store_true', help= 445"""Force adb to run in non root mode.""") 446 args = parser.parse_args() 447 config = {} 448 config['app_package_name'] = args.app 449 config['native_program'] = args.native_program 450 config['cmd'] = args.cmd 451 config['native_lib_dir'] = args.native_lib_dir 452 config['recompile_app'] = args.app and not args.skip_recompile 453 config['apk_file_path'] = args.apk 454 455 config['launch_activity'] = args.activity 456 config['launch_inst_test'] = args.test 457 458 config['app_arch'] = args.arch 459 config['record_options'] = args.record_options 460 config['perf_data_path'] = args.perf_data_path 461 config['collect_binaries'] = not args.skip_collect_binaries 462 config['profile_from_launch'] = args.profile_from_launch 463 config['disable_adb_root'] = args.disable_adb_root 464 465 profiler = AppProfiler(config) 466 profiler.profile() 467 468if __name__ == '__main__': 469 main() 470