1#!/usr/bin/env python
2#
3# Copyright 2019 The Chromium Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7"""A simple tool to run simpleperf to get sampling-based perf traces.
8
9Typical Usage:
10  android_webview/tools/run_simpleperf.py \
11    --report-path report.html \
12    --output-directory out/Debug/
13"""
14
15import argparse
16import cgi
17import logging
18import os
19import re
20import subprocess
21import sys
22
23sys.path.append(os.path.join(
24    os.path.dirname(__file__), os.pardir, os.pardir, 'build', 'android'))
25import devil_chromium
26from devil.android import apk_helper
27from devil.android import device_errors
28from devil.android.ndk import abis
29from devil.android.tools import script_common
30from devil.utils import logging_common
31from py_utils import tempfile_ext
32
33_SUPPORTED_ARCH_DICT = {
34    abis.ARM: 'arm',
35    abis.ARM_64: 'arm64',
36    abis.X86: 'x86',
37    # Note: x86_64 isn't tested yet.
38}
39
40
41class StackAddressInterpreter(object):
42  """A class to interpret addresses in simpleperf using stack script."""
43  def __init__(self, args, tmp_dir):
44    self.args = args
45    self.tmp_dir = tmp_dir
46
47  @staticmethod
48  def RunStackScript(output_dir, stack_input_path):
49    """Run the stack script.
50
51    Args:
52      output_dir: The directory of Chromium output.
53      stack_input_path: The path to the stack input file.
54
55    Returns:
56      The output of running the stack script (stack.py).
57    """
58    # Note that stack script is not designed to be used in a stand-alone way.
59    # Therefore, it is better off to call it as a command line.
60    # TODO(changwan): consider using llvm symbolizer directly.
61    cmd = ['third_party/android_platform/development/scripts/stack',
62           '--output-directory', output_dir,
63           stack_input_path]
64    return subprocess.check_output(cmd).splitlines()
65
66  @staticmethod
67  def _ConvertAddressToFakeTraceLine(address, lib_path):
68    formatted_address = '0x' + '0' * (16 - len(address)) + address
69    # Pretend that this is Chromium's stack traces output in logcat.
70    # Note that the date, time, pid, tid, frame number, and frame address
71    # are all fake and they are irrelevant.
72    return ('11-15 00:00:00.000 11111 11111 '
73            'E chromium: #00 0x0000001111111111 %s+%s') % (
74                lib_path, formatted_address)
75
76  def Interpret(self, addresses, lib_path):
77    """Interpret the given addresses.
78
79    Args:
80      addresses: A collection of addresses.
81      lib_path: The path to the WebView library.
82
83    Returns:
84      A list of (address, function_info) where function_info is the function
85      name, plus file name and line if args.show_file_line is set.
86    """
87    stack_input_path = os.path.join(self.tmp_dir, 'stack_input.txt')
88    with open(stack_input_path, 'w') as f:
89      for address in addresses:
90        f.write(StackAddressInterpreter._ConvertAddressToFakeTraceLine(
91            address, lib_path) + '\n')
92
93    stack_output = StackAddressInterpreter.RunStackScript(
94        self.args.output_directory, stack_input_path)
95
96    if self.args.debug:
97      logging.debug('First 10 lines of stack output:')
98      for i in range(max(10, len(stack_output))):
99        logging.debug(stack_output[i])
100
101    logging.info('We got the results from the stack script. Translating the '
102                 'addresses...')
103
104    address_function_pairs = []
105    pattern = re.compile(r'  0*(?P<address>[1-9a-f][0-9a-f]+)  (?P<function>.*)'
106                         r'  (?P<file_name_line>.*)')
107    for line in stack_output:
108      m = pattern.match(line)
109      if m:
110        function_info = m.group('function')
111        if self.args.show_file_line:
112          function_info += " | " + m.group('file_name_line')
113
114        address_function_pairs.append((m.group('address'), function_info))
115
116    logging.info('The translation is done.')
117    return address_function_pairs
118
119
120class SimplePerfRunner(object):
121  """A runner for simpleperf and its postprocessing."""
122
123  def __init__(self, device, args, tmp_dir, address_interpreter):
124    self.device = device
125    self.address_interpreter = address_interpreter
126    self.args = args
127    self.apk_helper = None
128    self.tmp_dir = tmp_dir
129
130  def _GetFormattedArch(self):
131    arch = _SUPPORTED_ARCH_DICT.get(
132        self.device.product_cpu_abi)
133    if not arch:
134      raise Exception('Your device arch (' +
135                      self.device.product_cpu_abi + ') is not supported.')
136    logging.info('Guessing arch=%s because product.cpu.abi=%s', arch,
137                 self.device.product_cpu_abi)
138    return arch
139
140  def GetWebViewLibraryNameAndPath(self, package_name):
141    """Get WebView library name and path on the device."""
142    apk_path = self._GetWebViewApkPath(package_name)
143    logging.debug('WebView APK path:' + apk_path)
144    # TODO(changwan): check if we need support for bundle.
145    tmp_apk_path = os.path.join(self.tmp_dir, 'base.apk')
146    self.device.adb.Pull(apk_path, tmp_apk_path)
147    self.apk_helper = apk_helper.ToHelper(tmp_apk_path)
148    metadata = self.apk_helper.GetAllMetadata()
149    lib_name = None
150    for key, value in metadata:
151      if key == 'com.android.webview.WebViewLibrary':
152        lib_name = value
153
154    lib_path = os.path.join(apk_path, 'lib', self._GetFormattedArch(), lib_name)
155    logging.debug("WebView's library path on the device should be:" + lib_path)
156    return lib_name, lib_path
157
158  def Run(self):
159    """Run the simpleperf and do the post processing."""
160    package_name = self.GetCurrentWebViewProvider()
161    SimplePerfRunner.RunPackageCompile(package_name)
162    perf_data_path = os.path.join(self.tmp_dir, 'perf.data')
163    SimplePerfRunner.RunSimplePerf(perf_data_path, self.args)
164    lines = SimplePerfRunner.GetOriginalReportHtml(
165        perf_data_path,
166        os.path.join(self.tmp_dir, 'unprocessed_report.html'))
167    lib_name, lib_path = self.GetWebViewLibraryNameAndPath(package_name)
168    addresses = SimplePerfRunner.CollectAddresses(lines, lib_name)
169    logging.info("Extracted %d addresses", len(addresses))
170    address_function_pairs = self.address_interpreter.Interpret(
171        addresses, lib_path)
172
173    lines = SimplePerfRunner.ReplaceAddressesWithFunctionInfos(
174        lines, address_function_pairs, lib_name)
175
176    with open(self.args.report_path, 'w') as f:
177      for line in lines:
178        f.write(line + '\n')
179
180    logging.info("The final report has been generated at '%s'.",
181                 self.args.report_path)
182
183  @staticmethod
184  def RunSimplePerf(perf_data_path, args):
185    """Runs the simple perf commandline."""
186    cmd = ['third_party/android_ndk/simpleperf/app_profiler.py',
187           '--perf_data_path', perf_data_path,
188           '--skip_collect_binaries']
189    if args.system_wide:
190      cmd.append('--system_wide')
191    else:
192      cmd.extend([
193          '--app', 'org.chromium.webview_shell', '--activity',
194          '.TelemetryActivity'
195      ])
196
197    if args.record_options:
198      cmd.extend(['--record_options', args.record_options])
199
200    logging.info("Profile has started.")
201    subprocess.check_call(cmd)
202    logging.info("Profile has finished, processing the results...")
203
204  @staticmethod
205  def RunPackageCompile(package_name):
206    """Compile the package (dex optimization)."""
207    cmd = [
208        'adb', 'shell', 'cmd', 'package', 'compile', '-m', 'speed', '-f',
209        package_name
210    ]
211    subprocess.check_call(cmd)
212
213  def GetCurrentWebViewProvider(self):
214    return self.device.GetWebViewUpdateServiceDump()['CurrentWebViewPackage']
215
216  def _GetWebViewApkPath(self, package_name):
217    return self.device.GetApplicationPaths(package_name)[0]
218
219  @staticmethod
220  def GetOriginalReportHtml(perf_data_path, report_html_path):
221    """Gets the original report.html from running simpleperf."""
222    cmd = ['third_party/android_ndk/simpleperf/report_html.py',
223           '--record_file', perf_data_path,
224           '--report_path', report_html_path,
225           '--no_browser']
226    subprocess.check_call(cmd)
227    lines = []
228    with open(report_html_path, 'r') as f:
229      lines = f.readlines()
230    return lines
231
232  @staticmethod
233  def CollectAddresses(lines, lib_name):
234    """Collect address-looking texts from lines.
235
236    Args:
237      lines: A list of strings that may contain addresses.
238      lib_name: The name of the WebView library.
239
240    Returns:
241      A set containing the addresses that were found in the lines.
242    """
243    addresses = set()
244    for line in lines:
245      for address in re.findall(lib_name + r'\[\+([0-9a-f]+)\]', line):
246        addresses.add(address)
247    return addresses
248
249  @staticmethod
250  def ReplaceAddressesWithFunctionInfos(lines, address_function_pairs,
251                                        lib_name):
252    """Replaces the addresses with function names.
253
254    Args:
255      lines: A list of strings that may contain addresses.
256      address_function_pairs: A list of pairs of (address, function_name).
257      lib_name: The name of the WebView library.
258
259    Returns:
260      A list of strings with addresses replaced by function names.
261    """
262
263    logging.info('Replacing the HTML content with new function names...')
264
265    # Note: Using a lenient pattern matching and a hashmap (dict) is much faster
266    # than using a double loop (by the order of 1,000).
267    # '+address' will be replaced by function name.
268    address_function_dict = {
269        '+' + k: cgi.escape(v)
270        for k, v in address_function_pairs
271    }
272    # Look behind the lib_name and '[' which will not be substituted. Note that
273    # '+' is used in the pattern but will be removed.
274    pattern = re.compile(r'(?<=' + lib_name + r'\[)\+([a-f0-9]+)(?=\])')
275
276    def replace_fn(match):
277      address = match.group(0)
278      if address in address_function_dict:
279        return address_function_dict[address]
280      else:
281        return address
282
283    # Line-by-line assignment to avoid creating a temp list.
284    for i, line in enumerate(lines):
285      lines[i] = pattern.sub(replace_fn, line)
286
287    logging.info('Replacing is done.')
288    return lines
289
290
291def main(raw_args):
292  parser = argparse.ArgumentParser()
293  parser.add_argument('--debug', action='store_true',
294                      help='Get additional debugging mode')
295  parser.add_argument(
296      '--output-directory',
297      help='the path to the build output directory, such as out/Debug')
298  parser.add_argument('--report-path',
299                      default='report.html', help='Report path')
300  parser.add_argument('--adb-path',
301                      help='Absolute path to the adb binary to use.')
302  parser.add_argument('--record-options',
303                      help=('Set recording options for app_profiler.py command.'
304                            ' Example: "-e task-clock:u -f 1000 -g --duration'
305                            ' 10" where -f means sampling frequency per second.'
306                            ' Try `app_profiler.py record -h` for more '
307                            ' information. Note that not setting this defaults'
308                            ' to the default record options.'))
309  parser.add_argument('--show-file-line', action='store_true',
310                      help='Show file name and lines in the result.')
311  parser.add_argument(
312      '--system-wide',
313      action='store_true',
314      help=('Whether to profile system wide (without launching'
315            'an app).'))
316
317  script_common.AddDeviceArguments(parser)
318  logging_common.AddLoggingArguments(parser)
319
320  args = parser.parse_args(raw_args)
321  logging_common.InitializeLogging(args)
322  devil_chromium.Initialize(adb_path=args.adb_path)
323
324  devices = script_common.GetDevices(args.devices, args.denylist_file)
325  device = devices[0]
326
327  if len(devices) > 1:
328    raise device_errors.MultipleDevicesError(devices)
329
330  with tempfile_ext.NamedTemporaryDirectory(
331      prefix='tmp_simpleperf') as tmp_dir:
332    runner = SimplePerfRunner(
333        device, args, tmp_dir,
334        StackAddressInterpreter(args, tmp_dir))
335    runner.Run()
336
337
338if __name__ == '__main__':
339  main(sys.argv[1:])
340