#!/usr/bin/env python # # Copyright (C) 2016 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """ Inferno is a tool to generate flamegraphs for android programs. It was originally written to profile surfaceflinger (Android compositor) but it can be used for other C++ program. It uses simpleperf to collect data. Programs have to be compiled with frame pointers which excludes ART based programs for the time being. Here is how it works: 1/ Data collection is started via simpleperf and pulled locally as "perf.data". 2/ The raw format is parsed, callstacks are merged to form a flamegraph data structure. 3/ The data structure is used to generate a SVG embedded into an HTML page. 4/ Javascript is injected to allow flamegraph navigation, search, coloring model. """ import argparse import datetime import os import subprocess import sys # pylint: disable=wrong-import-position SCRIPTS_PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) sys.path.append(SCRIPTS_PATH) from simpleperf_report_lib import ReportLib from utils import log_exit, log_info, AdbHelper, open_report_in_browser from data_types import Process from svg_renderer import get_proper_scaled_time_string, render_svg def collect_data(args): """ Run app_profiler.py to generate record file. """ app_profiler_args = [sys.executable, os.path.join(SCRIPTS_PATH, "app_profiler.py"), "-nb"] if args.app: app_profiler_args += ["-p", args.app] elif args.native_program: app_profiler_args += ["-np", args.native_program] elif args.pid != -1: app_profiler_args += ['--pid', str(args.pid)] elif args.system_wide: app_profiler_args += ['--system_wide'] else: log_exit("Please set profiling target with -p, -np, --pid or --system_wide option.") if args.compile_java_code: app_profiler_args.append("--compile_java_code") if args.disable_adb_root: app_profiler_args.append("--disable_adb_root") record_arg_str = "" if args.dwarf_unwinding: record_arg_str += "-g " else: record_arg_str += "--call-graph fp " if args.events: tokens = args.events.split() if len(tokens) == 2: num_events = tokens[0] event_name = tokens[1] record_arg_str += "-c %s -e %s " % (num_events, event_name) else: log_exit("Event format string of -e option cann't be recognized.") log_info("Using event sampling (-c %s -e %s)." % (num_events, event_name)) else: record_arg_str += "-f %d " % args.sample_frequency log_info("Using frequency sampling (-f %d)." % args.sample_frequency) record_arg_str += "--duration %d " % args.capture_duration app_profiler_args += ["-r", record_arg_str] returncode = subprocess.call(app_profiler_args) return returncode == 0 def parse_samples(process, args, sample_filter_fn): """Read samples from record file. process: Process object args: arguments sample_filter_fn: if not None, is used to modify and filter samples. It returns false for samples should be filtered out. """ record_file = args.record_file symfs_dir = args.symfs kallsyms_file = args.kallsyms lib = ReportLib() lib.ShowIpForUnknownSymbol() if symfs_dir: lib.SetSymfs(symfs_dir) if record_file: lib.SetRecordFile(record_file) if kallsyms_file: lib.SetKallsymsFile(kallsyms_file) if args.show_art_frames: lib.ShowArtFrames(True) process.cmd = lib.GetRecordCmd() product_props = lib.MetaInfo().get("product_props") if product_props: manufacturer, model, name = product_props.split(':') process.props['ro.product.manufacturer'] = manufacturer process.props['ro.product.model'] = model process.props['ro.product.name'] = name if lib.MetaInfo().get('trace_offcpu') == 'true': process.props['trace_offcpu'] = True if args.one_flamegraph: log_exit("It doesn't make sense to report with --one-flamegraph for perf.data " + "recorded with --trace-offcpu.""") else: process.props['trace_offcpu'] = False while True: sample = lib.GetNextSample() if sample is None: lib.Close() break symbol = lib.GetSymbolOfCurrentSample() callchain = lib.GetCallChainOfCurrentSample() if sample_filter_fn and not sample_filter_fn(sample, symbol, callchain): continue process.add_sample(sample, symbol, callchain) if process.pid == 0: main_threads = [thread for thread in process.threads.values() if thread.tid == thread.pid] if main_threads: process.name = main_threads[0].name process.pid = main_threads[0].pid for thread in process.threads.values(): min_event_count = thread.num_events * args.min_callchain_percentage * 0.01 thread.flamegraph.trim_callchain(min_event_count) log_info("Parsed %s callchains." % process.num_samples) def get_local_asset_content(local_path): """ Retrieves local package text content :param local_path: str, filename of local asset :return: str, the content of local_path """ with open(os.path.join(os.path.dirname(__file__), local_path), 'r') as f: return f.read() def output_report(process, args): """ Generates a HTML report representing the result of simpleperf sampling as flamegraph :param process: Process object :return: str, absolute path to the file """ f = open(args.report_path, 'w') filepath = os.path.realpath(f.name) if not args.embedded_flamegraph: f.write("<html><body>") f.write("<div id='flamegraph_id' style='font-family: Monospace; %s'>" % ( "display: none;" if args.embedded_flamegraph else "")) f.write("""<style type="text/css"> .s { stroke:black; stroke-width:0.5; cursor:pointer;} </style>""") f.write('<style type="text/css"> .t:hover { cursor:pointer; } </style>') f.write('<img height="180" alt = "Embedded Image" src ="data') f.write(get_local_asset_content("inferno.b64")) f.write('"/>') process_entry = ("Process : %s (%d)<br/>" % (process.name, process.pid)) if process.pid else "" thread_entry = '' if args.one_flamegraph else ('Threads: %d<br/>' % len(process.threads)) if process.props['trace_offcpu']: event_entry = 'Total time: %s<br/>' % get_proper_scaled_time_string(process.num_events) else: event_entry = 'Event count: %s<br/>' % ("{:,}".format(process.num_events)) # TODO: collect capture duration info from perf.data. duration_entry = ("Duration: %s seconds<br/>" % args.capture_duration ) if args.capture_duration else "" f.write("""<div style='display:inline-block;'> <font size='8'> Inferno Flamegraph Report%s</font><br/><br/> %s Date : %s<br/> %s Samples : %d<br/> %s %s""" % ((': ' + args.title) if args.title else '', process_entry, datetime.datetime.now().strftime("%Y-%m-%d (%A) %H:%M:%S"), thread_entry, process.num_samples, event_entry, duration_entry)) if 'ro.product.model' in process.props: f.write( "Machine : %s (%s) by %s<br/>" % (process.props["ro.product.model"], process.props["ro.product.name"], process.props["ro.product.manufacturer"])) if process.cmd: f.write("Capture : %s<br/><br/>" % process.cmd) f.write("</div>") f.write("""<br/><br/> <div>Navigate with WASD, zoom in with SPACE, zoom out with BACKSPACE.</div>""") f.write("<script>%s</script>" % get_local_asset_content("script.js")) if not args.embedded_flamegraph: f.write("<script>document.addEventListener('DOMContentLoaded', flamegraphInit);</script>") # Sort threads by the event count in a thread. for thread in sorted(process.threads.values(), key=lambda x: x.num_events, reverse=True): thread_name = 'One flamegraph' if args.one_flamegraph else ('Thread %d (%s)' % (thread.tid, thread.name)) f.write("<br/><br/><b>%s (%d samples):</b><br/>\n\n\n\n" % (thread_name, thread.num_samples)) render_svg(process, thread.flamegraph, f, args.color) f.write("</div>") if not args.embedded_flamegraph: f.write("</body></html") f.close() return "file://" + filepath def generate_threads_offsets(process): for thread in process.threads.values(): thread.flamegraph.generate_offset(0) def collect_machine_info(process): adb = AdbHelper() process.props = {} process.props['ro.product.model'] = adb.get_property('ro.product.model') process.props['ro.product.name'] = adb.get_property('ro.product.name') process.props['ro.product.manufacturer'] = adb.get_property('ro.product.manufacturer') def main(): # Allow deep callchain with length >1000. sys.setrecursionlimit(1500) parser = argparse.ArgumentParser(description="""Report samples in perf.data. Default option is: "-np surfaceflinger -f 6000 -t 10".""") record_group = parser.add_argument_group('Record options') record_group.add_argument('-du', '--dwarf_unwinding', action='store_true', help="""Perform unwinding using dwarf instead of fp.""") record_group.add_argument('-e', '--events', default="", help="""Sample based on event occurences instead of frequency. Format expected is "event_counts event_name". e.g: "10000 cpu-cyles". A few examples of event_name: cpu-cycles, cache-references, cache-misses, branch-instructions, branch-misses""") record_group.add_argument('-f', '--sample_frequency', type=int, default=6000, help="""Sample frequency""") record_group.add_argument('--compile_java_code', action='store_true', help="""On Android N and Android O, we need to compile Java code into native instructions to profile Java code. Android O also needs wrap.sh in the apk to use the native instructions.""") record_group.add_argument('-np', '--native_program', default="surfaceflinger", help="""Profile a native program. The program should be running on the device. Like -np surfaceflinger.""") record_group.add_argument('-p', '--app', help="""Profile an Android app, given the package name. Like -p com.example.android.myapp.""") record_group.add_argument('--pid', type=int, default=-1, help="""Profile a native program with given pid, the pid should exist on the device.""") record_group.add_argument('--record_file', default='perf.data', help='Default is perf.data.') record_group.add_argument('-sc', '--skip_collection', action='store_true', help="""Skip data collection""") record_group.add_argument('--system_wide', action='store_true', help='Profile system wide.') record_group.add_argument('-t', '--capture_duration', type=int, default=10, help="""Capture duration in seconds.""") report_group = parser.add_argument_group('Report options') report_group.add_argument('-c', '--color', default='hot', choices=['hot', 'dso', 'legacy'], help="""Color theme: hot=percentage of samples, dso=callsite DSO name, legacy=brendan style""") report_group.add_argument('--embedded_flamegraph', action='store_true', help="""Generate embedded flamegraph.""") report_group.add_argument('--kallsyms', help='Set the path to find kernel symbols.') report_group.add_argument('--min_callchain_percentage', default=0.01, type=float, help=""" Set min percentage of callchains shown in the report. It is used to limit nodes shown in the flamegraph. For example, when set to 0.01, only callchains taking >= 0.01%% of the event count of the owner thread are collected in the report.""") report_group.add_argument('--no_browser', action='store_true', help="""Don't open report in browser.""") report_group.add_argument('-o', '--report_path', default='report.html', help="""Set report path.""") report_group.add_argument('--one-flamegraph', action='store_true', help="""Generate one flamegraph instead of one for each thread.""") report_group.add_argument('--symfs', help="""Set the path to find binaries with symbols and debug info.""") report_group.add_argument('--title', help='Show a title in the report.') report_group.add_argument('--show_art_frames', action='store_true', help='Show frames of internal methods in the ART Java interpreter.') debug_group = parser.add_argument_group('Debug options') debug_group.add_argument('--disable_adb_root', action='store_true', help="""Force adb to run in non root mode.""") args = parser.parse_args() process = Process("", 0) if not args.skip_collection: if args.pid != -1: process.pid = args.pid args.native_program = '' if args.system_wide: process.pid = -1 args.native_program = '' if args.system_wide: process.name = 'system_wide' else: process.name = args.app or args.native_program or ('Process %d' % args.pid) log_info("Starting data collection stage for '%s'." % process.name) if not collect_data(args): log_exit("Unable to collect data.") if process.pid == 0: result, output = AdbHelper().run_and_return_output(['shell', 'pidof', process.name]) if result: try: process.pid = int(output) except ValueError: process.pid = 0 collect_machine_info(process) else: args.capture_duration = 0 sample_filter_fn = None if args.one_flamegraph: def filter_fn(sample, _symbol, _callchain): sample.pid = sample.tid = process.pid return True sample_filter_fn = filter_fn if not args.title: args.title = '' args.title += '(One Flamegraph)' parse_samples(process, args, sample_filter_fn) generate_threads_offsets(process) report_path = output_report(process, args) if not args.no_browser: open_report_in_browser(report_path) log_info("Flamegraph generated at '%s'." % report_path) if __name__ == "__main__": main()