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()