• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
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: Record cpu profiling data of an android app or native program.
19
20    It downloads simpleperf on device, uses it to collect profiling data on the selected app,
21    and pulls profiling data and related binaries on host.
22"""
23
24import logging
25import os
26import os.path
27import re
28import subprocess
29import sys
30import time
31
32from simpleperf_utils import (
33    AdbHelper, BaseArgumentParser, bytes_to_str, extant_dir, get_script_dir, get_target_binary_path,
34    log_exit, ReadElf, remove, str_to_bytes)
35
36NATIVE_LIBS_DIR_ON_DEVICE = '/data/local/tmp/native_libs/'
37
38SHELL_PS_UID_PATTERN = re.compile(r'USER.*\nu(\d+)_.*')
39
40
41class HostElfEntry(object):
42    """Represent a native lib on host in NativeLibDownloader."""
43
44    def __init__(self, path, name, score):
45        self.path = path
46        self.name = name
47        self.score = score
48
49    def __repr__(self):
50        return self.__str__()
51
52    def __str__(self):
53        return '[path: %s, name %s, score %s]' % (self.path, self.name, self.score)
54
55
56class NativeLibDownloader(object):
57    """Download native libs on device.
58
59    1. Collect info of all native libs in the native_lib_dir on host.
60    2. Check the available native libs in /data/local/tmp/native_libs on device.
61    3. Sync native libs on device.
62    """
63
64    def __init__(self, ndk_path, device_arch, adb):
65        self.adb = adb
66        self.readelf = ReadElf(ndk_path)
67        self.device_arch = device_arch
68        self.need_archs = self._get_need_archs()
69        self.host_build_id_map = {}  # Map from build_id to HostElfEntry.
70        self.device_build_id_map = {}  # Map from build_id to relative_path on device.
71        # Map from filename to HostElfEntry for elf files without build id.
72        self.no_build_id_file_map = {}
73        self.name_count_map = {}  # Used to give a unique name for each library.
74        self.dir_on_device = NATIVE_LIBS_DIR_ON_DEVICE
75        self.build_id_list_file = 'build_id_list'
76
77    def _get_need_archs(self):
78        """Return the archs of binaries needed on device."""
79        if self.device_arch == 'arm64':
80            return ['arm', 'arm64']
81        if self.device_arch == 'arm':
82            return ['arm']
83        if self.device_arch == 'x86_64':
84            return ['x86', 'x86_64']
85        if self.device_arch == 'x86':
86            return ['x86']
87        return []
88
89    def collect_native_libs_on_host(self, native_lib_dir):
90        self.host_build_id_map.clear()
91        for root, _, files in os.walk(native_lib_dir):
92            for name in files:
93                if not name.endswith('.so'):
94                    continue
95                self.add_native_lib_on_host(os.path.join(root, name), name)
96
97    def add_native_lib_on_host(self, path, name):
98        arch = self.readelf.get_arch(path)
99        if arch not in self.need_archs:
100            return
101        sections = self.readelf.get_sections(path)
102        score = 0
103        if '.debug_info' in sections:
104            score = 3
105        elif '.gnu_debugdata' in sections:
106            score = 2
107        elif '.symtab' in sections:
108            score = 1
109        build_id = self.readelf.get_build_id(path)
110        if build_id:
111            entry = self.host_build_id_map.get(build_id)
112            if entry:
113                if entry.score < score:
114                    entry.path = path
115                    entry.score = score
116            else:
117                repeat_count = self.name_count_map.get(name, 0)
118                self.name_count_map[name] = repeat_count + 1
119                unique_name = name if repeat_count == 0 else name + '_' + str(repeat_count)
120                self.host_build_id_map[build_id] = HostElfEntry(path, unique_name, score)
121        else:
122            entry = self.no_build_id_file_map.get(name)
123            if entry:
124                if entry.score < score:
125                    entry.path = path
126                    entry.score = score
127            else:
128                self.no_build_id_file_map[name] = HostElfEntry(path, name, score)
129
130    def collect_native_libs_on_device(self):
131        self.device_build_id_map.clear()
132        self.adb.check_run(['shell', 'mkdir', '-p', self.dir_on_device])
133        if os.path.exists(self.build_id_list_file):
134            os.remove(self.build_id_list_file)
135        result, output = self.adb.run_and_return_output(['shell', 'ls', self.dir_on_device])
136        if not result:
137            return
138        file_set = set(output.strip().split())
139        if self.build_id_list_file not in file_set:
140            return
141        self.adb.run(['pull', self.dir_on_device + self.build_id_list_file])
142        if os.path.exists(self.build_id_list_file):
143            with open(self.build_id_list_file, 'rb') as fh:
144                for line in fh.readlines():
145                    line = bytes_to_str(line).strip()
146                    items = line.split('=')
147                    if len(items) == 2:
148                        build_id, filename = items
149                        if filename in file_set:
150                            self.device_build_id_map[build_id] = filename
151            remove(self.build_id_list_file)
152
153    def sync_native_libs_on_device(self):
154        # Push missing native libs on device.
155        for build_id in self.host_build_id_map:
156            if build_id not in self.device_build_id_map:
157                entry = self.host_build_id_map[build_id]
158                self.adb.check_run(['push', entry.path, self.dir_on_device + entry.name])
159        # Remove native libs not exist on host.
160        for build_id in self.device_build_id_map:
161            if build_id not in self.host_build_id_map:
162                name = self.device_build_id_map[build_id]
163                self.adb.run(['shell', 'rm', self.dir_on_device + name])
164        # Push new build_id_list on device.
165        with open(self.build_id_list_file, 'wb') as fh:
166            for build_id in self.host_build_id_map:
167                s = str_to_bytes('%s=%s\n' % (build_id, self.host_build_id_map[build_id].name))
168                fh.write(s)
169        self.adb.check_run(['push', self.build_id_list_file,
170                            self.dir_on_device + self.build_id_list_file])
171        os.remove(self.build_id_list_file)
172
173        # Push elf files without build id on device.
174        for entry in self.no_build_id_file_map.values():
175            target = self.dir_on_device + entry.name
176
177            # Skip download if we have a file with the same name and size on device.
178            result, output = self.adb.run_and_return_output(['shell', 'ls', '-l', target])
179            if result:
180                items = output.split()
181                if len(items) > 5:
182                    try:
183                        file_size = int(items[4])
184                    except ValueError:
185                        file_size = 0
186                    if file_size == os.path.getsize(entry.path):
187                        continue
188            self.adb.check_run(['push', entry.path, target])
189
190
191class ProfilerBase(object):
192    """Base class of all Profilers."""
193
194    def __init__(self, args):
195        self.args = args
196        self.adb = AdbHelper(enable_switch_to_root=not args.disable_adb_root)
197        self.is_root_device = self.adb.switch_to_root()
198        self.android_version = self.adb.get_android_version()
199        if self.android_version < 7:
200            log_exit("""app_profiler.py isn't supported on Android < N, please switch to use
201                        simpleperf binary directly.""")
202        self.device_arch = self.adb.get_device_arch()
203        self.record_subproc = None
204
205    def profile(self):
206        logging.info('prepare profiling')
207        self.prepare()
208        logging.info('start profiling')
209        self.start()
210        self.wait_profiling()
211        logging.info('collect profiling data')
212        self.collect_profiling_data()
213        logging.info('profiling is finished.')
214
215    def prepare(self):
216        """Prepare recording. """
217        self.download_simpleperf()
218        if self.args.native_lib_dir:
219            self.download_libs()
220
221    def download_simpleperf(self):
222        simpleperf_binary = get_target_binary_path(self.device_arch, 'simpleperf')
223        self.adb.check_run(['push', simpleperf_binary, '/data/local/tmp'])
224        self.adb.check_run(['shell', 'chmod', 'a+x', '/data/local/tmp/simpleperf'])
225
226    def download_libs(self):
227        downloader = NativeLibDownloader(self.args.ndk_path, self.device_arch, self.adb)
228        downloader.collect_native_libs_on_host(self.args.native_lib_dir)
229        downloader.collect_native_libs_on_device()
230        downloader.sync_native_libs_on_device()
231
232    def start(self):
233        raise NotImplementedError
234
235    def start_profiling(self, target_args):
236        """Start simpleperf record process on device."""
237        args = ['/data/local/tmp/simpleperf', 'record', '-o', '/data/local/tmp/perf.data',
238                self.args.record_options]
239        if self.adb.run(['shell', 'ls', NATIVE_LIBS_DIR_ON_DEVICE]):
240            args += ['--symfs', NATIVE_LIBS_DIR_ON_DEVICE]
241        args += ['--log', self.args.log]
242        args += target_args
243        adb_args = [self.adb.adb_path, 'shell'] + args
244        logging.info('run adb cmd: %s' % adb_args)
245        self.record_subproc = subprocess.Popen(adb_args)
246
247    def wait_profiling(self):
248        """Wait until profiling finishes, or stop profiling when user presses Ctrl-C."""
249        returncode = None
250        try:
251            returncode = self.record_subproc.wait()
252        except KeyboardInterrupt:
253            self.stop_profiling()
254            self.record_subproc = None
255            # Don't check return value of record_subproc. Because record_subproc also
256            # receives Ctrl-C, and always returns non-zero.
257            returncode = 0
258        logging.debug('profiling result [%s]' % (returncode == 0))
259        if returncode != 0:
260            log_exit('Failed to record profiling data.')
261
262    def stop_profiling(self):
263        """Stop profiling by sending SIGINT to simpleperf, and wait until it exits
264           to make sure perf.data is completely generated."""
265        has_killed = False
266        while True:
267            (result, _) = self.adb.run_and_return_output(['shell', 'pidof', 'simpleperf'])
268            if not result:
269                break
270            if not has_killed:
271                has_killed = True
272                self.adb.run_and_return_output(['shell', 'pkill', '-l', '2', 'simpleperf'])
273            time.sleep(1)
274
275    def collect_profiling_data(self):
276        self.adb.check_run_and_return_output(['pull', '/data/local/tmp/perf.data',
277                                              self.args.perf_data_path])
278        if not self.args.skip_collect_binaries:
279            binary_cache_args = [sys.executable,
280                                 os.path.join(get_script_dir(), 'binary_cache_builder.py')]
281            binary_cache_args += ['-i', self.args.perf_data_path, '--log', self.args.log]
282            if self.args.native_lib_dir:
283                binary_cache_args += ['-lib', self.args.native_lib_dir]
284            if self.args.disable_adb_root:
285                binary_cache_args += ['--disable_adb_root']
286            if self.args.ndk_path:
287                binary_cache_args += ['--ndk_path', self.args.ndk_path]
288            subprocess.check_call(binary_cache_args)
289
290
291class AppProfiler(ProfilerBase):
292    """Profile an Android app."""
293
294    def prepare(self):
295        super(AppProfiler, self).prepare()
296        if self.args.compile_java_code:
297            self.compile_java_code()
298
299    def compile_java_code(self):
300        self.kill_app_process()
301        # Fully compile Java code on Android >= N.
302        self.adb.set_property('debug.generate-debug-info', 'true')
303        self.adb.check_run(['shell', 'cmd', 'package', 'compile', '-f', '-m', 'speed',
304                            self.args.app])
305
306    def kill_app_process(self):
307        if self.find_app_process():
308            self.adb.check_run(['shell', 'am', 'force-stop', self.args.app])
309            count = 0
310            while True:
311                time.sleep(1)
312                pid = self.find_app_process()
313                if not pid:
314                    break
315                count += 1
316                if count >= 5:
317                    logging.info('unable to kill %s, skipping...' % self.args.app)
318                    break
319                # When testing on Android N, `am force-stop` sometimes can't kill
320                # com.example.simpleperf.simpleperfexampleofkotlin. So use kill when this happens.
321                if count >= 3:
322                    self.run_in_app_dir(['kill', '-9', str(pid)])
323
324    def find_app_process(self):
325        result, pidof_output = self.adb.run_and_return_output(
326            ['shell', 'pidof', self.args.app])
327        if not result:
328            return None
329        result, current_user = self.adb.run_and_return_output(
330            ['shell', 'am', 'get-current-user'])
331        if not result:
332            return None
333        pids = pidof_output.split()
334        for pid in pids:
335            result, ps_output = self.adb.run_and_return_output(
336                ['shell', 'ps', '-p', pid, '-o', 'USER'])
337            if not result:
338              return None
339            uid = SHELL_PS_UID_PATTERN.search(ps_output).group(1)
340            if uid == current_user.strip():
341              return int(pid)
342        return None
343
344    def run_in_app_dir(self, args):
345        if self.is_root_device:
346            adb_args = ['shell', 'cd /data/data/' + self.args.app + ' && ' + (' '.join(args))]
347        else:
348            adb_args = ['shell', 'run-as', self.args.app] + args
349        return self.adb.run_and_return_output(adb_args)
350
351    def start(self):
352        if self.args.activity or self.args.test:
353            self.kill_app_process()
354        self.start_profiling(['--app', self.args.app])
355        if self.args.activity:
356            self.start_activity()
357        elif self.args.test:
358            self.start_test()
359        # else: no need to start an activity or test.
360
361    def start_activity(self):
362        activity = self.args.app + '/' + self.args.activity
363        result = self.adb.run(['shell', 'am', 'start', '-n', activity])
364        if not result:
365            self.record_subproc.terminate()
366            log_exit("Can't start activity %s" % activity)
367
368    def start_test(self):
369        runner = self.args.app + '/androidx.test.runner.AndroidJUnitRunner'
370        result = self.adb.run(['shell', 'am', 'instrument', '-e', 'class',
371                               self.args.test, runner])
372        if not result:
373            self.record_subproc.terminate()
374            log_exit("Can't start instrumentation test  %s" % self.args.test)
375
376
377class NativeProgramProfiler(ProfilerBase):
378    """Profile a native program."""
379
380    def start(self):
381        logging.info('Waiting for native process %s' % self.args.native_program)
382        while True:
383            (result, pid) = self.adb.run_and_return_output(['shell', 'pidof',
384                                                            self.args.native_program])
385            if not result:
386                # Wait for 1 millisecond.
387                time.sleep(0.001)
388            else:
389                self.start_profiling(['-p', str(int(pid))])
390                break
391
392
393class NativeCommandProfiler(ProfilerBase):
394    """Profile running a native command."""
395
396    def start(self):
397        self.start_profiling([self.args.cmd])
398
399
400class NativeProcessProfiler(ProfilerBase):
401    """Profile processes given their pids."""
402
403    def start(self):
404        self.start_profiling(['-p', ','.join(self.args.pid)])
405
406
407class NativeThreadProfiler(ProfilerBase):
408    """Profile threads given their tids."""
409
410    def start(self):
411        self.start_profiling(['-t', ','.join(self.args.tid)])
412
413
414class SystemWideProfiler(ProfilerBase):
415    """Profile system wide."""
416
417    def start(self):
418        self.start_profiling(['-a'])
419
420
421def main():
422    parser = BaseArgumentParser(description=__doc__)
423
424    target_group = parser.add_argument_group(title='Select profiling target'
425                                             ).add_mutually_exclusive_group(required=True)
426    target_group.add_argument('-p', '--app', help="""Profile an Android app, given the package name.
427                              Like `-p com.example.android.myapp`.""")
428
429    target_group.add_argument('-np', '--native_program', help="""Profile a native program running on
430                              the Android device. Like `-np surfaceflinger`.""")
431
432    target_group.add_argument('-cmd', help="""Profile running a command on the Android device.
433                              Like `-cmd "pm -l"`.""")
434
435    target_group.add_argument('--pid', nargs='+', help="""Profile native processes running on device
436                              given their process ids.""")
437
438    target_group.add_argument('--tid', nargs='+', help="""Profile native threads running on device
439                              given their thread ids.""")
440
441    target_group.add_argument('--system_wide', action='store_true', help="""Profile system wide.""")
442
443    app_target_group = parser.add_argument_group(title='Extra options for profiling an app')
444    app_target_group.add_argument('--compile_java_code', action='store_true', help="""Used with -p.
445                                  On Android N and Android O, we need to compile Java code into
446                                  native instructions to profile Java code. Android O also needs
447                                  wrap.sh in the apk to use the native instructions.""")
448
449    app_start_group = app_target_group.add_mutually_exclusive_group()
450    app_start_group.add_argument('-a', '--activity', help="""Used with -p. Profile the launch time
451                                 of an activity in an Android app. The app will be started or
452                                 restarted to run the activity. Like `-a .MainActivity`.""")
453
454    app_start_group.add_argument('-t', '--test', help="""Used with -p. Profile the launch time of an
455                                 instrumentation test in an Android app. The app will be started or
456                                 restarted to run the instrumentation test. Like
457                                 `-t test_class_name`.""")
458
459    record_group = parser.add_argument_group('Select recording options')
460    record_group.add_argument('-r', '--record_options',
461                              default='-e task-clock:u -f 1000 -g --duration 10', help="""Set
462                              recording options for `simpleperf record` command. Use
463                              `run_simpleperf_on_device.py record -h` to see all accepted options.
464                              Default is "-e task-clock:u -f 1000 -g --duration 10".""")
465
466    record_group.add_argument('-lib', '--native_lib_dir', type=extant_dir,
467                              help="""When profiling an Android app containing native libraries,
468                                      the native libraries are usually stripped and lake of symbols
469                                      and debug information to provide good profiling result. By
470                                      using -lib, you tell app_profiler.py the path storing
471                                      unstripped native libraries, and app_profiler.py will search
472                                      all shared libraries with suffix .so in the directory. Then
473                                      the native libraries will be downloaded on device and
474                                      collected in build_cache.""")
475
476    record_group.add_argument('-o', '--perf_data_path', default='perf.data',
477                              help='The path to store profiling data. Default is perf.data.')
478
479    record_group.add_argument('-nb', '--skip_collect_binaries', action='store_true',
480                              help="""By default we collect binaries used in profiling data from
481                                      device to binary_cache directory. It can be used to annotate
482                                      source code and disassembly. This option skips it.""")
483
484    other_group = parser.add_argument_group('Other options')
485    other_group.add_argument('--ndk_path', type=extant_dir,
486                             help="""Set the path of a ndk release. app_profiler.py needs some
487                                     tools in ndk, like readelf.""")
488
489    other_group.add_argument('--disable_adb_root', action='store_true',
490                             help="""Force adb to run in non root mode. By default, app_profiler.py
491                                     will try to switch to root mode to be able to profile released
492                                     Android apps.""")
493
494    def check_args(args):
495        if (not args.app) and (args.compile_java_code or args.activity or args.test):
496            log_exit('--compile_java_code, -a, -t can only be used when profiling an Android app.')
497
498    args = parser.parse_args()
499    check_args(args)
500    if args.app:
501        profiler = AppProfiler(args)
502    elif args.native_program:
503        profiler = NativeProgramProfiler(args)
504    elif args.cmd:
505        profiler = NativeCommandProfiler(args)
506    elif args.pid:
507        profiler = NativeProcessProfiler(args)
508    elif args.tid:
509        profiler = NativeThreadProfiler(args)
510    elif args.system_wide:
511        profiler = SystemWideProfiler(args)
512    profiler.profile()
513
514
515if __name__ == '__main__':
516    main()
517