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"""
19    Inferno is a tool to generate flamegraphs for android programs. It was originally written
20    to profile surfaceflinger (Android compositor) but it can be used for other C++ program.
21    It uses simpleperf to collect data. Programs have to be compiled with frame pointers which
22    excludes ART based programs for the time being.
23
24    Here is how it works:
25
26    1/ Data collection is started via simpleperf and pulled locally as "perf.data".
27    2/ The raw format is parsed, callstacks are merged to form a flamegraph data structure.
28    3/ The data structure is used to generate a SVG embedded into an HTML page.
29    4/ Javascript is injected to allow flamegraph navigation, search, coloring model.
30
31"""
32
33import argparse
34import datetime
35import os
36import subprocess
37import sys
38
39# fmt: off
40# pylint: disable=wrong-import-position
41SCRIPTS_PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
42sys.path.append(SCRIPTS_PATH)
43from simpleperf_report_lib import ReportLib
44from simpleperf_utils import log_exit, log_fatal, log_info, AdbHelper, open_report_in_browser
45
46from data_types import Process
47from svg_renderer import get_proper_scaled_time_string, render_svg
48# fmt: on
49
50
51def collect_data(args):
52    """ Run app_profiler.py to generate record file. """
53    app_profiler_args = [sys.executable, os.path.join(SCRIPTS_PATH, "app_profiler.py"), "-nb"]
54    if args.app:
55        app_profiler_args += ["-p", args.app]
56    elif args.native_program:
57        app_profiler_args += ["-np", args.native_program]
58    elif args.pid != -1:
59        app_profiler_args += ['--pid', str(args.pid)]
60    elif args.system_wide:
61        app_profiler_args += ['--system_wide']
62    else:
63        log_exit("Please set profiling target with -p, -np, --pid or --system_wide option.")
64    if args.compile_java_code:
65        app_profiler_args.append("--compile_java_code")
66    if args.disable_adb_root:
67        app_profiler_args.append("--disable_adb_root")
68    record_arg_str = ""
69    if args.dwarf_unwinding:
70        record_arg_str += "-g "
71    else:
72        record_arg_str += "--call-graph fp "
73    if args.events:
74        tokens = args.events.split()
75        if len(tokens) == 2:
76            num_events = tokens[0]
77            event_name = tokens[1]
78            record_arg_str += "-c %s -e %s " % (num_events, event_name)
79        else:
80            log_exit("Event format string of -e option cann't be recognized.")
81        log_info("Using event sampling (-c %s -e %s)." % (num_events, event_name))
82    else:
83        record_arg_str += "-f %d " % args.sample_frequency
84        log_info("Using frequency sampling (-f %d)." % args.sample_frequency)
85    record_arg_str += "--duration %d " % args.capture_duration
86    app_profiler_args += ["-r", record_arg_str]
87    returncode = subprocess.call(app_profiler_args)
88    return returncode == 0
89
90
91def parse_samples(process, args, sample_filter_fn):
92    """Read samples from record file.
93        process: Process object
94        args: arguments
95        sample_filter_fn: if not None, is used to modify and filter samples.
96                          It returns false for samples should be filtered out.
97    """
98
99    record_file = args.record_file
100    symfs_dir = args.symfs
101    kallsyms_file = args.kallsyms
102
103    lib = ReportLib()
104
105    lib.ShowIpForUnknownSymbol()
106    if symfs_dir:
107        lib.SetSymfs(symfs_dir)
108    if record_file:
109        lib.SetRecordFile(record_file)
110    if kallsyms_file:
111        lib.SetKallsymsFile(kallsyms_file)
112    if args.show_art_frames:
113        lib.ShowArtFrames(True)
114    for file_path in args.proguard_mapping_file or []:
115        lib.AddProguardMappingFile(file_path)
116    process.cmd = lib.GetRecordCmd()
117    product_props = lib.MetaInfo().get("product_props")
118    if product_props:
119        manufacturer, model, name = product_props.split(':')
120        process.props['ro.product.manufacturer'] = manufacturer
121        process.props['ro.product.model'] = model
122        process.props['ro.product.name'] = name
123    if lib.MetaInfo().get('trace_offcpu') == 'true':
124        process.props['trace_offcpu'] = True
125        if args.one_flamegraph:
126            log_exit("It doesn't make sense to report with --one-flamegraph for perf.data " +
127                     "recorded with --trace-offcpu.""")
128    else:
129        process.props['trace_offcpu'] = False
130
131    while True:
132        sample = lib.GetNextSample()
133        if sample is None:
134            lib.Close()
135            break
136        symbol = lib.GetSymbolOfCurrentSample()
137        callchain = lib.GetCallChainOfCurrentSample()
138        if sample_filter_fn and not sample_filter_fn(sample, symbol, callchain):
139            continue
140        process.add_sample(sample, symbol, callchain)
141
142    if process.pid == 0:
143        main_threads = [thread for thread in process.threads.values() if thread.tid == thread.pid]
144        if main_threads:
145            process.name = main_threads[0].name
146            process.pid = main_threads[0].pid
147
148    for thread in process.threads.values():
149        min_event_count = thread.num_events * args.min_callchain_percentage * 0.01
150        thread.flamegraph.trim_callchain(min_event_count, args.max_callchain_depth)
151
152    log_info("Parsed %s callchains." % process.num_samples)
153
154
155def get_local_asset_content(local_path):
156    """
157    Retrieves local package text content
158    :param local_path: str, filename of local asset
159    :return: str, the content of local_path
160    """
161    with open(os.path.join(os.path.dirname(__file__), local_path), 'r') as f:
162        return f.read()
163
164
165def output_report(process, args):
166    """
167    Generates a HTML report representing the result of simpleperf sampling as flamegraph
168    :param process: Process object
169    :return: str, absolute path to the file
170    """
171    f = open(args.report_path, 'w')
172    filepath = os.path.realpath(f.name)
173    if not args.embedded_flamegraph:
174        f.write("<html><body>")
175    f.write("<div id='flamegraph_id' style='font-family: Monospace; %s'>" % (
176        "display: none;" if args.embedded_flamegraph else ""))
177    f.write("""<style type="text/css"> .s { stroke:black; stroke-width:0.5; cursor:pointer;}
178            </style>""")
179    f.write('<style type="text/css"> .t:hover { cursor:pointer; } </style>')
180    f.write('<img height="180" alt = "Embedded Image" src ="data')
181    f.write(get_local_asset_content("inferno.b64"))
182    f.write('"/>')
183    process_entry = ("Process : %s (%d)<br/>" % (process.name, process.pid)) if process.pid else ""
184    thread_entry = '' if args.one_flamegraph else ('Threads: %d<br/>' % len(process.threads))
185    if process.props['trace_offcpu']:
186        event_entry = 'Total time: %s<br/>' % get_proper_scaled_time_string(process.num_events)
187    else:
188        event_entry = 'Event count: %s<br/>' % ("{:,}".format(process.num_events))
189    # TODO: collect capture duration info from perf.data.
190    duration_entry = ("Duration: %s seconds<br/>" % args.capture_duration
191                      ) if args.capture_duration else ""
192    f.write("""<div style='display:inline-block;'>
193                  <font size='8'>
194                  Inferno Flamegraph Report%s</font><br/><br/>
195                  %s
196                  Date&nbsp;&nbsp;&nbsp;&nbsp;: %s<br/>
197                  %s
198                  Samples : %d<br/>
199                  %s
200                  %s""" % ((': ' + args.title) if args.title else '',
201                           process_entry,
202                           datetime.datetime.now().strftime("%Y-%m-%d (%A) %H:%M:%S"),
203                           thread_entry,
204                           process.num_samples,
205                           event_entry,
206                           duration_entry))
207    if 'ro.product.model' in process.props:
208        f.write(
209            "Machine : %s (%s) by %s<br/>" %
210            (process.props["ro.product.model"],
211             process.props["ro.product.name"],
212             process.props["ro.product.manufacturer"]))
213    if process.cmd:
214        f.write("Capture : %s<br/><br/>" % process.cmd)
215    f.write("</div>")
216    f.write("""<br/><br/>
217            <div>Navigate with WASD, zoom in with SPACE, zoom out with BACKSPACE.</div>""")
218    f.write("<script>%s</script>" % get_local_asset_content("script.js"))
219    if not args.embedded_flamegraph:
220        f.write("<script>document.addEventListener('DOMContentLoaded', flamegraphInit);</script>")
221
222    # Sort threads by the event count in a thread.
223    for thread in sorted(process.threads.values(), key=lambda x: x.num_events, reverse=True):
224        thread_name = 'One flamegraph' if args.one_flamegraph else ('Thread %d (%s)' %
225                                                                    (thread.tid, thread.name))
226        f.write("<br/><br/><b>%s (%d samples):</b><br/>\n\n\n\n" %
227                (thread_name, thread.num_samples))
228        render_svg(process, thread.flamegraph, f, args.color)
229
230    f.write("</div>")
231    if not args.embedded_flamegraph:
232        f.write("</body></html")
233    f.close()
234    return "file://" + filepath
235
236
237def generate_threads_offsets(process):
238    for thread in process.threads.values():
239        thread.flamegraph.generate_offset(0)
240
241
242def collect_machine_info(process):
243    adb = AdbHelper()
244    process.props = {}
245    process.props['ro.product.model'] = adb.get_property('ro.product.model')
246    process.props['ro.product.name'] = adb.get_property('ro.product.name')
247    process.props['ro.product.manufacturer'] = adb.get_property('ro.product.manufacturer')
248
249
250def main():
251    # Allow deep callchain with length >1000.
252    sys.setrecursionlimit(1500)
253    parser = argparse.ArgumentParser(description="""Report samples in perf.data. Default option
254                                                    is: "-np surfaceflinger -f 6000 -t 10".""")
255    record_group = parser.add_argument_group('Record options')
256    record_group.add_argument('-du', '--dwarf_unwinding', action='store_true', help="""Perform
257                              unwinding using dwarf instead of fp.""")
258    record_group.add_argument('-e', '--events', default="", help="""Sample based on event
259                              occurences instead of frequency. Format expected is
260                              "event_counts event_name". e.g: "10000 cpu-cyles". A few examples
261                              of event_name: cpu-cycles, cache-references, cache-misses,
262                              branch-instructions, branch-misses""")
263    record_group.add_argument('-f', '--sample_frequency', type=int, default=6000, help="""Sample
264                              frequency""")
265    record_group.add_argument('--compile_java_code', action='store_true',
266                              help="""On Android N and Android O, we need to compile Java code
267                                      into native instructions to profile Java code. Android O
268                                      also needs wrap.sh in the apk to use the native
269                                      instructions.""")
270    record_group.add_argument('-np', '--native_program', default="surfaceflinger", help="""Profile
271                              a native program. The program should be running on the device.
272                              Like -np surfaceflinger.""")
273    record_group.add_argument('-p', '--app', help="""Profile an Android app, given the package
274                              name. Like -p com.example.android.myapp.""")
275    record_group.add_argument('--pid', type=int, default=-1, help="""Profile a native program
276                              with given pid, the pid should exist on the device.""")
277    record_group.add_argument('--record_file', default='perf.data', help='Default is perf.data.')
278    record_group.add_argument('-sc', '--skip_collection', action='store_true', help="""Skip data
279                              collection""")
280    record_group.add_argument('--system_wide', action='store_true', help='Profile system wide.')
281    record_group.add_argument('-t', '--capture_duration', type=int, default=10, help="""Capture
282                              duration in seconds.""")
283
284    report_group = parser.add_argument_group('Report options')
285    report_group.add_argument('-c', '--color', default='hot', choices=['hot', 'dso', 'legacy'],
286                              help="""Color theme: hot=percentage of samples, dso=callsite DSO
287                                      name, legacy=brendan style""")
288    report_group.add_argument('--embedded_flamegraph', action='store_true', help="""Generate
289                              embedded flamegraph.""")
290    report_group.add_argument('--kallsyms', help='Set the path to find kernel symbols.')
291    report_group.add_argument('--min_callchain_percentage', default=0.01, type=float, help="""
292                              Set min percentage of callchains shown in the report.
293                              It is used to limit nodes shown in the flamegraph. For example,
294                              when set to 0.01, only callchains taking >= 0.01%% of the event
295                              count of the owner thread are collected in the report.""")
296    report_group.add_argument('--max_callchain_depth', default=1000000000, type=int, help="""
297                              Set maximum depth of callchains shown in the report. It is used
298                              to limit the nodes shown in the flamegraph and avoid processing
299                              limits. For example, when set to 10, callstacks will be cut after
300                              the tenth frame.""")
301    report_group.add_argument('--no_browser', action='store_true', help="""Don't open report
302                              in browser.""")
303    report_group.add_argument('-o', '--report_path', default='report.html', help="""Set report
304                              path.""")
305    report_group.add_argument('--one-flamegraph', action='store_true', help="""Generate one
306                              flamegraph instead of one for each thread.""")
307    report_group.add_argument('--symfs', help="""Set the path to find binaries with symbols and
308                              debug info.""")
309    report_group.add_argument('--title', help='Show a title in the report.')
310    report_group.add_argument('--show_art_frames', action='store_true',
311                              help='Show frames of internal methods in the ART Java interpreter.')
312    report_group.add_argument('--proguard-mapping-file', nargs='+',
313                              help='Add proguard mapping file to de-obfuscate symbols')
314
315    debug_group = parser.add_argument_group('Debug options')
316    debug_group.add_argument('--disable_adb_root', action='store_true', help="""Force adb to run
317                             in non root mode.""")
318    args = parser.parse_args()
319    process = Process("", 0)
320
321    if not args.skip_collection:
322        if args.pid != -1:
323            process.pid = args.pid
324            args.native_program = ''
325        if args.system_wide:
326            process.pid = -1
327            args.native_program = ''
328
329        if args.system_wide:
330            process.name = 'system_wide'
331        else:
332            process.name = args.app or args.native_program or ('Process %d' % args.pid)
333        log_info("Starting data collection stage for '%s'." % process.name)
334        if not collect_data(args):
335            log_exit("Unable to collect data.")
336        if process.pid == 0:
337            result, output = AdbHelper().run_and_return_output(['shell', 'pidof', process.name])
338            if result:
339                try:
340                    process.pid = int(output)
341                except ValueError:
342                    process.pid = 0
343        collect_machine_info(process)
344    else:
345        args.capture_duration = 0
346
347    sample_filter_fn = None
348    if args.one_flamegraph:
349        def filter_fn(sample, _symbol, _callchain):
350            sample.pid = sample.tid = process.pid
351            return True
352        sample_filter_fn = filter_fn
353        if not args.title:
354            args.title = ''
355        args.title += '(One Flamegraph)'
356
357    try:
358        parse_samples(process, args, sample_filter_fn)
359        generate_threads_offsets(process)
360        report_path = output_report(process, args)
361        if not args.no_browser:
362            open_report_in_browser(report_path)
363    except RuntimeError as r:
364        if 'maximum recursion depth' in r.__str__():
365            log_fatal("Recursion limit exceeded (%s), try --max_callchain_depth." % r)
366        raise r
367
368    log_info("Flamegraph generated at '%s'." % report_path)
369
370
371if __name__ == "__main__":
372    main()
373