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